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/ts-lazy-module-url.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion examples/ssr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions examples/ssr/test/fixtures/ts-lazy/page.ts
Original file line number Diff line number Diff line change
@@ -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';
}
5 changes: 5 additions & 0 deletions examples/ssr/test/fixtures/ts-lazy/routes.ts
Original file line number Diff line number Diff line change
@@ -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'));
3 changes: 3 additions & 0 deletions examples/ssr/test/fixtures/ts-lazy/types.d.ts
Original file line number Diff line number Diff line change
@@ -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;
156 changes: 156 additions & 0 deletions examples/ssr/test/ts-module-url.mjs
Original file line number Diff line number Diff line change
@@ -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);
61 changes: 49 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading