From ec428e1a6031c1b8eb693074b510868bbd124b48 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 8 Oct 2026 12:37:17 -0700 Subject: [PATCH] fix: resolve slash-prefixed lazy() moduleUrl keys in dev and build Co-authored-by: Cursor --- .changeset/lazy-moduleurl-leading-slash.md | 5 ++++ examples/css-matrix/test/bridge.mjs | 16 +++++++++++ examples/start-ssr/src/App.tsx | 6 +++- examples/start-ssr/src/SlashLazy.tsx | 8 ++++++ examples/start-ssr/test/run.mjs | 32 ++++++++++++++++++---- src/dev-manifest.ts | 15 +++++++++- src/index.ts | 24 +++++++++++----- 7 files changed, 92 insertions(+), 14 deletions(-) create mode 100644 .changeset/lazy-moduleurl-leading-slash.md create mode 100644 examples/start-ssr/src/SlashLazy.tsx diff --git a/.changeset/lazy-moduleurl-leading-slash.md b/.changeset/lazy-moduleurl-leading-slash.md new file mode 100644 index 00000000..bb2dc681 --- /dev/null +++ b/.changeset/lazy-moduleurl-leading-slash.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +A hand-written `lazy()` moduleUrl with a leading slash (`lazy(() => import("./Page"), undefined, "/src/Page.tsx")`) now resolves like the project-relative key `src/Page.tsx` (#390). In dev the asset resolver built a protocol-relative `//src/Page.tsx` URL, so the preload failed and hydration fell back to a client render; in production the `virtual:solid-manifest` lookup missed, no client assets or root module map were emitted, and hydration threw "was not preloaded before hydration". The dev resolver (in-process, HTTP bridge, and the generated fallback) now strips the leading slash before building the URL and walking the module graph, and the baked build manifest answers slash-prefixed lookups through non-enumerable aliases, so `for…in`, `Object.keys`, and JSON consumers see the manifest unchanged. diff --git a/examples/css-matrix/test/bridge.mjs b/examples/css-matrix/test/bridge.mjs index d30d3a57..5636d9a7 100644 --- a/examples/css-matrix/test/bridge.mjs +++ b/examples/css-matrix/test/bridge.mjs @@ -92,6 +92,15 @@ try { assets.css.some((c) => typeof c === 'object' && c.content?.includes(LAZY_CSS_COLOR)), JSON.stringify(assets?.css)?.slice(0, 200), ); + const slashHit = await fetch(`${origin}${ENDPOINT}?key=${encodeURIComponent('/' + LAZY_KEY)}`); + const slashAssets = await slashHit.json(); + record( + 'slash-prefixed key resolves like the root-relative key (URL and CSS, #390)', + slashAssets?.js?.length === 1 && + slashAssets.js[0] === '/' + LAZY_KEY && + slashAssets.css?.some((c) => typeof c === 'object' && c.content?.includes(LAZY_CSS_COLOR)), + JSON.stringify(slashAssets)?.slice(0, 200), + ); // ---- Serve-side hardening: registry miss -------------------------------- { @@ -127,6 +136,13 @@ try { coldSync?.js?.includes('/' + LAZY_KEY) && coldSync?.css?.length === 0, JSON.stringify(coldSync), ); + // A hand-written slash-prefixed moduleUrl names the same module (#390). + const slashSync = bridgeResolver.resolveSync('/' + LAZY_KEY); + record( + 'bridge resolveSync maps a slash-prefixed key to the root-relative URL (no "//")', + slashSync?.js?.length === 1 && slashSync.js[0] === '/' + LAZY_KEY, + JSON.stringify(slashSync), + ); const bridged = await bridgeResolver.resolve(LAZY_KEY); record( 'bridge resolver answers full assets over HTTP', diff --git a/examples/start-ssr/src/App.tsx b/examples/start-ssr/src/App.tsx index 0510eecd..d027dade 100644 --- a/examples/start-ssr/src/App.tsx +++ b/examples/start-ssr/src/App.tsx @@ -29,9 +29,12 @@ const OnlyClient = clientOnly(() => import('./ClientOnlyWidget')); // (facade chunk, manifest key, dev URL) and must survive the SSR asset // lookup (#299), // - a module outside the Vite root — its dev URL must be a base-prefixed -// /@fs/ URL, not "/../…" (#298). +// /@fs/ URL, not "/../…" (#298), +// - a hand-written moduleUrl with a leading slash — the same module as the +// project-relative key in dev and prod (#390). const LazyQuery = lazy(() => import('./QueryLazy.tsx?variant=a')); const LazyOutside = lazy(() => import('../../start-ssr-external/LazyOutside')); +const LazySlash = lazy(() => import('./SlashLazy'), undefined, '/src/SlashLazy.tsx'); // Also a configured client build input in extra-input mode (#353): a lazily // imported module that is a genuine entry too, like a filesystem router's // `buildInputs` route modules. @@ -46,6 +49,7 @@ function LazyAssetsSection() { lazy…

}> +
); diff --git a/examples/start-ssr/src/SlashLazy.tsx b/examples/start-ssr/src/SlashLazy.tsx new file mode 100644 index 00000000..4dbd4d86 --- /dev/null +++ b/examples/start-ssr/src/SlashLazy.tsx @@ -0,0 +1,8 @@ +// Lazily imported with a hand-written, slash-prefixed moduleUrl +// (`lazy(…, undefined, '/src/SlashLazy.tsx')` in src/App.tsx). The compiler +// leaves three-argument calls alone, so the key reaches the asset resolvers +// as written. Regression fixture for #390: dev emitted a protocol-relative +// `//src/…` URL and the production manifest lookup missed. +export default function SlashLazy() { + return

