From 5fa03426e3dee3ad03f0de4b8e5e8f529d820865 Mon Sep 17 00:00:00 2001 From: Leonidas <329182+leonidaz@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:21:19 -0400 Subject: [PATCH] fix(core): read an element in a spread attribute's argument or an attribute value without braces as template markup (#656) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (`
… }} />`) or in an attribute value without braces (`
… />`) 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 --- .changeset/parser-spread-argument-markup.md | 15 + packages/tsrx/src/plugin.js | 113 ++++-- packages/tsrx/src/transform/jsx/index.js | 11 +- packages/tsrx/tests/shared/compile.js | 13 + .../expression-spread-ref-components.tsrx | 2 +- .../jsx-text-whitespace-components.tsrx | 49 +++ .../shared/runtime/jsx-text-whitespace.js | 9 + packages/tsrx/tests/utils/parser.test.js | 343 +++++++++++++++--- .../utils/type-only-jsx-analysis.test.js | 4 +- .../vite-plugin-hono/tests/runtime.test.tsrx | 11 + 10 files changed, 489 insertions(+), 81 deletions(-) create mode 100644 .changeset/parser-spread-argument-markup.md diff --git a/.changeset/parser-spread-argument-markup.md b/.changeset/parser-spread-argument-markup.md new file mode 100644 index 000000000..11861988d --- /dev/null +++ b/.changeset/parser-spread-argument-markup.md @@ -0,0 +1,15 @@ +--- +'@tsrx/core': patch +--- + +An element in a spread attribute's argument (`
… }} />`, +`
… : null)} />`) and an element that is an attribute value +without braces (`
… />`) are template markup, as an element in a +braced value (`
…} />`) is. They were parsed as plain JSX: + +- A comment in them is a comment instead of text (` /* c */ 2` + rendered `/* c */ 2`). +- `@if`, `@for`, `@switch`, `@try`, and `@{ … }` in them are directives instead + of text followed by a `{…}` container. +- Their text follows the template text rules, and a mismatched closing tag + reports the template's message. diff --git a/packages/tsrx/src/plugin.js b/packages/tsrx/src/plugin.js index b29e41bb2..271672b2c 100644 --- a/packages/tsrx/src/plugin.js +++ b/packages/tsrx/src/plugin.js @@ -845,6 +845,14 @@ export function TSRXPlugin(config) { // `parseElement`), for its closing tag to restore. /** @type {WeakMap} */ #elementContextDepths = new WeakMap(); + // The context depth before the `<` of an element that is an attribute + // value without braces, for `parseElement` to keep: the host's opening + // tag contexts. -1 otherwise. + #tagValueContextDepth = -1; + // The elements that are attribute values without braces: each closes in + // its host's opening tag, not in a template. + /** @type {WeakSet} */ + #tagValueElements = new WeakSet(); // What enclosed each element when it started: `this.labels` and its length // (see `#insideSwitchStartedAfter`), and the setup-statement depths (see // `#elementStart`). @@ -1087,8 +1095,7 @@ export function TSRXPlugin(config) { * started after `node`, and the position is in that statement's code. In * an element opened in the statement, it is that element's children. * `scriptDepth` is `#templateScriptParsingDepth`, raised by a setup - * statement of a `@{ … }` or directive body and by a spread attribute's - * argument; `switchCaseScriptDepth` is + * statement of a `@{ … }` or directive body; `switchCaseScriptDepth` is * `#parsingJSXSwitchCaseScriptStatementDepth`, raised by one of an `@case`. * `scriptJSXDepth` is `#scriptJSXElementDepth` (see * `#insideScriptJSXElement`). A node that no element start recorded counts @@ -1110,7 +1117,7 @@ export function TSRXPlugin(config) { /** * Whether the position is in an element that acorn-typescript's JSX parser * reads (see `jsx_parseElement`) and that started after the innermost TSRX - * element: an element in an unbraced attribute value (`
… />`). + * element: an element in a dynamic tag name (`<{c ? … : 'i'}>`). * Its children are read as template text, but that parser takes each text * token as a child as it is, so a template's comments are text there, as * in TSX. @@ -1638,9 +1645,8 @@ export function TSRXPlugin(config) { if (!current_template_node) { return false; } - // Code that started after the innermost element, a setup statement or a - // spread attribute's argument (`
`), isn't its text. An - // element opened in that code reads its own children as text + // Code that started after the innermost element, a setup statement, isn't + // its text. An element opened in that code reads its own children as text // (`const a = 1;` in a `@{ … }` body). const element_start = this.#elementStart(current_template_node); if ( @@ -5079,10 +5085,11 @@ export function TSRXPlugin(config) { // literal character (`
5/2
`, `
#tag
`). This must // not fire in the JS positions that can sit under a template element // on the path: inside a `{ … }` expression container (an attribute or - // child expression — ``, `{this.#x}`), inside a + // child expression — ``, `{this.#x}`) or a spread + // attribute's argument, read as one (`{...(a / 2)}`), inside a // control-flow directive header (`@if (a / 2 > 1)`), or in an opening - // tag (`
`, a spread attribute's `{...(a / 2)}`), where `/` is - // division or a self-closing tag's and `#` is a private-field access. + // tag (`
`), where `/` is division or a self-closing tag's and + // `#` is a private-field access. // The element being opened goes on `#path` only for the token after // its `>`, which is its first child. const template_parent = this.#path.at(-1); @@ -5866,25 +5873,19 @@ export function TSRXPlugin(config) { } if (this.eat(tt.braceL)) { - if (this.type === tt.ellipsis) { + if (this.type === tt.ellipsis || this.lookahead().type === tt.ellipsis) { + // The brace's context is on top + const brace_context_depth = this.context.length; this.#suppressTemplateRawTextToken = true; this.expect(tt.ellipsis); - this.#templateScriptParsingDepth++; - try { - /** @type {ESTreeJSX.JSXSpreadAttribute} */ (node).argument = this.parseMaybeAssign(); - } finally { - this.#templateScriptParsingDepth--; - } - this.#expectContainerClosingBrace(); - return this.finishNode(node, 'JSXSpreadAttribute'); - } else if (this.lookahead().type === tt.ellipsis) { - this.#suppressTemplateRawTextToken = true; - this.expect(tt.ellipsis); - this.#templateScriptParsingDepth++; - try { - /** @type {ESTreeJSX.JSXSpreadAttribute} */ (node).argument = this.parseMaybeAssign(); - } finally { - this.#templateScriptParsingDepth--; + /** @type {ESTreeJSX.JSXSpreadAttribute} */ (node).argument = this.#parseOpeningTagCode( + () => this.parseMaybeAssign(), + ); + // A control-flow directive in an element in the argument can leave + // contexts above the brace's, as in a container (see + // `jsx_parseExpressionContainer`). The `}` read pops the brace's. + if (this.type === tt.braceR) { + this.#truncateContext(brace_context_depth - 1); } this.#expectContainerClosingBrace(); return this.finishNode(node, 'JSXSpreadAttribute'); @@ -6061,6 +6062,16 @@ export function TSRXPlugin(config) { } } case tstt.jsxTagStart: + return this.#parseOpeningTagCode(() => { + // The element's `<` pushed its contexts on the opening tag's, which + // the tag goes on in after the element. + this.#tagValueContextDepth = this.context.length - 2; + try { + return this.parseExprAtom(); + } finally { + this.#tagValueContextDepth = -1; + } + }); case tt.string: return this.parseExprAtom(); default: @@ -6068,6 +6079,39 @@ export function TSRXPlugin(config) { } } + /** + * Parses code in an opening tag outside a `{ … }` container: a spread + * attribute's argument, whose first token is read, or an element that is an + * attribute value without braces (`
… />`). While the tag is + * read, the element it opens is `#openingNativeTemplateNode`, still in + * script mode, so `jsx_parseElement` would hand an element in that code to + * acorn-typescript's JSX parser. Hide it and read the code as a container's + * (see `jsx_parseExpressionContainer`), as a braced value is read: its + * elements are template markup, and the code around them is not template + * text. The token after the code is read in the tag again. + * @template T + * @param {() => T} parse + * @returns {T} + */ + #parseOpeningTagCode(parse) { + const opening_node = this.#openingNativeTemplateNode; + this.#openingNativeTemplateNode = null; + this.#jsxExpressionContainerDepth++; + this.#expressionContainerContextBaselines.push(this.context.length); + this.#expressionContainerPathBaselines.push({ + path: this.#path, + length: this.#path.length, + }); + try { + return parse(); + } finally { + this.#jsxExpressionContainerDepth--; + this.#expressionContainerContextBaselines.pop(); + this.#expressionContainerPathBaselines.pop(); + this.#openingNativeTemplateNode = opening_node; + } + } + /** * `@try`/`@pending`/`@catch` blocks are template control-flow blocks like * `@if`/`@for`/`@switch`: `return` is not allowed inside them. A `return` @@ -6684,9 +6728,13 @@ export function TSRXPlugin(config) { // stack here; the body (especially a control-flow block) can otherwise // leave residue that breaks tokenizing the following JS token when the // element is in expression position. The token after `<` is already read, - // so the `{` of a dynamic tag name (`<{tag}>`) doesn't count. + // so the `{` of a dynamic tag name (`<{tag}>`) doesn't count. An element + // that is an attribute value without braces keeps its host's opening tag + // contexts below it (see `jsx_parseAttributeValue`). + const tag_value_context_depth = this.#tagValueContextDepth; + this.#tagValueContextDepth = -1; let pre_element_context_depth = this.context.length - this.#currentTokenContextCount(); - while (pre_element_context_depth > 0) { + while (pre_element_context_depth > Math.max(0, tag_value_context_depth)) { const ctx = this.context[pre_element_context_depth - 1]; if (ctx === tstc.tc_expr || ctx === tstc.tc_oTag || ctx === tstc.tc_cTag) { pre_element_context_depth--; @@ -6719,6 +6767,9 @@ export function TSRXPlugin(config) { switchCaseScriptDepth: this.#parsingJSXSwitchCaseScriptStatementDepth, scriptJSXDepth: this.#scriptJSXElementDepth, }); + if (tag_value_context_depth >= 0) { + this.#tagValueElements.add(node); + } const previous_opening_native_template_node = this.#openingNativeTemplateNode; this.#openingNativeTemplateNode = node; @@ -7024,7 +7075,8 @@ export function TSRXPlugin(config) { this.#closingNativeTemplateNode = true; } // `>` reads the token after the element. When the element closes and no - // template encloses it, that token is code: the element leaves `#path` + // template encloses it, or in its host's opening tag as an attribute + // value without braces, that token is code: the element leaves `#path` // (`x / 2` divides; it is popped for good below), and the // tokenizer context returns to where the element began, as after a // balanced closing tag, dropping residue its body left (a control-flow @@ -7032,7 +7084,8 @@ export function TSRXPlugin(config) { const closes_outside_template = this.#isNativeTemplateNode(current) && current_name === closing_name_str && - !this.#isNativeTemplateNode(this.#path.at(-2)); + (!this.#isNativeTemplateNode(this.#path.at(-2)) || + this.#tagValueElements.has(current)); if (closes_outside_template) { this.#path.pop(); const context_depth = this.#elementContextDepths.get(current); diff --git a/packages/tsrx/src/transform/jsx/index.js b/packages/tsrx/src/transform/jsx/index.js index fd44d8e3d..ea895f6ec 100644 --- a/packages/tsrx/src/transform/jsx/index.js +++ b/packages/tsrx/src/transform/jsx/index.js @@ -714,11 +714,12 @@ export function createJsxTransform(platform) { } if (!node.metadata?.native_tsrx) { - // JSX inside a spread attribute's argument is parsed as plain JSX, - // but the JSXOpeningElement visitor still lowers its host ref/spread - // to a setup declaration. Wrap it here: left for the nearest native - // ancestor, it would be declared outside any callback between the - // two, where the callback's parameters are not in scope. + // JSX the parser reads as plain JSX (an element in a dynamic tag + // name, `<{…}>`), but the JSXOpeningElement visitor still lowers + // its host ref/spread to a setup declaration. Wrap it here: left + // for the nearest native ancestor, it would be declared outside + // any callback between the two, where the callback's parameters + // are not in scope. return wrap_jsx_setup_declarations( /** @type {AST.Expression} */ (next() ?? node), in_jsx_child_context(path), diff --git a/packages/tsrx/tests/shared/compile.js b/packages/tsrx/tests/shared/compile.js index a34aeaa15..cb5ba4407 100644 --- a/packages/tsrx/tests/shared/compile.js +++ b/packages/tsrx/tests/shared/compile.js @@ -2615,6 +2615,19 @@ export function App() @{ expect(code).toContain('\u00a03'); }); + it("keeps the text of an element in a spread attribute's argument and in an attribute value without braces", () => { + const { code } = compile( + 'export function App() @{\n\t
\n\t\t

1 /* c */ 2

}} />\n\t\t

