Skip to content

Remove Hybrid Tag - #1309

Draft
stephanie-shopify wants to merge 4 commits into
mainfrom
standalone-section-tag
Draft

stephanie-shopify wants to merge 4 commits into
mainfrom
standalone-section-tag

Conversation

@stephanie-shopify

@stephanie-shopify stephanie-shopify commented Sep 29, 2026 •

Copy link
Copy Markdown

What are you adding in this PR?

Parses section as a standalone tag and removes the hybrid tag kind.

The hand-written parser treated section as a hybrid tag: every {% section %} scanned forward through the rest of the document (or the rest of a {% liquid %} body) looking for a matching {% endsection %}. Files with many section tags parsed in quadratic time.

Ruby Liquid has no block form for section (the parity corpus already records {% section 'foo' %}{% endsection %} as Unknown tag 'endsection'), so the hybrid path wasn't modelling real Liquid. Its only consumer was Theme Check, which used the block form to report that error.

Changes:

  • sectionTag is now TagKind.Tag. Removed TagKind.Hybrid, TagDefinitionHybrid, liquid-hybrid.ts and parseLineHybridTag.
  • Stray end tags for registered tags still throw structural errors, with one exception: {% endsection %} now parses as an unknown tag, and LiquidSyntaxError reports it as Unknown tag 'endsection' (same message as before). Stray {% endrender %}, {% endsections %}, etc. are unchanged.
  • laxRecoverTagMarkup only recovers Tag/Block kinds, so hybrid section was never recovered. It now excludes section explicitly to keep that behavior (Ruby's section raises in every error mode).
  • Updated the parser spec docs.

Intentional behavior differences, all on Liquid that Ruby already rejects:

  • An orphan {% endsection %} used to be a parse error, so every other check skipped the file. Now it's an unknown-tag offense and the other checks still run.
  • An endsection line inside {% liquid %} reports Unexpected end tag 'endsection' in {% liquid %} block, same as a stray endif.
  • An endsection inside an HTML element (<p>{% section 'x' %}y{% endsection %}</p>) reports LiquidHTMLSyntaxError. That's the existing behavior for any unknown end* tag inside HTML (<p>{% endfoo %}</p> does the same on main).

Parse time for N consecutive section tags:

N {% section 'x' %} before after {% liquid %} lines before after
2,000 82ms 5ms 12ms 4ms
4,000 311ms 5ms 39ms 4ms
8,000 1210ms 8ms 136ms 6ms
16,000 4790ms 18ms 530ms 15ms

What's next? Any followup issues?

None.

Tophatting

Before/after harness (theme-check CLI + Prettier + parser timing, main build vs this branch):

  • Fixture theme covering valid section usage (incl. {% liquid %} and nested in if), missing section files (MissingTemplate), bad markup and each stray-endsection case. The only theme-check diffs are the intentional ones listed above. Prettier output also changes for those stray-endsection inputs. Most changes are cosmetic, but <p>{% section 'x' %}y{% endsection %}</p> gets mangled (see review thread on ast.test.ts).
  • A real exported theme (Tailor): identical theme-check offenses (5506 before and after), and identical Prettier output for every .liquid file.
  • 20k section tags: 7.3s → 26ms (document), 1.0s → 26ms ({% liquid %}).

Manual (VS Code extension, Debug Node Extension):

  • No diagnostics on valid section tags. Cmd-click on the section name opens the section file.
  • MissingTemplate on missing sections. Unknown tag 'endsection' on stray endsection.
  • Renaming a section file updates {% section %} references.
  • Large many-section file stays responsive.

Tests: vitest across liquid-html-parser, theme-check-common, prettier-plugin-liquid, theme-graph and theme-language-server-common all pass (6714 tests, rebased on 2066abfe).

Before you deploy

  • I included a patch bump changeset

* +section+ is a standalone tag, so +{% endsection %}+ parses as an unknown
* tag. Ruby Liquid reports it the same way.
*/
export function checkEndsectionTag(node: LiquidTag, context: Context): void {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if this is correct behavior to add

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This moves existing behavior rather than adding new behavior. On main, LiquidSyntaxError already reports Unknown tag 'endsection', from inside checkSectionTag via the section node's blockEndPosition. That only exists because of the hybrid block form. With section standalone, endsection is its own LiquidTag node, so the report moves to a checker keyed on endsection. The message and range are the same, and it matches Ruby Liquid (Shopify core asserts Unknown tag 'endsection' for the legacy block form, and the parity corpus expects it).

That said, it's only here because the parser change moves endsection. If we keep the hybrid tag and only fix the scan (see the thread on ast.test.ts), this goes away and checkSectionTag stays as it is on main.

// Re-export all tag definition types so existing imports from './environment' keep working.
export {
TagKind,
isStructuralEndTag,

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this added?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plumbing, not API. liquid-tags.ts already imports TagKind from ../environment, so I put the helper there too. But the re-export comment says it's there "so existing imports keep working", and the tag files import from ../tag-definitions directly, so a new symbol arguably doesn't belong here. environment isn't exported from the package index.ts, so nothing external sees it. If we keep this approach I'll import from ../tag-definitions and drop the re-export.

}
});

