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() {
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