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/lazy-moduleurl-leading-slash.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions examples/css-matrix/test/bridge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 --------------------------------
{
Expand Down Expand Up @@ -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',
Expand Down
6 changes: 5 additions & 1 deletion examples/start-ssr/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -46,6 +49,7 @@ function LazyAssetsSection() {
<Loading fallback={<p>lazy…</p>}>
<LazyQuery />
<LazyOutside />
<LazySlash />
</Loading>
</section>
);
Expand Down
8 changes: 8 additions & 0 deletions examples/start-ssr/src/SlashLazy.tsx
Original file line number Diff line number Diff line change
@@ -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 <p id="slash-lazy">SLASH-LAZY-CONTENT</p>;
}
32 changes: 27 additions & 5 deletions examples/start-ssr/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -622,24 +624,28 @@ 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(/<link rel="modulepreload" href="([^"]+)">/g)].map(
(m) => m[1],
);
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(
Expand All @@ -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'),
Expand Down Expand Up @@ -685,13 +698,22 @@ 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
// /@fs/ path 404s (dev) or misses the static handler (preview/prod).
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');
Expand Down
15 changes: 14 additions & 1 deletion src/dev-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,16 @@ export function renderDevStyleTag(desc: DevStyleDescriptor): string {
return `<style data-asset="${escapeAttr(desc.id)}"${attrs}>${content}</style>`;
}

/**
* 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
Expand All @@ -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);
Expand Down Expand Up @@ -390,6 +401,7 @@ export function createDevAssetResolver(
const resolve = function resolveDevAssets(
key: string,
): ResolvedAssets | Promise<ResolvedAssets | null> {
key = normalizeModuleKey(key);
const cached = resolved.get(key);
if (cached) return cached;
let walk = pending.get(key);
Expand Down Expand Up @@ -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: [] },
};
}
24 changes: 17 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -1954,13 +1955,22 @@ export default function solidPlugin(options: Partial<Options> = {}): 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).
Expand Down
Loading