Repository navigation
fix(core): read an element in a spread attribute's argument or an attribute value without braces as template markup (#656) - #713
Merged
Conversation
…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
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 `>`, 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>
This was referenced Sep 26, 2026
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>
This was referenced Sep 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
#currentNativeTemplateNodereturns the element being opened, which is still in script mode. Sojsx_parseElementhanded an element in that tag to acorn-typescript's JSX parser, and the element was plain JSX.<b><i /> /* c */ 2</b>rendered/* c */ 2.@ifwas text followed by a{…}container:<b>@if (x) { <i /> }</b>rendered@if (x)and then the element.jsx_parseAttributeValuealready cleared#openingNativeTemplateNodearound a braced value, but a spread's argument and an unbraced value didn't./in an opening tag as code (#653, #654, #655) #695). The same cause. fix(core): read comments in an element attribute value as text, report a closing tag where an element starts, and read a/in an opening tag as code (#653, #654, #655) #695 made a comment there text, as in TSX, to stop a runaway parse. The rest of the element was plain JSX too.main. A plain-JSX fragment as an unbraced value (<Foo prop=<>…</> />) went through TSRX'sjsx_parseOpeningElementAt, which stamped the host element with the fragment's slots. The hostJSXElementgot strayopeningFragment/closingFragmentkeys. That's gone now, since the fragment has its own node.The fix
All the parser changes are in
packages/tsrx/src/plugin.js, where these two positions are parsed:#parseOpeningTagCodereads a spread's argument and an unbraced element value as a braced value's container is read (seejsx_parseExpressionContainer):#openingNativeTemplateNode;#jsxExpressionContainerDepth;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<.#tagValueElementsincloses_outside_template), so the host's tag goes on after</b>.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:
transform/jsx/index.js, now reached only by an element in a dynamic tag name;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 nowa b<>/* c */</><b>a<i>/* c */</i></b>returnA 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.prettier-plugin/src/index.test.js:The host loses the stray
openingFragment/closingFragmentkeys:attr-element.snap's input and output, second statementprettier-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:
It reports TSRX's
Expected closing tag to match opening tag. Expected '</b>' but found '</div>' (2:10), andUnexpected 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:expression-spread-ref-components.tsrxjsx-test-suite.snap0019 and 0020, input and output (<LeftRight left=<a /> right=<b>monkeys …</b> />)prettier-plugin/src/index.test.js:const a = <Foo prop=<Bar><Baz /></Bar> />; …x = <div {...<b />} />;x = <div {...(<b />)} />;attr=<i /> // cparser.test.js:<div {...{ a: b ? c : d, e: <i>1</i> }}> 3</div>/in the opening tag of an element in a template is read as text:<div / >and division in a spread attribute fail #655's<div a=<b / > />and<div a=<b /> />type-only-jsx-analysis.test.js's<svg {...{ children: props.rows.map(…) }} />. The generated binding for the<text>host is now namedTSRXTemplate__spread_props1instead of_tsrx_spread_props_1on every target, and the test passes unchanged.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.<b>t</b> /* c */2);<!--if-->anchor):@ifin an element in a spread argument;@ifin an unbraced value.@ifin each compiler's output.All of them fail on
main, and they pass on all five targets here.Formatters
@tsrx/prettier-pluginformats every source, and each output is stable and renders the same.tsrx-prettier-plugindoes too, except 80 sources in each set: the same 8 shapes with a block comment before the closing tag or at the start, in both sets ([Bug]: tsrx-prettier-plugin changes what an element renders when a block comment sits between its children #639).@if,@for,@switch,@try,@{ … }), comments, containers, and nested spread and unbraced values in these positions, and their braced twins.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:
.tsrxfiles;readSnapshotEntries/readFormatCase);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@switchand a@{ … }in an element in a spread argument.Mutated inputs, 22,234, made from the corpus inputs that have a tag:
/, a brace, a closing tag,=<b>…</b>,=<>t</>, a spread with an element, an@if, or a>;const el = <div attr=<>//* c */</> />;. The//starts a line comment in template text, so the fragment is unclosed (Unterminated JSX contents). The braced valueattr={<>//* c */</>}reports the same.Unclosed tag '<b>';<([Bug]: tsrx-core: A closing tag where a statement's expression starts fails withRangeError: Invalid array length#653);Expected corresponding JSX closing tag for <undefined>is gone.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.
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 areturn, in a container, and in a spread.main: 3,712 mismatches, all in the 16 spread and unbraced places, 232 each: the comment shapes.Against a braced value. 10,296 sources, in three modes:
/>,>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=4fortsrx-utils,tsrx-analyze,tsrx-vite, the five compilers, the five runtime projects,prettier-plugin, andtsrx-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@ifis 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.All of them fail on
mainexcept the guards.pnpm typecheckpasses.tsrx-tscisn't linked afterpnpm install --ignore-scripts, so I built@tsrx/typescript-pluginand ran the@tsrx/vite-plugin-honotypecheck:testsstep throughpackages/typescript-plugin/dist/tsc.js, then the steps after it.pnpm formatchanged nothing else, andpnpm format:checkandpnpm changeset:checkpass. Changeset:patchfor@tsrx/core.Findings
Filed:
found '</null>'instead offound '</>'#712: an element closed by a fragment's closing tag (<b>x</>) reportsfound '</null>'. The mutated inputs hit it; it's the same onmain.Same cause as here, left as is:
<{c ? <b>/* c */</b> : 'div'} />) is still read by acorn-typescript's JSX parser, so it's plain JSX.#parseJSXDynamicElementNamereads its container while#openingNativeTemplateNodeis set. fix(tsrx-prettier-plugin): keep a comment in a method's type parameters, and what an element renders around comments between its children (#630, #639) #596's decision covers spread arguments, so I didn't change this position. If it should follow, it's the same#parseOpeningTagCodearound that container.#insideScriptJSXElementand thejsx_parseTextguard from fix(core): read comments in an element attribute value as text, report a closing tag where an element starts, and read a/in an opening tag as code (#653, #654, #655) #695 now protect only this position, and a test keeps it covered.For the user:
Container bugs now reach these positions. Since these elements read like a braced value, the container text bugs apply to them too, as they already do to
attr={…}://after text is a comment and text.On
mainthese positions read comments as text instead. The runtime cases avoid those shapes.🤖 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, matchingk={<b>…</b>}.The parser adds
#parseOpeningTagCodeto treat those opening-tag regions like{…}expression containers (hide#openingNativeTemplateNode, bump#jsxExpressionContainerDepth, restore context baselines). Spread parsing drops#templateScriptParsingDepthin 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/coregets a patch changeset.Reviewed by Cursor Bugbot for commit 3e79d50. Bugbot is set up for automated code reviews on this repo. Configure here.