Skip to content

fix(core): read an element in a spread attribute's argument or an attribute value without braces as template markup (#656) - #713

Merged
leonidaz merged 3 commits into
mainfrom
fix/core-spread-argument-markup
Sep 25, 2026
Merged

leonidaz merged 3 commits into
mainfrom
fix/core-spread-argument-markup

Conversation

@leonidaz

@leonidaz leonidaz commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Closes #656.

An element in a spread attribute's argument (<div {...(cond ? <b>x</b> : null)} />, {...{ children: <b>@if (x) { … }</b> }}) and an element that is an attribute value without braces (<div attr=<b>…</b> />) are now TSRX template markup, as an element in a braced value (attr={<b>…</b>}) is. This follows your decision on #596 (decision 24). #695's findings said the unbraced value should follow the same rule, and it turned out to be clean, so both are here.

The bug

The fix

All the parser changes are in packages/tsrx/src/plugin.js, where these two positions are parsed:

  • #parseOpeningTagCode reads a spread's argument and an unbraced element value as a braced value's container is read (see jsx_parseExpressionContainer):

    • it hides #openingNativeTemplateNode;
    • it raises #jsxExpressionContainerDepth;
    • it pushes the context and path baselines.

    So elements there are template markup. The code around them isn't template text, so a /, a #, or a regular expression in the argument still reads as code ([Bug]: tsrx-core: A / in the opening tag of an element in a template is read as text: <div / > and division in a spread attribute fail #655's tests are unchanged). The token after the code is read in the tag again.

  • The spread no longer raises #templateScriptParsingDepth. The container state covers what that did.

  • The spread's } drops contexts that a directive left, as a container's } does (jsx_parseExpressionContainer's truncation). Without this, <div><p {...{ k: <i>@if (x) { <b /> }</i>, j: 1 }}>t</p></div> failed at the >.

  • An element that is an unbraced value keeps its host's opening-tag contexts below it.

    • parseElement's strip of trailing tag contexts stops at #tagValueContextDepth, the depth before the value's <.
    • The element closes outside the template (#tagValueElements in closes_outside_template), so the host's tag goes on after </b>.
    • Without this, const a = <div c=<b></b>></div>; and <div><p k=<i>@if (x) { <b /> }</i>>t</p></div> failed at the host's >.

It's narrow because each change applies only to code in an opening tag outside braces. That is a spread argument or an unbraced element value, which is exactly what a braced value already did. The container state, the context truncation, and the context floor are the ones a braced value uses.

Three comments described the old behavior, and I updated them:

  • the non-native branch in transform/jsx/index.js, now reached only by an element in a dynamic tag name;
  • a fixture comment;
  • a test comment.

No override: the cause is in TSRX.

What changes in markup that parses today

Every input in the comparison whose AST changes. All of them change only inside a spread argument or an unbraced element value (checked by masking those subtrees: the rest of each AST, its comments, and its errors are identical).

Comments that were text are now comments, attached to the element. These are #654's test inputs, and I updated their expectations in parser.test.js > an element as an attribute value without braces (#654):

  • <div attr=<b>/* c */</b> />
  • <b>a /* c */ b</b>, whose text is now a b
  • <b>// c
    </b>
    
    <b> // c
    </b>
    
    <b>
    // c
    </b>
    
  • <>/* c */</>
  • <b>a<i>/* c */</i></b>
  • the same element in a template, in a setup statement, and in a function's return

A whitespace-only text with a line break is dropped, as in a braced value. It rendered nothing before either, since JSX drops it:

  • tsrx-prettier-plugin/tests/prettier/jsx/attr-element.snap: the output below and its fragment twin.

    <Foo
      prop=<Bar>
        <Baz />
      </Bar>
    />
    
  • prettier-plugin/src/index.test.js:

    const a = (
      <Foo
        prop=<Bar>…
    

The host loses the stray openingFragment/closingFragment keys:

  • attr-element.snap's input and output, second statement
  • prettier-plugin/src/index.test.js: const el = <div attr=/* a */ <i /> c=/* d */ <></>></div>;

The mismatched closing tag message changes, in the updated test reports a closing tag that does not match it as in a braced value:

  • Input:

    const el = <div attr= <b>// c
    "foo">text</div>;
    
  • It reports TSRX's Expected closing tag to match opening tag. Expected '</b>' but found '</div>' (2:10), and Unexpected closing tag (2:10) in collect and loose mode, as the braced value does.

  • Before, it reported acorn-typescript's Expected corresponding JSX closing tag for <b>.

Only the element's metadata changes (native_tsrx, templateMode); the tree is the same:

Compiled output changes only for these inputs: the comments leave the text, the whitespace-only lines go, and that one binding is renamed.

Rendered output on the targets

The shared runtime suite from #615, #641 and #657 (jsx-text-whitespace*) gets 5 cases, rendered on React, Preact, Solid, Vue, and Hono in jsdom. I added the Hono loop too, since Hono imports the case lists directly.

  • These compare with what React renders for the same TSX without the comments:
    • a block comment in an element in a spread argument (<b>t</b> /* c */2);
    • a line comment in the same place;
    • a block comment in an unbraced value.
  • A new list compares with the same markup in a braced value, because a directive renders differently per target (Vue adds an <!--if--> anchor):
    • an @if in an element in a spread argument;
    • an @if in an unbraced value.
  • A shared compile test checks the text and the compiled @if in each compiler's output.

All of them fail on main, and they pass on all five targets here.

Formatters

Verification

Each comparison parses with main's parser (3a33f715) and this branch's. It parses strict, with { collect: true, errors: [], comments: [], preserveParens: true }, and with { loose: true }, in 4 workers with a hard timeout.

  • Corpus, 19,118 inputs:

    • the 70 tracked .tsrx files;
    • the input and output of all 2,040 Prettier snapshot cases (readSnapshotEntries/readFormatCase);
    • 14,968 distinct string literals from test files.

    No hangs. 45 change: the 32 listed above, and 13 of this PR's new test inputs. Two of the new inputs failed on main: an @switch and a @{ … } in an element in a spread argument.

  • Mutated inputs, 22,234, made from the corpus inputs that have a tag:

    • the mutations: truncated, a span deleted, or one of these inserted, mostly next to a tag: a comment, a /, a brace, a closing tag, =<b>…</b>, =<>t</>, a spread with an element, an @if, or a >;
    • no hangs, and 243 change;
    • every AST change is confined to a spread argument or an unbraced element value (155 strict, 171 collect, 171 loose);
    • 6 failed and parse now;
    • 1 parsed and fails now: const el = <div attr=<>//* c */</> />;. The // starts a line comment in template text, so the fragment is unclosed (Unterminated JSX contents). The braced value attr={<>//* c */</>} reports the same.
    • The rest report a different syntax error in invalid markup, the one the same markup reports in a braced value, which I sampled:
  • TSRX against TSX. fix(core): read the text of elements in {…} containers and switch cases like everywhere else (#612, #613) #641's 464 shapes in 70 places (32,480 sources): fix(core): read the text of elements in setup statements as template text, and report syntax errors in element values (#636, #638) #657's 56 places, and 14 new spread and unbraced places.

    • The new places cover a spread in a conditional, nested, in another spread, in a return, in a container, and in an @if, and unbraced values in a template, a nested element, before an attribute, in a setup statement, in a return, in a container, and in a spread.
    • main: 3,712 mismatches, all in the 16 spread and unbraced places, 232 each: the comment shapes.
    • This branch: 0 mismatches, 0 errors.
  • Against a braced value. 10,296 sources, in three modes:

    • 26 element contents, 6 attribute forms, 11 places, and 6 tag endings;
    • the forms: object, conditional, array, object with more properties, arrow, and unbraced;
    • the endings: />, >t</p>, another attribute, a spread, no space, and a line break.

    Each <i> element parses identically to the one in the braced value, with its host's children, comments, and errors: 30,888 of 30,888.

  • Tests. vitest run --maxWorkers=4 for tsrx-utils, tsrx-analyze, tsrx-vite, the five compilers, the five runtime projects, prettier-plugin, and tsrx-prettier-plugin: 53 files, 9,808 passed, 77 skipped. New and updated tests:

    • parser.test.js, an element as an attribute value without braces (#654): 14 shapes. The 10 comment shapes are updated, and @if is new. A mismatched closing tag is compared with the braced value. A guard checks that an element in a dynamic tag name still keeps its comment as text.
    • parser.test.js, an element in a spread attribute's argument (#656): 9 shapes (comments, every directive, text) and 6 equivalence tests. Each form is compared with a braced value over 6 contents, 4 places, and 3 endings, in three modes.
    • the compile test and the runtime cases above.

    All of them fail on main except the guards.

  • pnpm typecheck passes. tsrx-tsc isn't linked after pnpm install --ignore-scripts, so I built @tsrx/typescript-plugin and ran the @tsrx/vite-plugin-hono typecheck:tests step through packages/typescript-plugin/dist/tsc.js, then the steps after it.

  • pnpm format changed nothing else, and pnpm format:check and pnpm changeset:check pass. Changeset: patch for @tsrx/core.

Findings

Filed:

Same cause as here, left as is:

For the user:

🤖 Generated with Claude Code


Note

Medium Risk
Parser and AST semantics change for markup embedded in opening-tag expressions; output changes for sources that relied on the old plain-JSX behavior, though the scope is limited to spread arguments and unbraced attribute values.

Overview
Elements inside spread attribute arguments ({...{ k: <b>…</b> }}, {...(cond ? <b>…</b> : null)}) and unbraced attribute values (k=<b>…</b>) are now parsed as TSRX template markup, matching k={<b>…</b>}.

The parser adds #parseOpeningTagCode to treat those opening-tag regions like {…} expression containers (hide #openingNativeTemplateNode, bump #jsxExpressionContainerDepth, restore context baselines). Spread parsing drops #templateScriptParsingDepth in favor of that path, truncates leftover contexts after a spread }, and tracks tag-value elements so their closing tags restore the host’s opening-tag tokenizer state instead of a template.

Behavioral fixes: comments become real comments (not text), @if/@for/etc. are directives (not literal text + containers), template text/whitespace rules apply, and mismatched closing tags use TSRX’s messages. The JSX transform comment is narrowed so the “wrap plain JSX setup” path applies only to dynamic tag names (<{…}>), not spread/unbraced markup.

Tests and runtime cases assert parity with braced values and updated compile expectations; @tsrx/core gets a patch changeset.

Reviewed by Cursor Bugbot for commit 3e79d50. Bugbot is set up for automated code reviews on this repo. Configure here.

…ribute value without braces as template markup (#656)

While an opening tag is read, the element it opens is
`#openingNativeTemplateNode`, still in script mode, so `jsx_parseElement`
handed an element in a spread attribute's argument (`<div {...{ k: <b>…</b> }} />`)
or in an attribute value without braces (`<div k=<b>…</b> />`) to
acorn-typescript's JSX parser. Its comments were text, and `@if` was text and
a container. A braced value already cleared the node.

`#parseOpeningTagCode` now reads both as a braced value's container is read:
it hides the opening node and sets the container depth and baselines, so the
elements are template markup and the code around them isn't template text.
The spread's `}` drops contexts a directive left, as a container's does. An
element that is a value without braces keeps its host's opening tag contexts
below it and closes outside the template, so the tag goes on after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@leonidaz
leonidaz merged commit f73b676 into main Sep 25, 2026
8 checks passed
@leonidaz
leonidaz deleted the fix/core-spread-argument-markup branch September 25, 2026 23:57
leonidaz added a commit that referenced this pull request Sep 26, 2026
#656 (#713) reads an element in a spread attribute's argument or an
unbraced attribute value as template markup. Its text is template text now,
so a `>` there is text and prints as `&gt;`, and its references stay in
`raw`. The runtime suites keep both #656's braced-value cases and this
branch's character cases. The tests for #693's places now check `raw`
there, and check acorn's decoded `value` in an element in a dynamic tag
name, which acorn-typescript's JSX parser still reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
leonidaz added a commit that referenced this pull request Sep 26, 2026
The next release changes the language: elements in spread arguments and
unbraced attribute values become template markup (#713), and several
parser fixes reject code that used to parse. Mark #713's changeset as a
minor so the release is 0.5.0 and consumers on ^0.4.0 upgrade on purpose.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

[Bug]: tsrx-core: An element in a spread attribute's argument is parsed as plain JSX: comments and template syntax read as text

1 participant