Skip to content

Remove Hybrid Tag - #1309

Merged
stephanie-shopify merged 6 commits into
mainfrom
standalone-section-tag
Oct 15, 2026
Merged

stephanie-shopify merged 6 commits into
mainfrom
standalone-section-tag

Conversation

@stephanie-shopify

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

Copy link
Copy Markdown
Contributor

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

Comment thread packages/theme-check-common/src/checks/liquid-syntax-error/section.ts Outdated
Comment thread packages/liquid-html-parser/src/environment.ts
Comment thread packages/liquid-html-parser/src/ast.test.ts
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

@charlespwd charlespwd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we're almost there but I don't think we want to leave anything that leaves a hint that this was ever a thing. We never merged hybrid. New agents should not have to wonder about endsection ever being a thing

Comment on lines +44 to +52
/**
* Whether a stray `{% end<name> %}` for a registered tag is a structural parse
* error. `endsection` is the exception: `section` is standalone, and Ruby
* Liquid reports `{% endsection %}` as an unknown tag (the legacy block form
* was removed), so it parses as an unknown tag and Theme Check reports it.
*/
export function isStructuralEndTag(innerName: string, def: { kind: string } | undefined): boolean {
return def !== undefined && innerName !== 'section';
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I feel like that's "legacy" noise. It should just throw. This was never a thing. We want something like "design by omission" here.

the innerName !== 'section' is sus.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in f7d0355 — removed the section exception from isStructuralEndTag, so endsection now throws through the same structural end-tag path as the other registered tags.

Comment on lines +93 to +95
// Ruby's `section` tag has no lax parse: it raises on anything but
// `section '[type]'` in every error mode, so don't recover it here.
if (!def || tagName === 'section' || (def.kind !== TagKind.Tag && def.kind !== TagKind.Block)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

feels off to have tagName == 'seciton' here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in f7d0355 — removed the tagName === "section" special case from lax recovery.

@karreiro karreiro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you, @stephanie-shopify!

I agree with the point about removing hybrid support entirely. Apart from that, the PR looks good to me, and with that change applied we'll be ready to proceed with the merge 🚀

@stephanie-shopify
stephanie-shopify marked this pull request as ready for review October 15, 2026 16:48
@stephanie-shopify
stephanie-shopify requested a review from a team as a code owner October 15, 2026 16:48
Comment on lines +647 to +649
### D12: `section` is a standalone tag

When parser encounters `{% section %}`, it scans forward in the token array for `{% endsection %}`. The array is materialized, so scanning is a cheap index walk.
`section` is a plain `TagKind.Tag`. Ruby Liquid has no block form, so `{% endsection %}` is a structural error.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Does it even need mentioning ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call — removed the section-specific decision note in 947a33a.

Comment on lines +760 to +762
### 6.4 Section Tag

**Detection:** When parser encounters `{% section %}`, it scans forward in the token array for `{% endsection %}`, respecting nesting. If found, parse as block. Otherwise, standalone.

**Markup for both forms:** `SectionMarkup { name: LiquidString, args: LiquidNamedArgument[] }`. Same regardless of form. The difference is whether `children` is populated and `blockEndPosition` exists.
`section` is standalone only (`{% section 'name' %}`), producing `SectionMarkup { name: LiquidString, args: LiquidNamedArgument[] }`. See D12.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is no longer special I think? Does it need special handling?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep, no longer special. Removed this section-specific deep dive and renumbered the following sections in 947a33a.

@charlespwd charlespwd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor doc nit but we're good to merge after :)

@stephanie-shopify
stephanie-shopify merged commit ee5ab68 into main Oct 15, 2026
8 checks passed
@stephanie-shopify
stephanie-shopify deleted the standalone-section-tag branch October 15, 2026 17:37
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.

3 participants