hi
+ > +}`) + ).rejects.toThrow(/only have one style tag/i); + }); + + test("return inside an @if branch is rejected", async () => { + await expect( + compile(`export function C({ ok }) @{ +no
; + } +{value}
}", + message: /Unexpected token/ + } + ])("rejects $name", async ({ source, message }) => { + await expect(compile(source)).rejects.toThrow(message); + }); + + test("statement container without an output node is rejected", async () => { + await expect( + compile(`export function C() @{ + const x = 1; +}`) + ).rejects.toThrow(); + }); + + test("syntax: 'jsx' disables TSRX routing even for .tsrx filenames", async () => { + await expect( + compile( + `export function C() @{ +hi
+}`, + { syntax: "jsx" } + ) + ).rejects.toThrow(); + }); + + test("syntax: 'tsrx' forces TSRX parsing for non-.tsrx filenames", async () => { + const code = await compile( + `export const C = ({ on }) => @if (on) { +yes
+};`, + { filename: "case.tsx", syntax: "tsrx" } + ); + expect(code).toContain("Show"); + }); + + test("plain JSX files are untouched by the TSRX frontend", async () => { + const code = await compile(`export const C = () =>hi
;`, { + filename: "case.jsx" + }); + expect(code).toContain("_$template"); + }); +}); diff --git a/packages/babel-plugin/test/tsrx-lazy.spec.js b/packages/babel-plugin/test/tsrx-lazy.spec.js new file mode 100644 index 000000000..44bfb7e6c --- /dev/null +++ b/packages/babel-plugin/test/tsrx-lazy.spec.js @@ -0,0 +1,414 @@ +const babel = require("@babel/core"); +const plugin = require("../index"); + +function compile(code) { + return babel.transformSync(code, { + babelrc: false, + configFile: false, + filename: "lazy.tsrx", + plugins: [[plugin, { generate: "dom" }]] + }).code; +} + +function execute(code) { + return Function(`${compile(code)}\nreturn result;`)(); +} + +describe("TSRX lazy destructuring", () => { + test("defaults use undefined semantics and updates write the defaulted value", () => { + const result = execute(` + let backing; + let reads = 0; + let writes = 0; + let fallbacks = 0; + const source = { + get value() { + reads++; + return backing; + }, + set value(next) { + writes++; + backing = next; + } + }; + let &{ value = ++fallbacks } = source; + + const first = value; + const post = value++; + const pre = ++value; + value = undefined; + const compound = value += 5; + source.value = null; + const nullValue = value; + + const result = { + first, + post, + pre, + compound, + nullValue, + backing, + reads, + writes, + fallbacks + }; + `); + + expect(result).toEqual({ + first: 1, + post: 2, + pre: 4, + compound: 8, + nullValue: null, + backing: null, + reads: 5, + writes: 5, + fallbacks: 3 + }); + }); + + test("nested and computed defaults stay lazy and evaluate each member once", () => { + const result = execute(` + let keyReads = 0; + let fallbackReads = 0; + let backing; + const source = { + nested: { + get value() { + keyReads++; + return backing; + }, + set value(next) { + backing = next; + } + } + }; + let &{ + nested: &{ + ["value"]: renamed = ++fallbackReads + } + } = source; + + const first = renamed; + const post = renamed++; + const result = { + first, + post, + current: source.nested.value, + keyReads, + fallbackReads + }; + `); + + expect(result).toEqual({ + first: 1, + post: 2, + current: 3, + keyReads: 3, + fallbackReads: 2 + }); + }); + + test("array rest creates a fresh Array.from slice for every read", () => { + const result = execute(` + const arrayLike = { 0: "a", 1: "b", 2: "c", length: 3 }; + let &[head, ...tail] = arrayLike; + + let iterations = 0; + const iterable = { + *[Symbol.iterator]() { + iterations++; + yield 1; + yield 2; + yield 3; + } + }; + let &[first, ...remaining] = iterable; + + const result = { + head, + tailA: tail, + tailB: tail, + first, + remainingA: remaining, + remainingB: remaining, + iterations + }; + `); + + expect(result).toEqual({ + head: "a", + tailA: ["b", "c"], + tailB: ["b", "c"], + // Non-rest lazy array elements preserve TSRX's indexed-read semantics; + // only the rest view consumes generic iterables. + first: undefined, + remainingA: [2, 3], + remainingB: [2, 3], + iterations: 2 + }); + expect(result.tailA).not.toBe(result.tailB); + expect(result.remainingA).not.toBe(result.remainingB); + }); + + test("object rest lowers to a collision-safe reactive omit call", () => { + const output = compile(` + const __lazy0 = 0; + const __lazyOmit0 = 0; + const source = { selected: 1, other: 2 }; + let &{ selected, ...rest } = source; + const result = [selected, rest]; + `); + + expect(output).toContain('import { omit as __lazyOmit1 } from "solid-js"'); + expect(output).toContain("let __lazy1 = source"); + expect(output).toContain('__lazyOmit1(__lazy1, "selected")'); + }); + + test("function names and parameters shadow outer lazy bindings", () => { + const result = execute(` + const source = { value: 42 }; + let &{ value } = source; + const named = function value(param = value) { + return param; + }; + const parameter = (value = value) => value; + let parameterThrew = false; + try { + parameter(); + } catch (error) { + parameterThrew = error instanceof ReferenceError; + } + const result = { + namedUsesItself: named() === named, + parameterThrew, + outer: value + }; + `); + + expect(result).toEqual({ + namedUsesItself: true, + parameterThrew: true, + outer: 42 + }); + }); + + test("var lazy bindings remain visible across their function and program scope", () => { + const result = execute(` + const programSource = { programValue: "program" }; + if (true) { + var &{ programValue } = programSource; + } + + function fromBlock(source) { + if (true) { + var &{ value } = source; + } + return value; + } + + function fromLoop(source) { + for (var &{ value } = source; value < 3; value++) {} + return value; + } + + const result = { + programValue, + block: fromBlock({ value: 2 }), + loop: fromLoop({ value: 0 }) + }; + `); + + expect(result).toEqual({ + programValue: "program", + block: 2, + loop: 3 + }); + }); + + test("var collection stops at nested function, class, and static-block boundaries", () => { + const result = execute(` + const outerSource = { value: "outer" }; + let &{ value } = outerSource; + + function nested() { + if (true) { + var &{ value } = { value: "function" }; + } + return value; + } + + class Holder { + static before = value; + static { + if (true) { + var &{ value } = { value: "static" }; + } + this.inside = value; + } + static after = value; + } + + const result = { + outer: value, + nested: nested(), + before: Holder.before, + inside: Holder.inside, + after: Holder.after + }; + `); + + expect(result).toEqual({ + outer: "outer", + nested: "function", + before: "outer", + inside: "static", + after: "outer" + }); + }); + + test("loop scopes preserve iteration, shadowing, and post-loop var visibility", () => { + const result = execute(` + const outerSource = { value: 10 }; + let &{ value } = outerSource; + + const classic = { value: 0 }; + const lexicalSeen = []; + for (let &{ value } = classic; value < 2; value++) { + lexicalSeen.push(value); + } + + const iterationSeen = []; + for (const &{ value } of [{ value: 3 }, { value: 4 }]) { + iterationSeen.push(value); + } + + const varSeen = []; + for (var &{ item } of [{ item: "a" }, { item: "b" }]) { + varSeen.push(item); + } + + const result = { + lexicalSeen, + classicValue: classic.value, + iterationSeen, + outerAfterLoops: value, + varSeen, + itemAfterLoop: item + }; + `); + + expect(result).toEqual({ + lexicalSeen: [0, 1], + classicValue: 2, + iterationSeen: [3, 4], + outerAfterLoops: 10, + varSeen: ["a", "b"], + itemAfterLoop: "b" + }); + }); + + test("for-of lexical lazy bindings are in the RHS temporal dead zone", () => { + expect(() => + execute(` + const outerSource = { value: 10 }; + let &{ value } = outerSource; + for (const &{ value } of (value, [])) {} + const result = value; + `) + ).toThrow(ReferenceError); + }); + + test("defaulted component names lower through Dynamic", () => { + const output = compile(` + function View(&{ Component = "div" }) @{ +{name}:{index}
+ } + @try {{error.message}
} + + > +}`; + + const output = projectTsrxForTypecheck(source, { filename: "card.tsrx" }); + + expect(output.code).toContain("keyed={false}"); + expect(output.code).toMatch(/__lazy\d+\(\)\.name/); + expect(output.code).toContain("error().message"); + expect(output.cssHash).toMatch(/^tsrx-/); + expect(output.css).toContain(output.cssHash); + expect(JSON.parse(output.map)).toMatchObject({ + sources: ["card.tsrx"], + sourcesContent: [source] + }); + expect(output.embeddedRegions).toEqual([ + { + kind: "css", + start: source.indexOf(css), + end: source.indexOf(css) + css.length, + content: css + }, + { + kind: "script", + start: source.indexOf(script), + end: source.indexOf(script) + script.length, + content: script + } + ]); + expect(Buffer.byteLength(source.slice(0, source.indexOf(css)))).toBeGreaterThan( + source.indexOf(css) + ); + }); + + test( + "emits collision-safe helper imports that TypeScript can check directly", + { timeout: 15000 }, + () => { + const source = `const __tsrx_For0 = "taken"; +export function Rows({ rows }: { rows: { name: string }[] }) @{ + @for (const row of rows; index index) { +{row.name}:{index}
+ } +}`; + const output = projectTsrxForTypecheck(source, { filename: "rows.tsrx" }); + + expect(output.code).toContain("For as __tsrx_For1"); + expect(typecheck(output.code)).toEqual([]); + } + ); +}); diff --git a/packages/compiler/index.js b/packages/compiler/index.js index 2a9d41760..4f0a52b68 100644 --- a/packages/compiler/index.js +++ b/packages/compiler/index.js @@ -12,16 +12,59 @@ function transform(code, options) { const nativeOptions = validateOptions(code, options); const result = native.transform(code, nativeOptions); - return { + const output = { code: result.code, map: result.map ?? null }; + // Preserve the established JSX result shape. Native TSRX transforms always + // return a CSS string (including `""` when no styles are present), which + // makes the sidecar fields a route-specific extension. + if (result.css != null) { + output.css = result.css; + output.cssHash = result.cssHash ?? null; + } + return output; } function transformAsync(code, options) { return Promise.resolve().then(() => transform(code, options)); } +function projectTsrxForTypecheck(code, options) { + if (typeof code !== "string") { + throw new TypeError( + "@solidjs/compiler projectTsrxForTypecheck() expects source code as a string" + ); + } + const nativeOptions = validateTypecheckProjectionOptions(options); + const result = native.projectTsrxForTypecheck(code, nativeOptions); + return { + code: result.code, + map: result.map, + css: result.css, + cssHash: result.cssHash ?? null, + embeddedRegions: result.embeddedRegions + }; +} + +function validateTypecheckProjectionOptions(options) { + if (options == null) return options; + if (typeof options !== "object" || Array.isArray(options)) { + throw new TypeError( + "@solidjs/compiler projectTsrxForTypecheck() expects options to be an object" + ); + } + for (const key of Object.keys(options)) { + if (key !== "filename") { + throw new Error(`@solidjs/compiler received unknown option \`${key}\``); + } + } + if (options.filename !== undefined && typeof options.filename !== "string") { + throw new TypeError("@solidjs/compiler `filename` option must be a string"); + } + return options; +} + function transformDirectives(code, options) { if (typeof code !== "string") { throw new TypeError("@solidjs/compiler transformDirectives() expects source code as a string"); @@ -405,6 +448,7 @@ function isMissingPackage(error, packageName) { module.exports = { transform, transformAsync, + projectTsrxForTypecheck, transformDirectives, transformDirectivesAsync, transformLazy, diff --git a/packages/compiler/package.json b/packages/compiler/package.json index aaa435532..a60677785 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -30,7 +30,7 @@ "bench": "pnpm run build && node scripts/bench.mjs", "lint": "cargo clippy --manifest-path ./Cargo.toml -- -D warnings", "test": "pnpm run test:rust && pnpm run build:debug && vitest run", - "test:rust": "cargo test --manifest-path ./Cargo.toml && cargo test --manifest-path ./Cargo.toml --no-default-features", + "test:rust": "cargo test --manifest-path ./Cargo.toml && cargo test --manifest-path ./Cargo.toml --no-default-features && cargo test --manifest-path ./Cargo.toml --no-default-features --features tsrx", "artifacts": "napi artifacts", "napi:version": "napi version && node ./sync-optional-deps.mjs", "create-npm-dirs": "napi create-npm-dirs" diff --git a/packages/compiler/src/compiler.rs b/packages/compiler/src/compiler.rs index 56f006a26..3b55f327c 100644 --- a/packages/compiler/src/compiler.rs +++ b/packages/compiler/src/compiler.rs @@ -21,6 +21,19 @@ pub enum Generate { Dynamic, } +/// Source syntax selection, mirroring the Babel plugin's `syntax` option. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum Syntax { + /// Route `.tsrx` filenames through the TSRX frontend, everything else + /// through standard JSX. + #[default] + Auto, + /// Never use the TSRX frontend. + Jsx, + /// Force the TSRX frontend for every file. + Tsrx, +} + /// A wrapper import setting without any Node-API representation in its interface. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub enum Wrapper { @@ -62,6 +75,9 @@ pub(crate) fn default_built_ins() -> Vecno
;\n }\n{value}
}", + "Unexpected token", + ), + ]; + + for (name, source, expected) in cases { + let message = compile_error(source); + assert!( + message.contains(expected), + "{name} diagnostic must contain {expected:?}: {message}" + ); + } +} + +#[test] +fn unicode_offsets_preserve_authored_diagnostic_coordinates() { + let message = compile_error( + "const emoji = \"🚀\";\nexport function C() @{\n{plain.name}
} + @for (const indexed of rows; index index) {{indexed.name}:{index}
} + @for (const keyed of rows; key keyed.id) {{keyed.name}
} + @for (const both of rows; index position; key both.id) {{both.name}:{position}
} + @for (const { name = "missing", ...rest } of rows; index offset) { +{name}:{rest.extra}:{offset}
+ } + @try {{error.message}
} + > +}"#; + let output = project(source); + + assert!(output.code.contains("from \"solid-js\"")); + assert!(output.code.contains("<__tsrx_For0")); + assert!(output.code.contains("<__tsrx_Errored0")); + assert!(output.code.contains("plain.name")); + assert!(!output.code.contains("plain().name")); + assert!(output.code.contains("indexed().name")); + assert!(output.code.contains("keyed().name")); + assert!(output.code.contains("both().name")); + assert!(output.code.contains("position()")); + assert!(output.code.contains("keyed={false}")); + assert!(output.code.contains("__lazy")); + assert!(output.code.contains(".name")); + assert!(output.code.contains(".extra")); + assert!(output.code.contains("error().message")); + + let runtime = compile( + source, + &CompileOptions { + filename: Some("typecheck.tsrx".into()), + syntax: Syntax::Tsrx, + ..CompileOptions::default() + }, + ) + .expect("runtime projection"); + for shared_semantic_read in ["indexed().name", "error().message"] { + assert!( + runtime.code.contains(shared_semantic_read), + "runtime and tooling must share {shared_semantic_read}: {}", + runtime.code + ); + } +} + +#[test] +fn typecheck_helper_aliases_do_not_capture_authored_bindings_or_elements() { + let source = r#"const __tsrx_For0 = "taken"; +const For = (props: { children?: unknown }) => props.children; +export function Rows({ rows }: { rows: { name: string }[] }) @{ + <> +{row.name}:{index}
} + > +}"#; + let output = project(source); + + assert!( + output.code.contains("For as __tsrx_For1"), + "{}", + output.code + ); + assert!( + output.code.contains("