it('should throw on orphaned endsection', () => {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why was this here? why does removing hybrid tag change it?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On main, any end tag for a registered tag with no opener is a parse error ({% endrender %}, {% endif %}, …). section is registered, so a lone {% endsection %} threw too. With the hybrid form, a paired endsection was legitimate, and this test pinned that difference: paired parses, orphan throws.

Once section isn't a block, every endsection is effectively an orphan. Left alone, the legacy {% section %}…{% endsection %} form would go from "Unknown tag, other checks still run" to "whole file fails to parse" (theme-check skips every other check on files that fail to parse). So I exempted endsection from that throw, which is what flips this test. Other tags' end-tag errors are unchanged, and there's a regression test for that.

This, the checker move and the new export all follow from removing the block form. They also cause a few remaining differences on already-invalid Liquid, which the tophat caught: different messages for a lone endsection, one in {% liquid %} and one inside HTML, and Prettier output changes for those. <p>{% section 'x' %}y{% endsection %}</p> gets mangled.

Alternative with zero behavior change: keep section hybrid and replace the per-tag forward scan with one pass that pairs each section with its endsection (like matching parentheses), per document and per {% liquid %} block. That's still linear in the worst case, and theme-check, Prettier and all existing tests stay as on main. Removing the block form could then be a separate follow-up where the behavior change is the explicit point. I'm leaning that way. I'll check with CP, since removing hybrid was their suggestion.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternative with zero behavior change: keep section hybrid and replace the per-tag forward scan with one pass that pairs each section with its endsection

dont want to do this. defeats purpose of removing hybrid section tag.

Ruby Liquid has no block form for section, but the parser treated it as a
hybrid tag and scanned forward for a matching endsection on every section
tag (both in documents and in {% liquid %} bodies). Files with many section
tags parsed in quadratic time.

Make section a plain TagKind.Tag and remove the hybrid tag kind. Theme
Check still reports a stray endsection as "Unknown tag 'endsection'" by
mapping the parser error.
The previous commit made a stray endsection throw "Attempting to close
LiquidTag 'section'". In a full Theme Check run that turns the file's AST into
an error, so the only offense was LiquidHTMLSyntaxError and every other check
skipped the file.

Only tags with a body (Block/Raw) have end tags, so only those now throw the
structural close errors. endsection parses as an unknown tag like any other
end* name, and LiquidSyntaxError reports it as "Unknown tag 'endsection'".
laxRecoverTagMarkup only recovers Tag/Block kinds, so section (previously
Hybrid) was never recovered. Now that section is a Tag it would be, and
{% section 'x' junk %} would recover and render, while Ruby's section tag
raises in every error mode. Exclude it explicitly to keep the previous
behavior.
The previous change stopped throwing structural close errors for every
registered tag without a body, so stray {% endrender %}, {% endecho %},
{% endsections %}, etc. silently parsed as unknown tags instead of failing
as they do on main. Only endsection needs the exception. Restore the
existing behavior for everything else and add a regression test.
@stephanie-shopify stephanie-shopify changed the title Parse section as a standalone tag Remove Hybrid Tag Sep 30, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant