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
5 changes: 5 additions & 0 deletions .changeset/fix-parenthesized-tsrx-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/compiler": patch
---

Fix native TSRX compilation of parenthesized keys, iterables, and conditions, including annotated `@for` loops used directly in a component body. Map comments back to the authored source to prevent invalid JavaScript and Unicode panics when source maps are enabled.
65 changes: 64 additions & 1 deletion packages/compiler/__tests__/tsrx-for-semantics.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { compileBabel, compileOxc, modes } = require("./parity/harness");
const { compileBabel, compileOxc, modes, normalize } = require("./parity/harness");

const source = `
export function Rows({ rows }) @{
Expand Down Expand Up @@ -53,3 +53,66 @@ describe("TSRX @for semantics", () => {
);
});
});

// Keep the published parser's grammar: parenthesized key values are supported,
// while index bindings still use a bare identifier.
describe.each(["dom", "ssr", "universal"])("parenthesized @for keys (%s)", mode => {
test.each([
["(item.id)", 3],
["(((item.id)))", 3],
["( /* before */ (item.id) /* after */ )", 3],
["((item.id + 1) * 2)", 8],
["(item.id, item.other)", 7],
["(item.id ? (item.other ?? 9) : 0)", 7]
])("preserves emitted code and key evaluation for key%s", (key, expected) => {
const source = `export function F(items) @{ @for(const item of items; key${key}) { <div/> } }`;
const options = modes[`tsrx-${mode}`].options;
const babel = compileBabel(source, options, "parenthesized-key.tsrx");
const native = compileOxc(source, "parenthesized-key", options, ".tsrx");
expect(normalize(native)).toBe(normalize(babel));
for (const output of [babel, native]) {
expect(output).not.toMatch(/__tsrx/);
// Execute the emitted key callback independently of renderer helpers.
const core = require("@babel/core");
const callbacks = [];
core.transformSync(output, {
configFile: false,
babelrc: false,
plugins: [
() => ({
visitor: {
ObjectProperty(path) {
if (path.node.key.name !== "keyed") return;
const expression = core.transformFromAstSync(
core.types.file(
core.types.program([core.types.expressionStatement(path.node.value)])
),
null,
{ configFile: false, babelrc: false }
).code;
callbacks.push(new Function(`return (${expression.replace(/;$/, "")});`)());
}
}
})
]
});
expect(callbacks).toHaveLength(1);
expect(callbacks[0]({ id: 3, other: 7 })).toBe(expected);
}
});
});

describe.each(["dom", "ssr", "universal"])("deep parenthesized control anchors (%s)", mode => {
test.each([
"@for(const item of (((items))); key item.id) { <div/> }",
"@for(const item of (((items)))) { <div/> }",
"@if ((((ready)))) { <div/> }",
"@if ((/* outer */ ((ready)))) { <div/> }"
])("matches Babel for %s", control => {
const source = `export function F(items, ready) @{ ${control} }`;
const options = modes[`tsrx-${mode}`].options;
expect(normalize(compileOxc(source, "deep-control", options, ".tsrx"))).toBe(
normalize(compileBabel(source, options, "deep-control.tsrx"))
);
});
});
112 changes: 89 additions & 23 deletions packages/compiler/__tests__/tsrx-runtime.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,21 @@ function compileRuntime(source, compiler, generate) {
// prod (signals' `DEV` export is undefined) and the first click throws. Every
// flag the runtime packages gate on must be listed in BOTH places: a flag the
// define misses is a ReferenceError at first use.
const workspaceSourceRoots = ["solid", "web", "signals"].map(
const workspaceSourceRoots = ["solid", "web", "signals", "universal"].map(
name => path.join(repoRoot, "packages", name, "src") + path.sep
);

async function loadRuntimeModule(code, generate) {
const aliases = new Map([
[
"@solidjs/web",
path.join(repoRoot, "packages/web/src", generate === "ssr" ? "index.server.ts" : "index.ts")
generate === "universal"
? path.join(repoRoot, "packages/universal/test/custom.js")
: path.join(
repoRoot,
"packages/web/src",
generate === "ssr" ? "index.server.ts" : "index.ts"
)
],
["solid-js", path.join(repoRoot, "packages/solid/src/index.ts")],
// The web runtime reaches solid-js's seams through this subpath (#3470).
Expand Down Expand Up @@ -99,6 +105,11 @@ async function loadRuntimeModule(code, generate) {
path: aliases.get(args.path)
})
);
esbuild.onLoad({ filter: /\/universal\/test\/custom\.js$/ }, args => ({
contents: fs.readFileSync(args.path, "utf8") + '\nexport * from "solid-js";',
loader: "js",
resolveDir: path.dirname(args.path)
}));
esbuild.onLoad({ filter: /\.ts$/ }, args => {
if (!workspaceSourceRoots.some(root => args.path.startsWith(root))) return;
return {
Expand Down Expand Up @@ -215,7 +226,11 @@ describe.each(["babel", "oxc"])("%s TSRX runtime behavior", compiler => {
dispose();
});

test("passes For and Errored accessor bindings through as authored", async () => {
test.each(
["dom", "universal"].flatMap(mode =>
[" row.id", "(row.id)", "(/* key */ ((row.id)))"].map(key => [mode, key])
)
)("passes For and Errored accessor bindings through in %s with key%s", async (mode, key) => {
// #3474: the bindings are the accessors Solid hands out. `row()` and `i()`
// read live under a custom key, `err()` is the ErrorAccessor.
const source = `
Expand All @@ -236,7 +251,7 @@ describe.each(["babel", "oxc"])("%s TSRX runtime behavior", compiler => {
export function App() @{
<section>
<ul>
@for (const row of rows(); index i; key row.id) {
@for (const row of rows(); index i; key${key}) {
const snapshot = row;
<li data-id={snapshot().id}>{i()}:{row().label ?? row().id}:{row().extra}</li>
}
Expand All @@ -258,7 +273,7 @@ describe.each(["babel", "oxc"])("%s TSRX runtime behavior", compiler => {
flush();
}
`;
const runtime = await loadRuntimeModule(compileRuntime(source, compiler, "dom"), "dom");
const runtime = await loadRuntimeModule(compileRuntime(source, compiler, mode), mode);
const root = document.createElement("div");
const dispose = runtime.mount(root);

Expand All @@ -281,6 +296,54 @@ describe.each(["babel", "oxc"])("%s TSRX runtime behavior", compiler => {
dispose();
});

test.each(
["dom", "ssr", "universal"].flatMap(mode =>
["key item.id", "index i", "index i; key item.id"].map(annotation => [mode, annotation])
)
)("renders a statement-position annotated loop in %s with %s", async (mode, annotation) => {
const source = `
import { createSignal, flush } from "solid-js";
import { ${mode === "ssr" ? "renderToString" : "render"} } from "@solidjs/web";
const [items, setItems] = createSignal([
{ id: 1, label: "one" }, { id: 2, label: "two" }
]);
export function App() @{
@for (const item of items(); ${annotation}) {
<span data-id={item().id}>{item().label}</span>
}
}
export function run(target) {
return ${mode === "ssr" ? "renderToString(App)" : "render(App, target)"};
}
export function replaceItems(next) {
setItems(next);
flush();
}
`;
const output = compileRuntime(source, compiler, mode);
expect(output).not.toMatch(/__tsrx/);
const runtime = await loadRuntimeModule(output, mode);
if (mode === "ssr") {
expect(runtime.run()).toBe('<span data-id="1">one</span><span data-id="2">two</span>');
return;
}
const root = document.createElement("div");
const dispose = runtime.run(root);
expect(root.textContent).toBe("onetwo");
const retained = root.querySelector('[data-id="2"]');
runtime.replaceItems([
{ id: 2, label: "TWO" },
{ id: 3, label: "three" }
]);
expect(root.textContent).toBe("TWOthree");
if (annotation.includes("key")) {
expect(root.querySelector('[data-id="2"]')).toBe(retained);
}
runtime.replaceItems([]);
expect(root.textContent).toBe("");
dispose();
});

test("rejects destructuring where Solid passes an accessor", () => {
const cases = [
[
Expand Down Expand Up @@ -348,22 +411,25 @@ describe.each(["babel", "oxc"])("%s TSRX runtime behavior", compiler => {
dispose();
});

test("renders SSR branches, keyed lists, and empty fallbacks", async () => {
const source = readRuntimeFixture("ssr");
const runtime = await loadRuntimeModule(compileRuntime(source, compiler, "ssr"), "ssr");

expect(
runtime.renderPage({
show: true,
items: [
{ id: 1, label: "one" },
{ id: 2, label: "two" }
]
})
).toBe("<main><ul><li>one</li><li>two</li></ul></main>");
expect(runtime.renderPage({ show: true, items: [] })).toBe(
"<main><ul><li>empty</li></ul></main>"
);
expect(runtime.renderPage({ show: false, items: [] })).toBe("<main><p>hidden</p></main>");
});
test.each([" item.id", "(item.id)", "(/* key */ ((item.id)))"])(
"renders SSR branches, keyed lists, and empty fallbacks with key%s",
async key => {
const source = readRuntimeFixture("ssr").replace("key item.id", `key${key}`);
const runtime = await loadRuntimeModule(compileRuntime(source, compiler, "ssr"), "ssr");

expect(
runtime.renderPage({
show: true,
items: [
{ id: 1, label: "one" },
{ id: 2, label: "two" }
]
})
).toBe("<main><ul><li>one</li><li>two</li></ul></main>");
expect(runtime.renderPage({ show: true, items: [] })).toBe(
"<main><ul><li>empty</li></ul></main>"
);
expect(runtime.renderPage({ show: false, items: [] })).toBe("<main><p>hidden</p></main>");
}
);
});
63 changes: 63 additions & 0 deletions packages/compiler/__tests__/tsrx-source-comments.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
const babel = require("@babel/core");
const { compileBabel, compileOxc, modes } = require("./parity/harness");

function assertValid(code) {
expect(() => babel.parseSync(code, { babelrc: false, configFile: false })).not.toThrow();
expect(code).not.toMatch(/_t\d+_(?:R|K|I|H)\d/);
}

describe.each(["dom", "ssr", "universal"])("TSRX authored comments (%s)", mode => {
const options = { ...modes[`tsrx-${mode}`].options, sourceMap: true };
test("emits valid JavaScript across projected comment offsets", () => {
for (let width = 0; width <= 32; width++) {
const key = `item.id + "${"a".repeat(width)}"`;
const sources = [
`export function F(items, ready) @{ @for(const item of items; key(${key})) { <div/> } }`,
`export function F(items, ready) @{ @for(const item of items; key ${key}) { <div/> } }`,
`const v = @for(const item of items; key ${key}) { <div/> };`,
`const v = @for(const item of items; key(${key})) { <div/> };`,
`const v = @for(const item of items; index i; key ${key}) { <div>{i()}</div> };`,
`export function F(items) @{ <ul>@for(const item of items; key ${key}) { <li/> }</ul> }`,
`export function F(ready) @{ @if (ready === "${"a".repeat(width)}") { <div/> } }`,
`const v = @switch (value) { @case "${"a".repeat(width)}": { <div/> } @default: { <span/> } };`
];
for (const source of sources) assertValid(compileOxc(source, "comments", options, ".tsrx"));
}
});

test("emits a valid conditional key after LF", () => {
const source =
"export function F(items) @{ @for(const item of items;\nkey(item.active ? item.id : item.other)) { <div/> } }";
assertValid(compileBabel(source, options, "comments.tsrx"));
assertValid(compileOxc(source, "comments", options, ".tsrx"));
});

test("preserves authored comments and annotations after projection", () => {
const source = `
/*! authored license */
export function F(items) @{
// authored setup
const local = /* @__PURE__ */ factory();
@for(const item of items; key(/* authored key */ item.id)) { <div>{local}</div> }
}
/* @__NO_SIDE_EFFECTS__ */
function helper() { return 1; }
// authored tail
export const result = /* #__PURE__ */ helper();
`;
const output = compileOxc(source, "comments", options, ".tsrx");
assertValid(output);
for (const comment of [
"authored license",
"authored setup",
"authored tail",
"@__NO_SIDE_EFFECTS__",
"#__PURE__"
]) {
expect(output).toContain(comment);
}
// Babel also drops the key comment; it must not leak authored source instead.
expect(output).not.toMatch(/item\.id\)\) \{/);
expect(output).toMatch(/@__PURE__[\s\S]*factory\(\)/);
});
});
Loading
Loading