Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/parser-spread-argument-markup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@tsrx/core': patch
---

An element in a spread attribute's argument (`<div {...{ k: <b>…</b> }} />`,
`<div {...(c ? <b>…</b> : null)} />`) and an element that is an attribute value
without braces (`<div k=<b>…</b> />`) are template markup, as an element in a
braced value (`<div k={<b>…</b>} />`) is. They were parsed as plain JSX:

- A comment in them is a comment instead of text (`<b><i /> /* c */ 2</b>`
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.
113 changes: 83 additions & 30 deletions packages/tsrx/src/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,14 @@ export function TSRXPlugin(config) {
// `parseElement`), for its closing tag to restore.
/** @type {WeakMap<AST.Node, number>} */
#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<AST.Node>} */
#tagValueElements = new WeakSet();
// What enclosed each element when it started: `this.labels` and its length
// (see `#insideSwitchStartedAfter`), and the setup-statement depths (see
// `#elementStart`).
Expand Down Expand Up @@ -1144,8 +1152,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
Expand All @@ -1167,7 +1174,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 (`<div a=<b>…</b> />`).
* element: an element in a dynamic tag name (`<{c ? <b>…</b> : '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.
Expand Down Expand Up @@ -1695,9 +1702,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 (`<div {...props}>`), 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 = <b> 1</b>;` in a `@{ … }` body).
const element_start = this.#elementStart(current_template_node);
if (
Expand Down Expand Up @@ -5391,10 +5397,11 @@ export function TSRXPlugin(config) {
// literal character (`<div>5/2</div>`, `<div>#tag</div>`). 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 — `<rect x={a / 2}/>`, `{this.#x}`), inside a
// child expression — `<rect x={a / 2}/>`, `{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 (`<div / >`, a spread attribute's `{...(a / 2)}`), where `/` is
// division or a self-closing tag's and `#` is a private-field access.
// tag (`<div / >`), 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);
Expand Down Expand Up @@ -6458,25 +6465,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');
Expand Down Expand Up @@ -6653,13 +6654,56 @@ 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:
this.raise(this.start, 'value should be either an expression or a quoted text');
}
}

/**
* 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 (`<div a=<b>…</b> />`). 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`
Expand Down Expand Up @@ -7276,9 +7320,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--;
Expand Down Expand Up @@ -7311,6 +7359,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;
Expand Down Expand Up @@ -7616,15 +7667,17 @@ 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`
// (`<b>x</b> / 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
// block's contexts). parseElement keeps the contexts the token pushes.
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);
Expand Down
11 changes: 6 additions & 5 deletions packages/tsrx/src/transform/jsx/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
13 changes: 13 additions & 0 deletions packages/tsrx/tests/shared/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<main>\n\t\t<p {...{ k: <div><b>1</b> /* c */ 2</div> }} />\n\t\t<p k=<div><i>3</i> /* c */ 4</div> />\n\t\t<p {...(x ? <div>@if (y) { <s>5</s> } 6</div> : {})} />\n\t</main>\n}',
'App.tsrx',
);

expect(code).toContain('</b> 2');
expect(code).toContain('</i> 4');
expect(code).toContain('5</s>');
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() @{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ function List(props: { items: unknown }) @{
<ul>{props.items}</ul>
}

// 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 = (
<List
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,22 @@ export function JsxTextWhitespaceApp() @{
<div>{pickBySwitch('a')}</div>
// An element in a setup statement reads its text as anywhere else too
<SetupText kind="a" />
// 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
<Slot {...{ content: <span class="ws-spread-comment"><b>t</b> /* c */2</span> }} />
// prettier-ignore
<Slot {...{ content: <span class="ws-spread-line-comment"><b>t</b> // c
two</span> }} />
// prettier-ignore
<Slot {...(show ? { content: <span class="ws-spread-directive">@if (show) { <b>1</b> } 2</span> } : { content: null })} />
// prettier-ignore
<Slot content=<span class="ws-unbraced-comment"><b>t</b> /* c */2</span> />
// prettier-ignore
<Slot content=<span class="ws-unbraced-directive">@if (show) { <b>1</b> } 2</span> />
// prettier-ignore
<Slot content={<span class="ws-braced-directive">@if (show) { <b>1</b> } 2</span>} />
</>
}

Expand Down Expand Up @@ -248,6 +264,39 @@ export const jsx_text_whitespace_cases = [
'<b>1</b> 2',
],
['text before a tag in a setup statement of an @case', '.ws-case-setup', 'Hello<b></b> 2'],
[
"a block comment in an element in a spread attribute's argument",
'.ws-spread-comment',
'<b>t</b> 2',
],
[
"a line comment in an element in a spread attribute's argument",
'.ws-spread-line-comment',
'<b>t</b>two',
],
[
'a block comment in an element that is an attribute value without braces',
'.ws-unbraced-comment',
'<b>t</b> 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;

/**
Expand Down
9 changes: 9 additions & 0 deletions packages/tsrx/tests/shared/runtime/jsx-text-whitespace.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
});
}
});
}
Loading
Loading