SLASH-LAZY-CONTENT

; +} diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index 229d575b..d20091a7 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -128,7 +128,9 @@ // - lazy asset keys survive module identities beyond plain root-relative // paths (the /lazy-assets surface, dev and prod): a query-suffixed lazy // import keeps its query through the manifest key / dev URL (#299), and -// a root-external module gets a /@fs/ dev URL (#298), +// a root-external module gets a /@fs/ dev URL (#298), and a +// hand-written slash-prefixed moduleUrl resolves like the +// project-relative key (#390), // - a non-root Vite `base` (base mode, SOLID_BASE=/app/) holds end to end: // dev pages/assets/endpoint and preview pages/statics/endpoint all serve // base-prefixed, the built handler receives base-restored URLs from the @@ -622,17 +624,20 @@ async function runHttpChecks(mode, origin) { // for #299 (the query is part of the module identity — manifest key, dev // URL — and must survive the SSR asset lookup) and #298 (dev URLs must be // base-prefixed, and root-external modules must resolve to /@fs/ URLs, not -// "/../…"). `basePrefix` is the configured Vite base without its trailing -// slash ('' for the default '/'), asserted on every emitted URL. +// "/../…"), plus a hand-written slash-prefixed moduleUrl (#390: dev emitted +// a protocol-relative "//src/…" URL, the manifest lookup missed in prod). +// `basePrefix` is the configured Vite base without its trailing slash ('' +// for the default '/'), asserted on every emitted URL. async function runLazyAssetChecks(mode, origin, { dev, basePrefix = '' } = {}) { const page = await fetchStreamed(origin + basePrefix + '/lazy-assets'); record( mode, 'lazy', - 'query-suffixed and root-external lazy components SSR', + 'query-suffixed, root-external and slash-keyed lazy components SSR', page.status === 200 && page.html.includes('QUERY-LAZY-CONTENT') && - page.html.includes('EXTERNAL-LAZY-CONTENT'), + page.html.includes('EXTERNAL-LAZY-CONTENT') && + page.html.includes('SLASH-LAZY-CONTENT'), `status ${page.status}`, ); const preloads = [...page.html.matchAll(//g)].map( @@ -640,6 +645,7 @@ async function runLazyAssetChecks(mode, origin, { dev, basePrefix = '' } = {}) { ); const queryHref = preloads.find((href) => href.includes('QueryLazy')); const externalHref = preloads.find((href) => href.includes('LazyOutside')); + const slashHref = preloads.find((href) => href.includes('SlashLazy')); if (dev) { record( @@ -658,6 +664,13 @@ async function runLazyAssetChecks(mode, origin, { dev, basePrefix = '' } = {}) { externalHref.endsWith('start-ssr-external/LazyOutside.tsx'), `modulepreloads: ${preloads.join(', ') || '(none)'}`, ); + record( + mode, + 'lazy', + 'slash-keyed module preloaded by its root-relative dev URL (no "//")', + slashHref === `${basePrefix}/src/SlashLazy.tsx`, + `modulepreloads: ${preloads.join(', ') || '(none)'}`, + ); } else { const clientManifest = JSON.parse( readFileSync(path.join(exampleDir, 'dist/client/.vite/manifest.json'), 'utf-8'), @@ -685,6 +698,14 @@ async function runLazyAssetChecks(mode, origin, { dev, basePrefix = '' } = {}) { !!externalEntry?.file && externalHref === `${basePrefix}/${externalEntry.file}`, `modulepreloads: ${preloads.join(', ') || '(none)'}`, ); + const slashEntry = clientManifest['src/SlashLazy.tsx']; + record( + mode, + 'lazy', + 'slash-keyed module preload resolved through the manifest', + !!slashEntry?.file && slashHref === `${basePrefix}/${slashEntry.file}`, + `modulepreloads: ${preloads.join(', ') || '(none)'}`, + ); } // The emitted URLs must actually be servable — a wrong base or a mangled @@ -692,6 +713,7 @@ async function runLazyAssetChecks(mode, origin, { dev, basePrefix = '' } = {}) { for (const [name, href] of [ ['queried module', queryHref], ['root-external module', externalHref], + ['slash-keyed module', slashHref], ]) { if (!href) { record(mode, 'lazy', `${name} preload URL serves JS`, false, 'no modulepreload emitted'); diff --git a/src/dev-manifest.ts b/src/dev-manifest.ts index 5aa85a2b..80c6ea76 100644 --- a/src/dev-manifest.ts +++ b/src/dev-manifest.ts @@ -342,6 +342,16 @@ export function renderDevStyleTag(desc: DevStyleDescriptor): string { return ``; } +/** + * Module keys are project-root-relative (`src/Page.tsx`), but a hand-written + * `lazy()` moduleUrl often carries a leading slash (`/src/Page.tsx`) — the + * compiler only rewrites one- and two-argument calls, so the string reaches + * the resolvers as written. Both spellings name the same module (#390). + */ +export function normalizeModuleKey(key: string): string { + return key.replace(/^\/+/, ''); +} + /** * Browser URL for a lazy module's dev asset key (a project-root-relative * path, query included when the module identity carries one). Vite only @@ -352,6 +362,7 @@ export function renderDevStyleTag(desc: DevStyleDescriptor): string { * `devManifestCode` (src/index.ts) — keep the two in sync. */ export function devModuleUrl(root: string, base: string, key: string): string { + key = normalizeModuleKey(key); const queryIndex = key.indexOf('?'); const file = queryIndex === -1 ? key : key.slice(0, queryIndex); const query = queryIndex === -1 ? '' : key.slice(queryIndex); @@ -390,6 +401,7 @@ export function createDevAssetResolver( const resolve = function resolveDevAssets( key: string, ): ResolvedAssets | Promise { + key = normalizeModuleKey(key); const cached = resolved.get(key); if (cached) return cached; let walk = pending.get(key); @@ -420,6 +432,7 @@ export function createDevAssetResolver( }; return { resolve, - resolveSync: (key: string) => resolved.get(key) ?? { js: [devModuleUrl(root, base, key)], css: [] }, + resolveSync: (key: string) => + resolved.get(normalizeModuleKey(key)) ?? { js: [devModuleUrl(root, base, key)], css: [] }, }; } diff --git a/src/index.ts b/src/index.ts index 2fa1eab7..07510ce4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -113,6 +113,7 @@ const devManifestCode = (root: string, base: string, bridgeUrl: string | null) = const projectRoot = ${JSON.stringify(root.split(path.sep).join('/'))}; const base = ${JSON.stringify(base.startsWith('/') ? base.replace(/\/$/, '') : '')}; function moduleUrl(key) { + key = key.replace(/^\\/+/, ""); const queryIndex = key.indexOf("?"); const file = queryIndex === -1 ? key : key.slice(0, queryIndex); const query = queryIndex === -1 ? "" : key.slice(queryIndex); @@ -1954,13 +1955,22 @@ export default function solidPlugin(options: Partial = {}): Plugin[] { warn: (message) => this.warn(message), repairDynamicEntries: true, }); - return `export default ${JSON.stringify( - stampClientEntry( - manifest, - resolveClientEntryKey(manifest, startClientEntryId, clientBuildConfig, projectRoot), - base, - ), - )};`; + const stamped = stampClientEntry( + manifest, + resolveClientEntryKey(manifest, startClientEntryId, clientBuildConfig, projectRoot), + base, + ); + // The runtime looks records up as `manifest[moduleUrl]`, so a + // hand-written slash-prefixed key (`/src/Page.tsx`, #390) needs + // its own property. The aliases are non-enumerable: `for…in`, + // `Object.keys` and JSON consumers see the manifest unchanged. + return `const manifest = ${JSON.stringify(stamped)}; +for (const key of Object.keys(manifest)) { + if (key[0] !== "/" && typeof manifest[key] === "object") { + Object.defineProperty(manifest, "/" + key, { value: manifest[key] }); + } +} +export default manifest;`; } // SSR build before the client build produced a manifest: bake in the // dev-shaped fallback (registry miss degrades to js-only resolution).