3 /* c */ 4

/>\n\t\t

@if (y) { 5 } 6

: {})} />\n\t\n}', + 'App.tsrx', + ); + + expect(code).toContain(' 2'); + expect(code).toContain('
4'); + expect(code).toContain('5'); + expect(code).not.toContain('/* c */'); + expect(code).not.toContain('@if'); + }); + it('keeps double-quoted strings inside expression containers as JavaScript strings', () => { const { code } = compile( `export function App() @{ diff --git a/packages/tsrx/tests/shared/runtime/expression-spread-ref-components.tsrx b/packages/tsrx/tests/shared/runtime/expression-spread-ref-components.tsrx index 4a669f562..89b5562a9 100644 --- a/packages/tsrx/tests/shared/runtime/expression-spread-ref-components.tsrx +++ b/packages/tsrx/tests/shared/runtime/expression-spread-ref-components.tsrx @@ -47,7 +47,7 @@ function List(props: { items: unknown }) @{
    {props.items}
} -// JSX in a spread argument is parsed as plain JSX rather than a template node. +// JSX in a spread argument is template markup, as in a braced value. export function SpreadArgumentSpreadRefApp({ bags }: { bags: SpreadBag[] }) { const list = ( {pickBySwitch('a')}
// An element in a setup statement reads its text as anywhere else too + // So does an element in a spread attribute's argument, and one that is an + // attribute value without braces: its comments are comments, and its `@if` + // a directive, as in a braced value + // prettier-ignore + t /* c */2 }} /> + // prettier-ignore + t // c + two }} /> + // prettier-ignore + @if (show) { 1 } 2 } : { content: null })} /> + // prettier-ignore + t /* c */2 /> + // prettier-ignore + @if (show) { 1 } 2 /> + // prettier-ignore + @if (show) { 1 } 2} /> } @@ -248,6 +264,39 @@ export const jsx_text_whitespace_cases = [ '1 2', ], ['text before a tag in a setup statement of an @case', '.ws-case-setup', 'Hello 2'], + [ + "a block comment in an element in a spread attribute's argument", + '.ws-spread-comment', + 't 2', + ], + [ + "a line comment in an element in a spread attribute's argument", + '.ws-spread-line-comment', + 'ttwo', + ], + [ + 'a block comment in an element that is an attribute value without braces', + '.ws-unbraced-comment', + 't 2', + ], +] as const; + +/** + * Markup in a spread attribute's argument or in an attribute value without + * braces, and the same markup in a braced value, which it renders like. A + * directive renders differently on each target (Vue adds an anchor comment). + */ +export const jsx_text_braced_value_cases = [ + [ + "an @if in an element in a spread attribute's argument", + '.ws-spread-directive', + '.ws-braced-directive', + ], + [ + 'an @if in an element that is an attribute value without braces', + '.ws-unbraced-directive', + '.ws-braced-directive', + ], ] as const; /** diff --git a/packages/tsrx/tests/shared/runtime/jsx-text-whitespace.js b/packages/tsrx/tests/shared/runtime/jsx-text-whitespace.js index 60d57645d..59fc2bd29 100644 --- a/packages/tsrx/tests/shared/runtime/jsx-text-whitespace.js +++ b/packages/tsrx/tests/shared/runtime/jsx-text-whitespace.js @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { JsxTextWhitespaceApp, + jsx_text_braced_value_cases, jsx_text_nbsp_cases, jsx_text_whitespace_cases, } from './jsx-text-whitespace-components.tsrx'; @@ -39,5 +40,13 @@ export function runJsxTextWhitespaceRuntimeTests({ nbsp }) { expect(await render(selector)).toBe(nbsp === 'kept' ? kept : trimmed); }); } + + for (const [label, selector, braced] of jsx_text_braced_value_cases) { + it(`renders ${label} like the same markup in a braced value`, async () => { + const html = await render(selector); + expect(html).toBeTypeOf('string'); + expect(html).toBe(globalThis.container.querySelector(braced)?.innerHTML); + }); + } }); } diff --git a/packages/tsrx/tests/utils/parser.test.js b/packages/tsrx/tests/utils/parser.test.js index 195bf70be..52dc1eb59 100644 --- a/packages/tsrx/tests/utils/parser.test.js +++ b/packages/tsrx/tests/utils/parser.test.js @@ -10244,17 +10244,57 @@ describe('syntax errors in an element that is a value', () => { }); }); +/** + * The comments attached in `node`, by their values. + * + * @param {unknown} node + * @returns {string[]} + */ +function comments_in(node) { + /** @type {string[]} */ + const values = []; + JSON.stringify(node, (key, value) => { + if ( + (key === 'leadingComments' || key === 'trailingComments' || key === 'innerComments') && + Array.isArray(value) + ) { + values.push(...value.map((/** @type {{ value: string }} */ comment) => comment.value)); + } + return value; + }); + return values; +} + +/** + * The children of `node`: each text by its value, each element by its tag and + * children, and anything else by its type. + * + * @param {AST.Node} node + * @returns {unknown[]} + */ +function read_children(node) { + return node_children(node).map((child) => + child.type === 'JSXText' + ? child.value + : child.type === 'JSXElement' + ? { + [/** @type {{ name: string }} */ (child.openingElement.name).name]: + read_children(child), + } + : child.type, + ); +} + describe('an element as an attribute value without braces (#654)', () => { const modes = [undefined, { collect: true, preserveParens: true }, { loose: true }]; /** - * The children of the value of the `attr` attribute: each text by its value, - * each element by its tag and children. + * The value of the `attr` attribute. * * @param {unknown} ast - * @returns {unknown[]} + * @returns {AST.Node} */ - function value_children(ast) { + function attr_value(ast) { const attribute = /** @type {ESTreeJSX.JSXAttribute} */ ( find_first( ast, @@ -10263,83 +10303,300 @@ describe('an element as an attribute value without braces (#654)', () => { /** @type {ESTreeJSX.JSXAttribute} */ (node).name.name === 'attr', ) ); - /** @param {AST.Node} node @returns {unknown[]} */ - const read = (node) => - node_children(node).map((child) => - child.type === 'JSXText' - ? child.value - : child.type === 'JSXElement' - ? { - [/** @type {{ name: string }} */ (child.openingElement.name).name]: read(child), - } - : child.type, - ); - return read(/** @type {AST.Node} */ (/** @type {unknown} */ (attribute.value))); + return /** @type {AST.Node} */ (/** @type {unknown} */ (attribute.value)); } - // acorn-typescript's JSX parser reads the element, taking each text token as + // acorn-typescript's JSX parser read the element, taking each text token as // a child. Its text was read as a template's, which ends at a comment, so - // the parser read the same empty text there until memory ran out. A comment - // is text there, as in TSX. - /** @type {Array<[string, string, unknown[]]>} */ + // the parser read the same empty text there until memory ran out. The + // element is template markup now, as in a braced value (#656), so a comment + // is a comment. + /** @type {Array<[string, string, unknown[], string[]]>} */ const cases = [ - ['a block comment', 'const el =
/* c */ />;', ['/* c */']], - ['a block comment after text', 'const el =
a /* c */ b />;', ['a /* c */ b']], - ['a line comment', 'const el =
// c\n />;', ['// c\n']], - ['a line comment after text', 'const el =
// c\n />;', [' // c\n']], - // It was text on main too, but a text of its own - ['a line comment on its own line', 'const el =
\n// c\n />;', ['\n// c\n']], - ['a comment in a fragment', 'const el =
/* c */ />;', ['/* c */']], + ['a block comment', 'const el =
/* c */ />;', [], [' c ']], + [ + 'a block comment after text', + 'const el =
a /* c */ b />;', + ['a b'], + [' c '], + ], + ['a line comment', 'const el =
// c\n />;', [], [' c']], + ['a line comment after a space', 'const el =
// c\n />;', [], [' c']], + ['a line comment on its own line', 'const el =
\n// c\n />;', [], [' c']], + ['a comment in a fragment', 'const el =
/* c */ />;', [], [' c ']], [ 'a comment in a nested element', 'const el =
a/* c */ />;', - ['a', { i: ['/* c */'] }], + ['a', { i: [] }], + [' c '], ], [ 'a comment in an element in a template', 'export function App() @{\n\t
\n\t\t
/* c */ />\n\t
\n}', - ['/* c */'], + [], + [' c '], ], [ 'a comment in an element in a setup statement', 'export function App() @{\n\tconst el =
/* c */ />;\n\t
{el}
\n}', - ['/* c */'], + [], + [' c '], ], [ 'a comment in an element in a function', 'function App() {\n\treturn
// c\n />;\n}', - ['// c\n'], + [], + [' c'], ], + ['an @if', 'const el =
@if (x) { } />;', ['JSXIfExpression'], []], // The other children read as they did - ['text', 'const el =
text />;', ['text']], - ['an element', 'const el =
/>;', [{ i: [] }]], - ['a container', 'const el =
{x} />;', ['JSXExpressionContainer']], + ['text', 'const el =
text />;', ['text'], []], + ['an element', 'const el =
/>;', [{ i: [] }], []], + ['a container', 'const el =
{x} />;', ['JSXExpressionContainer'], []], ]; - it.each(cases)('reads %s', async (_label, source, expected) => { + it.each(cases)('reads %s', async (_label, source, children, comments) => { const outcomes = await parse_in_worker_with_ast(modes.map((options) => ({ source, options }))); for (const [index, outcome] of outcomes.entries()) { const label = `${JSON.stringify(source)} with ${JSON.stringify(modes[index])}`; if (!outcome.ok) throw new Error(`${label} threw ${outcome.message}`); expect(outcome.errors ?? [], label).toEqual([]); - expect(value_children(outcome.ast), label).toEqual(expected); + expect(read_children(attr_value(outcome.ast)), label).toEqual(children); + expect(comments_in(attr_value(outcome.ast)), label).toEqual(comments); } }); - it('reports a closing tag that does not match it', async () => { - const source = 'const el =
// c\n"foo">text
;'; - const outcomes = await parse_in_worker(modes.map((options) => ({ source, options }))); + it('reports a closing tag that does not match it as in a braced value', async () => { + const sources = [ + 'const el =
// c\n"foo">text
;', + 'const el =
// c\n"foo">text
};', + ]; + const outcomes = await parse_in_worker( + sources.flatMap((source) => modes.map((options) => ({ source, options }))), + ); + + expect(outcomes.map((outcome) => !outcome.ok && outcome.message)).toEqual([ + "Expected closing tag to match opening tag. Expected '' but found '
' (2:10)", + 'Unexpected closing tag (2:10)', + 'Unexpected closing tag (2:10)', + "Expected closing tag to match opening tag. Expected '' but found '
' (2:10)", + 'Unexpected closing tag (2:10)', + 'Unexpected closing tag (2:10)', + ]); + }); + + // An element in a dynamic tag name is still read by acorn-typescript's JSX + // parser (see `jsx_parseElement`), where a comment is text + it('reads a comment in an element in a dynamic tag name as text', async () => { + const source = 'const el = <{c ? /* c */ : "div"} />;'; + const outcomes = await parse_in_worker_with_ast(modes.map((options) => ({ source, options }))); for (const outcome of outcomes) { - expect(outcome).toMatchObject({ - ok: false, - message: 'Expected corresponding JSX closing tag for (2:10)', - }); + if (!outcome.ok) throw new Error(outcome.message); + const b = /** @type {AST.Node} */ ( + find_first( + outcome.ast, + (node) => + node.type === 'JSXElement' && + /** @type {{ name: string }} */ ( + /** @type {ESTreeJSX.JSXElement} */ (node).openingElement.name + ).name === 'b', + ) + ); + expect(read_children(b)).toEqual(['/* c */']); } }); }); +describe("an element in a spread attribute's argument (#656)", () => { + const modes = [undefined, { collect: true, preserveParens: true }, { loose: true }]; + + /** + * The element ``. + * + * @param {unknown} ast + * @returns {AST.Node} + */ + function element_i(ast) { + return /** @type {AST.Node} */ ( + find_first( + ast, + (node) => + node.type === 'JSXElement' && + /** @type {{ name?: string }} */ ( + /** @type {ESTreeJSX.JSXElement} */ (node).openingElement.name + ).name === 'i', + ) + ); + } + + // acorn-typescript's JSX parser read the element as plain JSX: a comment was + // text, and `@if` was text and a container. It is template markup, as in a + // braced value. + /** @type {Array<[string, string, unknown[], string[]]>} */ + const cases = [ + [ + 'a comment in an object', + 'export function App() @{\n\t
/* c */ 2 }} />\n}', + [{ b: [] }, ' 2'], + [' c '], + ], + [ + 'an @if in an object', + 'export function App() @{\n\t
@if (x) { } }} />\n}', + ['JSXIfExpression'], + [], + ], + [ + 'a comment in a conditional', + 'export function App() @{\n\t
\n\t\t
/* c */ 2 : null)}>t
\n\t
\n}', + [{ b: [] }, ' 2'], + [' c '], + ], + [ + 'an @for in a setup statement', + 'export function App() @{\n\tconst a =
@for (const y of ys) { {y} }, j: 1 }} />;\n\t
{a}
\n}', + ['JSXForExpression'], + [], + ], + [ + 'a line comment in a function', + 'function App() {\n\treturn
\n\t\t// c\n\t\ttwo }} />;\n}', + [{ b: [] }, '\n\t\t\n\t\ttwo'], + [' c'], + ], + [ + 'text in an array', + 'function App() {\n\treturn
a ]}>t
;\n}', + [' a ', { b: [] }, ' '], + [], + ], + [ + 'an @switch in an arrow', + 'const a =
@switch (x) { @case 1: { } } }} />;', + ['JSXSwitchExpression'], + [], + ], + [ + 'a code block', + 'export function App() @{\n\t
\n\t\t
@{ const y = 1; {y} } }}>t
\n\t
\n}', + ['JSXCodeBlock'], + [], + ], + [ + 'an @try in a container', + 'export function App() @{\n\t
\n\t\t{x &&
@try { } @catch (e) { } }} />}\n\t
\n}', + ['JSXTryExpression'], + [], + ], + ]; + + it.each(cases)('reads %s', async (_label, source, children, comments) => { + const outcomes = await parse_in_worker_with_ast(modes.map((options) => ({ source, options }))); + + for (const [index, outcome] of outcomes.entries()) { + const label = `${JSON.stringify(source)} with ${JSON.stringify(modes[index])}`; + if (!outcome.ok) throw new Error(`${label} threw ${outcome.message}`); + expect(outcome.errors ?? [], label).toEqual([]); + const i = element_i(outcome.ast); + expect(i.metadata?.native_tsrx, label).toBe(true); + expect(read_children(i), label).toEqual(children); + expect(comments_in(i), label).toEqual(comments); + } + }); + + /** + * A node without its locations and metadata. + * @param {unknown} node + */ + function without_locations(node) { + return JSON.parse( + JSON.stringify(node, (key, value) => + key === 'start' || key === 'end' || key === 'loc' || key === 'range' || key === 'metadata' + ? undefined + : value, + ), + ); + } + + // Each spread, and an attribute value without braces, against the same + // expression in a braced value + /** @type {Array<[string, (element: string) => string, (element: string) => string]>} */ + const forms = [ + ['an object', (x) => `{...{ k: ${x} }}`, (x) => `k={{ k: ${x} }}`], + ['a conditional', (x) => `{...(c ? ${x} : null)}`, (x) => `k={(c ? ${x} : null)}`], + ['an array', (x) => `{...[${x}]}`, (x) => `k={[${x}]}`], + ['an object with more', (x) => `{...{ k: ${x}, j: 1 }}`, (x) => `k={{ k: ${x}, j: 1 }}`], + ['an arrow', (x) => `{...{ r: () => ${x} }}`, (x) => `k={{ r: () => ${x} }}`], + ['an attribute value without braces', (x) => `k=${x}`, (x) => `k={${x}}`], + ]; + const contents = [ + 't /* c */ ', + 't\n\t\t// c\n\t\ttwo', + '@if (x) { } @else { } 2', + '@switch (x) { @case 1: { } }', + '{x} /* c */ {y}', + 'n /* c */ {...{ j: @if (y) { } }} />', + ]; + /** @type {Array<(host: string) => string>} */ + const places = [ + (host) => `const a = ${host};`, + (host) => `export function App() @{\n\t
\n\t\t${host}\n\t
\n}`, + (host) => `export function App() @{\n\tconst a = ${host};\n\t
{a}
\n}`, + (host) => `export function App() @{\n\t
\n\t\t{x && ${host}}\n\t
\n}`, + ]; + const ends = [' />', '>t

', ' j="1" {...y}>t

']; + + it.each(forms)( + 'reads an element in %s like one in a braced value', + async (_label, form, braced) => { + const pairs = contents.flatMap((content) => + places.flatMap((place) => + ends.map((end) => [ + place(`

