Skip to content

[Bug]: tsrx-core: JSXText value keeps character references undecoded, unlike the JSX AST standard #710

Closed

Description

@leonidaz

Issue Category

Parser bug (@tsrx/core). A JSXText node's value differs from TSX's.

Decision

TSRX is a superset of TSX, so JSXText follows the JSX AST standard, as the specification already says. value has its character references decoded, and raw keeps the text as written. Printers print from raw. The specification's two sentences are right, and the parser changes.

Bug Description

The specification says twice that a JSXText value has its character references decoded:

  • 4.3, template text: "Character references such as " are decoded before the text value is stored."
  • The AST appendix: "Static text children decode JSX character references before the text value is stored."

The parser keeps them as written. For <p>a &quot;b&quot; &amp; c</p>, both value and raw are a &quot;b&quot; &amp; c, in a template, in a function, and at the top level.

The JSX parsers that build this AST decode value. acorn-typescript alone gives:

value: a "b" & c
raw:   a &quot;b&quot; &amp; c

The compilers rely on the undecoded value today: they write it into the output as JSX text, and the target's JSX compiler decodes the references there (see the compile test "preserves entities in JSX text children for JSX runtime decoding"). A tool that reads value as decoded text gets the references instead.

Reproduction Steps

At c4fa2588, run pnpm install --frozen-lockfile, then save this as repro.mjs in the repository root and run node repro.mjs:

import { parseModule } from './packages/tsrx/src/index.js';

// The first JSXText node in the tree.
function findText(node) {
	if (!node || typeof node !== 'object') return;
	if (node.type === 'JSXText') return node;
	for (const [key, child] of Object.entries(node)) {
		if (key === 'loc' || key === 'metadata') continue;
		const found = findText(child);
		if (found) return found;
	}
}

const sources = {
	template: `export function App() @{
	<p>a &quot;b&quot; &amp; c</p>
}`,
	function: `export function App() {
	return <p>a &quot;b&quot; &amp; c</p>;
}`,
	module: `export const p = <p>a &quot;b&quot; &amp; c</p>;`,
};

for (const [name, source] of Object.entries(sources)) {
	const text = findText(parseModule(source, 'App.tsrx'));
	console.log(`${name}: value ${JSON.stringify(text.value)}, raw ${JSON.stringify(text.raw)}`);
}
template: value "a &quot;b&quot; &amp; c", raw "a &quot;b&quot; &amp; c"
function: value "a &quot;b&quot; &amp; c", raw "a &quot;b&quot; &amp; c"
module: value "a &quot;b&quot; &amp; c", raw "a &quot;b&quot; &amp; c"

Expected: value "a \"b\" & c" in all three, with raw unchanged.

Cause

  • #parseTemplateRawText builds value from the source characters, leaving out only comments. The text of plain-JSX elements gets the same undecoded value.
  • The JSXText printer writes value back into the output as JSX text, which only works because value isn't decoded.

Fix Direction

  1. The parser decodes character references into value, for template text and plain-JSX text alike, as acorn's JSX parser does. raw stays the text as written. A comment between children stays out of both, as today.

  2. Everything that prints JSX text prints from raw instead of value, keeping fix(core): write a > in text as &gt;, read it as text in containers, and print JSX text from raw (#694, #693) #711's escaping of <, >, { and }. That covers:

    • core's JSXText printer, for every target;
    • both formatters, @tsrx/prettier-plugin and tsrx-prettier-plugin.

    Printing from raw keeps references like &nbsp; as written, instead of turning them into characters.

  3. Find every other reader of JSXText.value and switch it to whichever it means:

    • the analyzer, the whitespace rules (#shouldKeepTemplateTextNode), editor mappings (segments.js) and the language server;
    • the ESLint parser and plugin;
    • the Ripple and Octane compilers, which parse with @tsrx/core. File follow-up issues in those repositories for them.
  4. PR fix(core): write a > in text as &gt;, read it as text in containers, and print JSX text from raw (#694, #693) #711 ([Bug]: tsrx-core: Character references in an element in a spread argument, or in an unbraced attribute value in a container, compile decoded: &#123;x&#125; becomes an expression #693) currently sets value = raw for plain-JSX text, which is the opposite of this decision. It should keep acorn's decoded value and print raw instead, or leave value to this issue's fix.

  5. Tests:

    • the parser gives decoded value and as-written raw in each place;
    • compiled output for every target keeps the references and renders the decoded text;
    • both formatters keep the text as written.
  6. tsrx specification on the website updated

Environment

  • origin/main at c4fa2588, @tsrx/core 0.4.0
  • Node 24.18.0, macOS 26.6.2

Additional Context

Found while fixing #693 and #694. This was first filed as a question for the website's specification. The user decided on 2026-09-25 that the specification is right, since TSRX is a superset of TSX.

Downstream

Both Ripple and Octane decode JSX text themselves, so a decoded value makes them decode it a second time: &amp;lt; renders < instead of &lt;. Neither repository's tests catch it. Checked on 2026-09-26 with #710's decoding simulated on top of #711:

Activity

  1. changed the title [-][Bug]: tsrx-website: The specification says JSXText values decode character references, but the parser keeps them as written[/-] [+][Bug]: tsrx-core: JSXText `value` keeps character references undecoded, unlike the JSX AST standard[/+] on Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions