From af79dd809914250f30452fde9a6e9e5500999016 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 9 Oct 2026 01:08:07 -0700 Subject: [PATCH] fix: annotate non-JSX modules with lazy module URLs Plain .ts/.js files returned before the lazy pipeline, so a routes.ts callsite never got a module URL and an SSR target never got $$moduleUrl. Co-authored-by: Cursor --- .changeset/ts-lazy-module-url.md | 5 + examples/ssr/package.json | 2 +- examples/ssr/test/fixtures/ts-lazy/page.ts | 5 + examples/ssr/test/fixtures/ts-lazy/routes.ts | 5 + examples/ssr/test/fixtures/ts-lazy/types.d.ts | 3 + examples/ssr/test/ts-module-url.mjs | 156 ++++++++++++++++++ src/index.ts | 61 +++++-- 7 files changed, 224 insertions(+), 13 deletions(-) create mode 100644 .changeset/ts-lazy-module-url.md create mode 100644 examples/ssr/test/fixtures/ts-lazy/page.ts create mode 100644 examples/ssr/test/fixtures/ts-lazy/routes.ts create mode 100644 examples/ssr/test/fixtures/ts-lazy/types.d.ts create mode 100644 examples/ssr/test/ts-module-url.mjs diff --git a/.changeset/ts-lazy-module-url.md b/.changeset/ts-lazy-module-url.md new file mode 100644 index 0000000..a4a3010 --- /dev/null +++ b/.changeset/ts-lazy-module-url.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +Plain `.ts` and `.js` modules now get lazy callsite module URLs and, on the server, a `$$moduleUrl` export. Non-JSX files used to return before that pipeline, so a `routes.ts` of `lazy(() => import(...))` never told SSR which module to preload. diff --git a/examples/ssr/package.json b/examples/ssr/package.json index 4f44ff9..118f7cb 100644 --- a/examples/ssr/package.json +++ b/examples/ssr/package.json @@ -8,7 +8,7 @@ "build:client": "vite build --outDir dist/client", "build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server", "serve": "NODE_ENV=production node server.js", - "test": "node test/run.mjs && node test/boundary.mjs && node test/scan.mjs && node test/server-components-define.mjs" + "test": "node test/run.mjs && node test/boundary.mjs && node test/scan.mjs && node test/server-components-define.mjs && node test/ts-module-url.mjs" }, "devDependencies": { "vite": "^8.2.1", diff --git a/examples/ssr/test/fixtures/ts-lazy/page.ts b/examples/ssr/test/fixtures/ts-lazy/page.ts new file mode 100644 index 0000000..8134677 --- /dev/null +++ b/examples/ssr/test/fixtures/ts-lazy/page.ts @@ -0,0 +1,5 @@ +// SSR lazy target with no JSX (#406). The server compile stamps the +// module id; the client compile must not. +export default function Page() { + return 'TS-LAZY-PAGE'; +} diff --git a/examples/ssr/test/fixtures/ts-lazy/routes.ts b/examples/ssr/test/fixtures/ts-lazy/routes.ts new file mode 100644 index 0000000..bf79728 --- /dev/null +++ b/examples/ssr/test/fixtures/ts-lazy/routes.ts @@ -0,0 +1,5 @@ +import { lazy } from 'solid-js'; + +// Plain `.ts` callsite (#406). Primitive naming is off in the test +// (`solid.sourceNames: false`); the module URL must still be written. +export const Page = lazy(() => import('./page.ts')); diff --git a/examples/ssr/test/fixtures/ts-lazy/types.d.ts b/examples/ssr/test/fixtures/ts-lazy/types.d.ts new file mode 100644 index 0000000..d390eb7 --- /dev/null +++ b/examples/ssr/test/fixtures/ts-lazy/types.d.ts @@ -0,0 +1,3 @@ +// Declaration modules are not script modules. The SSR compile must not +// stamp a module id onto them. +export type TsLazyMarker = string; diff --git a/examples/ssr/test/ts-module-url.mjs b/examples/ssr/test/ts-module-url.mjs new file mode 100644 index 0000000..b74a844 --- /dev/null +++ b/examples/ssr/test/ts-module-url.mjs @@ -0,0 +1,156 @@ +// Non-JSX lazy module URLs (#406). `transform()` used to return before the +// lazy pipeline for anything that was not JSX, so a `routes.ts` of +// `lazy(() => import(...))` never got a callsite module URL and an SSR +// `.ts` target never got `$$moduleUrl`. Primitive naming is forced off +// (`solid.sourceNames: false`) — in dev it would otherwise be on, and the +// pipeline must not be gated on it. A production build is asserted as well +// as the dev transform: module URLs stay project-relative manifest keys +// (no leading slash, no dist path), the same contract as the JSX path and +// the #390 slash-key coverage. +// +// Requires the plugin built (pnpm build at the repo root). No browser. +// Usage: node test/ts-module-url.mjs + +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { build, createServer } from 'vite'; +import solidPlugin from '@solidjs/vite-plugin'; + +const exampleDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const fixture = (name) => path.join(exampleDir, 'test/fixtures/ts-lazy', name); +const routesFile = fixture('routes.ts'); +const PAGE_KEY = 'test/fixtures/ts-lazy/page.ts'; +// The compiler's third `lazy()` argument. Vite rewrites the import specifier +// (dev URL, or a hashed chunk in the build); this key stays project-relative. +const PAGE_CALLSITE = `, void 0, "${PAGE_KEY}"`; +const PAGE_MODULE_URL = `$$moduleUrl = "${PAGE_KEY}"`; + +const results = []; +function record(name, ok, detail = '') { + results.push({ name, ok, detail }); + console.log( + ` [ts-module-url] ${ok ? 'PASS' : 'FAIL'} ${name}${detail && !ok ? ` — ${detail}` : ''}`, + ); +} + +function plugin() { + return solidPlugin({ ssr: true, solid: { sourceNames: false } }); +} + +function hasCallsite(code) { + return !!code && code.includes(PAGE_CALLSITE) && !code.includes('__SOLID_LAZY_MODULE__'); +} + +async function bundle(ssr) { + const result = await build({ + root: exampleDir, + configFile: false, + logLevel: 'silent', + plugins: [plugin()], + build: { + write: false, + minify: false, + ssr: ssr ? routesFile : false, + rollupOptions: ssr ? {} : { input: routesFile }, + }, + }); + const outputs = Array.isArray(result) ? result : [result]; + return outputs + .flatMap((output) => ('output' in output ? output.output : [])) + .map((chunk) => (chunk.type === 'chunk' ? chunk.code : '')) + .join('\n'); +} + +const server = await createServer({ + root: exampleDir, + configFile: false, + logLevel: 'silent', + plugins: [plugin()], + server: { middlewareMode: true }, + optimizeDeps: { noDiscovery: true }, +}); + +try { + const ssrRoutes = await server.environments.ssr.transformRequest( + '/test/fixtures/ts-lazy/routes.ts', + ); + const ssrPage = await server.environments.ssr.transformRequest('/test/fixtures/ts-lazy/page.ts'); + const clientRoutes = await server.environments.client.transformRequest( + '/test/fixtures/ts-lazy/routes.ts', + ); + const clientPage = await server.environments.client.transformRequest( + '/test/fixtures/ts-lazy/page.ts', + ); + let dtsCode = ''; + try { + const dts = await server.environments.ssr.transformRequest('/test/fixtures/ts-lazy/types.d.ts'); + dtsCode = dts?.code ?? ''; + } catch (error) { + dtsCode = String(error); + } + + record( + 'dev SSR callsite gets the project-relative module URL', + hasCallsite(ssrRoutes?.code), + ssrRoutes?.code ?? 'no code', + ); + record( + 'dev SSR lazy target exports $$moduleUrl', + !!ssrPage?.code?.includes(PAGE_MODULE_URL), + ssrPage?.code ?? 'no code', + ); + record( + 'dev client callsite gets the module URL and no refresh runtime', + hasCallsite(clientRoutes?.code) && !clientRoutes.code.includes('solid-js/refresh'), + clientRoutes?.code ?? 'no code', + ); + record( + 'dev client lazy target has no $$moduleUrl', + !clientPage?.code?.includes('$$moduleUrl'), + clientPage?.code ?? 'no code', + ); + record( + 'dev SSR does not stamp $$moduleUrl onto .d.ts', + !dtsCode.includes('$$moduleUrl'), + dtsCode.slice(0, 300), + ); +} finally { + await server.close(); +} + +{ + const serverBundle = await bundle(true); + record( + 'production SSR callsite gets the project-relative module URL', + hasCallsite(serverBundle), + serverBundle.slice(0, 500), + ); + record( + 'production SSR lazy target exports $$moduleUrl', + serverBundle.includes(PAGE_MODULE_URL), + serverBundle.includes('$$moduleUrl') ? 'export shape differed' : '$$moduleUrl missing', + ); +} +{ + const clientBundle = await bundle(false); + record( + 'production client callsite gets the project-relative module URL', + hasCallsite(clientBundle), + clientBundle.slice(0, 500), + ); + record( + 'production client has no $$moduleUrl', + !clientBundle.includes('$$moduleUrl'), + clientBundle.includes('$$moduleUrl') ? 'stamped a client module' : '', + ); +} + +const failures = results.filter((r) => !r.ok); +console.log( + `\n${results.length - failures.length}/${results.length} ts module-url assertions passed`, +); +if (failures.length) { + console.log('\nFailures:'); + for (const f of failures) console.log(` ${f.name} — ${f.detail}`); +} +process.exit(failures.length ? 1 : 0); diff --git a/src/index.ts b/src/index.ts index 6a03f12..cfc9af2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -740,10 +740,12 @@ function getSourceNames( } /** - * The `sourceNames.primitives` pass is plain JavaScript in and out, so it - * also applies to the `.ts`/`.js` modules primitives are composed in — the - * ids the JSX transform gate below would otherwise return early for. A - * `.d.ts` has nothing to name. + * Script modules the JSX gate returns early for: `.ts`/`.js` and the `m`/`c` + * variants. Primitive naming applies here (plain JavaScript in and out; a + * `.d.ts` has nothing to name), and so does the lazy module-URL pipeline — + * a `routes.ts` is not JSX, but its `lazy()` callsites and an SSR module's + * `$$moduleUrl` export still have to be written. Refresh and the JSX + * transform stay on the JSX path. */ const PRIMITIVES_ONLY_MODULE = /\.[mc]?[jt]s$/i; const DECLARATION_MODULE = /\.d\.[mc]?ts$/i; @@ -2123,16 +2125,51 @@ export default manifest;`; !inNodeModules && getSourceNames(options, replaceDev, observe).primitives; if (!(/\.[mc]?[tj]sx$/i.test(id) || isTsrx || allExtensions.includes(currentFileExtension))) { - // Not a JSX module. The one pass that still applies is primitive - // naming — `createSignal` lives in `.ts`/`.js` as much as in - // components — and it runs alone: no lazy/refresh/JSX work. - if (namePrimitives && PRIMITIVES_ONLY_MODULE.test(id) && !DECLARATION_MODULE.test(id)) { + // Not a JSX module. Primitive naming stays gated as before + // (`createSignal` lives in `.ts`/`.js` as much as in components). + // The lazy module-URL pipeline also runs, and it is not gated on + // naming: a production `routes.ts` of `lazy(() => import(...))` + // still needs callsite module URLs, and an SSR script module still + // needs `$$moduleUrl`. No JSX transform and no refresh here — those + // stay on the JSX path. `.d.ts` is left untouched. + if (!PRIMITIVES_ONLY_MODULE.test(id) || DECLARATION_MODULE.test(id)) { + return null; + } + + let code = source; + const maps: ChainableMap[] = []; + // Same cheap pre-check as primitive naming: `transformLazy` only + // rewrites `lazy()` calls, so source without that identifier cannot + // change. SSR still appends `$$moduleUrl` below. + const needsCompiler = namePrimitives || code.includes('lazy'); + if (needsCompiler) { const compiler = await loadNativeCompiler(); - const named = await transformPrimitiveNames(this, compiler, source, id); - if (named === null) return null; - return { code: named.code, map: normalizeSourceMap(named.map) }; + + if (namePrimitives) { + const named = await transformPrimitiveNames(this, compiler, code, id); + if (named !== null) { + code = named.code; + maps.push(named.map); + } + } + + if (code.includes('lazy')) { + const lazyResult = await compiler.transformLazyAsync(code, { + filename: id, + sourceMap: true, + }); + code = lazyResult.code; + maps.push(lazyResult.map); + } } - return null; + + const finalCode = injectSsrModuleId( + await resolveLazyModuleUrls(this, code, id), + moduleId, + !!isSsr, + ); + if (finalCode === source) return null; + return { code: finalCode, map: combineSourcemaps(maps) }; } const solidOptions = getSolidOptions(options, !!isSsr, replaceDev, observe, isTestMode);