${content}`)}${end}`), + place(`

${content}`)}${end}`), + ]), + ), + ); + const outcomes = await parse_in_worker_with_ast( + pairs.flatMap((pair) => + pair.flatMap((source) => modes.map((options) => ({ source, options }))), + ), + ); + + for (const [index, [source]] of pairs.entries()) { + for (const [mode_index, options] of modes.entries()) { + const outcome = outcomes[index * 2 * modes.length + mode_index]; + const reference = outcomes[(index * 2 + 1) * modes.length + mode_index]; + const label = `${JSON.stringify(source)} with ${JSON.stringify(options)}`; + if (!outcome.ok) throw new Error(`${label} threw ${outcome.message}`); + if (!reference.ok) throw new Error(`the braced value of ${label} threw`); + expect(outcome.errors ?? [], label).toEqual([]); + const [i, reference_i] = [outcome, reference].map(({ ast }) => element_i(ast)); + expect(i.metadata?.native_tsrx, label).toBe(true); + expect(without_locations(i), label).toEqual(without_locations(reference_i)); + const [p, reference_p] = [outcome, reference].map(({ ast }) => + read_children( + /** @type {AST.Node} */ ( + find_first( + ast, + (node) => + node.type === 'JSXElement' && + /** @type {{ name?: string }} */ ( + /** @type {ESTreeJSX.JSXElement} */ (node).openingElement.name + ).name === 'p', + ) + ), + ), + ); + expect(p, label).toEqual(reference_p); + } + } + }, + ); +}); + describe('a `/` in the opening tag of an element in a template (#655)', () => { const modes = [undefined, { collect: true, preserveParens: true }, { loose: true }]; diff --git a/packages/tsrx/tests/utils/type-only-jsx-analysis.test.js b/packages/tsrx/tests/utils/type-only-jsx-analysis.test.js index 165473713..ccfd62266 100644 --- a/packages/tsrx/tests/utils/type-only-jsx-analysis.test.js +++ b/packages/tsrx/tests/utils/type-only-jsx-analysis.test.js @@ -410,8 +410,8 @@ describe('type-only JSX analysis', () => { it.each(REF_SPREAD_OUTPUTS)( 'declares the binding of a host inside a spread argument within its callback (%s)', (_output, type_only, platform) => { - // JSX in a spread argument is parsed as plain JSX, not a native template - // node; its binding must stay in the callback that declares `row`. + // A host in a callback in a spread argument: its binding must stay in + // the callback that declares `row`. const svg = ' ) }} />'; for (const [name, source] of [ diff --git a/packages/vite-plugin-hono/tests/runtime.test.tsrx b/packages/vite-plugin-hono/tests/runtime.test.tsrx index faf439e53..5454592f3 100644 --- a/packages/vite-plugin-hono/tests/runtime.test.tsrx +++ b/packages/vite-plugin-hono/tests/runtime.test.tsrx @@ -27,6 +27,7 @@ import { } from '@tsrx/core/test-harness/runtime/generator-spread-ref-components.tsrx'; import { JsxTextWhitespaceApp, + jsx_text_braced_value_cases, jsx_text_nbsp_cases, jsx_text_whitespace_cases, } from '@tsrx/core/test-harness/runtime/jsx-text-whitespace-components.tsrx'; @@ -260,6 +261,16 @@ describe('@tsrx/vite-plugin-hono DOM runtime', () => { }); } + for (const [label, selector, braced] of jsx_text_braced_value_cases) { + it(`renders ${label} like the same markup in a braced value`, () => { + render(, container); + + const html = container.querySelector(selector)?.innerHTML; + expect(html).toBeTypeOf('string'); + expect(html).toBe(container.querySelector(braced)?.innerHTML); + }); + } + for (const [label, App, selector] of [ ['', NullishSpreadRefApp, '.nullish-spread-ref'], [' inside @if', NullishSpreadRefInIfApp, '.nullish-spread-ref-if'],