From 468423b00271d95cee278d10756f5bac43b8dc0d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 08:36:06 +0000 Subject: [PATCH 1/2] fix(compiler): secondary passes accept any file extension transformDirectives, lazy, refresh and source names run on already-lowered code; the filename is metadata (server-function IDs hash it). An unknown extension now parses as TSX instead of failing, so a Vite front end can pass a module's real path (e.g. App.rx). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWMdD4K7882vob4J9vq --- ...compiler-secondary-passes-any-extension.md | 5 ++ .../secondary-passes-frontend.test.js | 71 +++++++++++++++++++ packages/compiler/src/config.rs | 13 ++++ packages/compiler/src/directives/mod.rs | 4 +- packages/compiler/src/lazy.rs | 4 +- packages/compiler/src/refresh/mod.rs | 4 +- packages/compiler/src/source_names.rs | 4 +- 7 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 .changeset/compiler-secondary-passes-any-extension.md create mode 100644 packages/compiler/__tests__/secondary-passes-frontend.test.js diff --git a/.changeset/compiler-secondary-passes-any-extension.md b/.changeset/compiler-secondary-passes-any-extension.md new file mode 100644 index 0000000000..f4a0b5b35e --- /dev/null +++ b/.changeset/compiler-secondary-passes-any-extension.md @@ -0,0 +1,5 @@ +--- +"@solidjs/compiler": patch +--- + +The secondary passes (`transformDirectives`, the lazy and refresh passes, source names) accept a filename with any extension. They run on code that is already lowered to JavaScript, so the filename is metadata rather than a parser choice: a known extension still picks its dialect, and any other (`App.rx`, from another front end) is parsed as TSX instead of failing with "Unknown file extension". This matters for server functions, whose IDs hash the filename: a front end can now pass the module's real path, so the ID matches the one `moduleForFunctionId` computes for that module. The JSX transform itself is unchanged. diff --git a/packages/compiler/__tests__/secondary-passes-frontend.test.js b/packages/compiler/__tests__/secondary-passes-frontend.test.js new file mode 100644 index 0000000000..790f429872 --- /dev/null +++ b/packages/compiler/__tests__/secondary-passes-frontend.test.js @@ -0,0 +1,71 @@ +const path = require("path"); + +const compilerDir = path.resolve(__dirname, ".."); +const { transformDirectives, transformLazy, transformRefresh } = require(compilerDir); + +// Another front end (ReactiveScript's `.rx`, through a bundler's frontends hook) +// lowers its own modules to Solid's output, then hands that ordinary +// JavaScript to the secondary passes under the module's own filename — the +// path server-function ids hash, as the bundler's manifest keys it. +const ROOT = "/project"; +const FILENAME = `${ROOT}/src/routes/App.rx`; +const generated = ` + export async function save(value) { + "use server"; + return value; + } +`; + +describe("secondary passes for another front end's modules", () => { + it.each(["server", "client"])( + "transforms the generated JavaScript under the original path in %s mode", + mode => { + const result = transformDirectives(generated, { + filename: FILENAME, + root: ROOT, + mode, + env: "production" + }); + expect(result.valid).toBe(true); + expect(result.functions).toHaveLength(1); + expect(result.functions[0].name).toBe("save"); + } + ); + + it("hashes the module's own path, not a borrowed extension", () => { + const own = transformDirectives(generated, { + filename: FILENAME, + root: ROOT, + mode: "server", + env: "production" + }); + const client = transformDirectives(generated, { + filename: FILENAME, + root: ROOT, + mode: "client", + env: "production" + }); + const borrowed = transformDirectives(generated, { + filename: `${FILENAME}.tsx`, + root: ROOT, + mode: "server", + env: "production" + }); + expect(client.functions[0].id).toBe(own.functions[0].id); + expect(borrowed.functions[0].id).not.toBe(own.functions[0].id); + }); + + it("runs the lazy and refresh passes under that filename", () => { + const lazy = transformLazy( + `import { lazy } from "solid-js";\nexport const Page = lazy(() => import("./Page.rx"));\n`, + { filename: FILENAME } + ); + expect(lazy.code).toContain("lazy("); + const refresh = transformRefresh(`export function App() { return null; }\n`, { + filename: FILENAME, + bundler: "vite", + jsx: false + }); + expect(typeof refresh.code).toBe("string"); + }); +}); diff --git a/packages/compiler/src/config.rs b/packages/compiler/src/config.rs index d10f05baa1..ff090b7c23 100644 --- a/packages/compiler/src/config.rs +++ b/packages/compiler/src/config.rs @@ -114,6 +114,19 @@ pub struct TransformResult { pub css_hash: Option, } +/// The dialect a secondary pass (`transformDirectives`, `transformLazy`, +/// `transformRefresh`, `transformSourceNames`) parses its input in. Those +/// passes run on what a JSX transform — this crate's, or another front end's — +/// already produced, and keep the authored filename only for path-derived +/// metadata (server-function ids hash it). So a filename Oxc knows picks its +/// dialect, and any other (`.tsrx`, another front end's `.rx`) is ordinary +/// code, parsed as TSX. +pub(crate) fn secondary_source_type(filename: Option<&str>) -> SourceType { + filename + .and_then(|filename| SourceType::from_path(filename).ok()) + .unwrap_or_else(SourceType::tsx) +} + pub(crate) fn source_type_for_filename(filename: Option<&str>) -> Result { if filename.is_some_and(|filename| filename.ends_with(".tsrx")) { // Secondary passes receive already-projected ordinary code but retain diff --git a/packages/compiler/src/directives/mod.rs b/packages/compiler/src/directives/mod.rs index 01c5e883cc..13ffae009f 100644 --- a/packages/compiler/src/directives/mod.rs +++ b/packages/compiler/src/directives/mod.rs @@ -22,7 +22,7 @@ use oxc_allocator::Allocator; use oxc_codegen::{Codegen, CodegenOptions}; use oxc_parser::{ParseOptions, Parser}; -use crate::config::source_type_for_filename; +use crate::config::secondary_source_type; use transform::{DirectivesTransform, Env, ImportDef, ImportKind, Mode, UnsupportedExport}; const DEFAULT_RUNTIME: &str = "@solidjs/web/server-functions"; @@ -118,7 +118,7 @@ pub fn transform_directives( .clone() .unwrap_or_else(|| "use server".to_string()); - let source_type = source_type_for_filename(Some(filename))?; + let source_type = secondary_source_type(Some(filename)); let allocator = Allocator::default(); let parsed = Parser::new(&allocator, &code, source_type) .with_options(ParseOptions { diff --git a/packages/compiler/src/lazy.rs b/packages/compiler/src/lazy.rs index 0b3c8e62ea..63c9e8334b 100644 --- a/packages/compiler/src/lazy.rs +++ b/packages/compiler/src/lazy.rs @@ -32,7 +32,7 @@ use oxc_parser::{ParseOptions, Parser}; use oxc_semantic::SemanticBuilder; use oxc_span::{GetSpan, Span}; -use crate::config::{TransformResult, source_type_for_filename}; +use crate::config::{TransformResult, secondary_source_type}; pub const LAZY_PLACEHOLDER_PREFIX: &str = "__SOLID_LAZY_MODULE__:"; @@ -60,7 +60,7 @@ pub fn transform_lazy( }); }; - let source_type = source_type_for_filename(Some(filename))?; + let source_type = secondary_source_type(Some(filename)); let allocator = Allocator::default(); let parsed = Parser::new(&allocator, &code, source_type) .with_options(ParseOptions { diff --git a/packages/compiler/src/refresh/mod.rs b/packages/compiler/src/refresh/mod.rs index fcdc2fe4c4..5a1d8eda9b 100644 --- a/packages/compiler/src/refresh/mod.rs +++ b/packages/compiler/src/refresh/mod.rs @@ -25,7 +25,7 @@ use oxc_allocator::Allocator; use oxc_codegen::{Codegen, CodegenOptions}; use oxc_parser::{ParseOptions, Parser}; -use crate::config::{TransformResult, source_type_for_filename}; +use crate::config::{TransformResult, secondary_source_type}; use transform::{Bundler, RefreshConfig, RefreshTransform}; pub const DEFAULT_IMPORT_SOURCE: &str = "solid-refresh"; @@ -90,7 +90,7 @@ pub fn transform_refresh( .unwrap_or_else(|| DEFAULT_IMPORT_SOURCE.to_string()), }; - let source_type = source_type_for_filename(options.filename.as_deref())?; + let source_type = secondary_source_type(options.filename.as_deref()); let allocator = Allocator::default(); let parsed = Parser::new(&allocator, &code, source_type) .with_options(ParseOptions { diff --git a/packages/compiler/src/source_names.rs b/packages/compiler/src/source_names.rs index 8376a93aeb..71895204be 100644 --- a/packages/compiler/src/source_names.rs +++ b/packages/compiler/src/source_names.rs @@ -51,7 +51,7 @@ use oxc_semantic::{Scoping, SemanticBuilder, SymbolId}; use oxc_span::{GetSpan, Span}; use oxc_syntax::scope::ScopeFlags; -use crate::config::{TransformResult, source_type_for_filename}; +use crate::config::{TransformResult, secondary_source_type}; use crate::shared::ast::object_property; use crate::shared::ast_builder::AstBuilder; @@ -93,7 +93,7 @@ pub fn transform_source_names( return Ok(untouched(code)); } - let source_type = source_type_for_filename(filename)?; + let source_type = secondary_source_type(filename); let allocator = Allocator::default(); let parsed = Parser::new(&allocator, &code, source_type) .with_options(ParseOptions { From 72ff6d5e125c041620393991fa2aef89209ff37a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 08:49:04 +0000 Subject: [PATCH 2/2] fix(babel-plugin): keep evaluated negative/non-finite numbers static #4309 switched evaluateAndInline to t.valueToNode for numbers, which builds -1 / 0/0 expressions; the SSR and universal transforms then treated them as dynamic, diverging from the oxc compiler (parity-probes "exotic literal holes"). Build NumericLiteral nodes directly where @babel/types 8 rejects the value. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0117BoWMdD4K7882vob4J9vq --- .changeset/babel-evaluated-number-literals.md | 5 +++++ packages/babel-plugin/src/shared/utils.ts | 12 +++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 .changeset/babel-evaluated-number-literals.md diff --git a/.changeset/babel-evaluated-number-literals.md b/.changeset/babel-evaluated-number-literals.md new file mode 100644 index 0000000000..168139cb1f --- /dev/null +++ b/.changeset/babel-evaluated-number-literals.md @@ -0,0 +1,5 @@ +--- +"@solidjs/babel-plugin": patch +--- + +Evaluated negative and non-finite numbers (`c={-1}`, `d={NaN}`) are static again. Since Babel 8 support, `evaluateAndInline` built them with `t.valueToNode`, which gives `-1` / `0 / 0` expressions rather than numeric literals, so they stopped being inlined into templates (SSR emitted `ssrAttribute("c", -1)` instead of `c="-1"`) and diverged from `@solidjs/compiler` (the parity probes' "exotic literal holes"). They are now NumericLiteral nodes again, built directly where @babel/types 8's builder would reject the value; both Babel majors print them as before. diff --git a/packages/babel-plugin/src/shared/utils.ts b/packages/babel-plugin/src/shared/utils.ts index ae3953e41b..66b38080a6 100644 --- a/packages/babel-plugin/src/shared/utils.ts +++ b/packages/babel-plugin/src/shared/utils.ts @@ -759,6 +759,16 @@ const templateEscapes = new Map([ ["\u2029", "\\u2029"] ]); +// A NumericLiteral for any evaluated number, so `-1` and `NaN` stay static +// literals downstream. @babel/types 8's builder rejects negative and +// non-finite values (`t.valueToNode` builds `-1` / `0 / 0` expressions +// instead), so those are built as plain nodes; generators print them as +// `-1` / `NaN` / `Infinity`, as Babel 7's builder did. +function numberLiteral(value: number): t.NumericLiteral { + if (value >= 0 && Number.isFinite(value) && !Object.is(value, -0)) return t.numericLiteral(value); + return { type: "NumericLiteral", value } as t.NumericLiteral; +} + export function evaluateAndInline( value: EvaluateAndInlineNode | null | undefined, valueNode: NodePath @@ -786,7 +796,7 @@ export function evaluateAndInline( if (typeof r.value === "string") { valueNode.replaceWith(t.stringLiteral(r.value)); } else if (typeof r.value === "number") { - valueNode.replaceWith(t.valueToNode(r.value)); + valueNode.replaceWith(numberLiteral(r.value)); } else if (typeof r.value === "boolean") { valueNode.replaceWith(t.booleanLiteral(r.value)); }