Skip to content

Commit f6a9635

Browse files
authored
Merge pull request #223 from JoviDeCroock/JoviDeCroock/preload-client-entry-deps
Preload the client entry's static import closure
2 parents b183114 + 1b5c2a5 commit f6a9635

8 files changed

Lines changed: 144 additions & 14 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@pracht/core": patch
3+
"@pracht/vite-plugin": patch
4+
"@pracht/cli": patch
5+
---
6+
7+
Emit modulepreload links for the client entry's own static import closure. The client entry statically imports secondary chunks (shared runtime, preload helper), but generated HTML previously only preloaded shell/route chunks — so the browser discovered those imports only after downloading and parsing the entry, adding a serial round trip before hydration. The build now stores each entry's transitive static JS imports in the js manifest under its virtual module id, and both server-rendered and prerendered pages merge them into the page's modulepreload links. Islands pages preload the islands bootstrap's closure; `hydration: "none"` pages still emit no JS at all.

packages/cli/src/build-metadata.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,20 +89,43 @@ export function readClientBuildAssets(root: string = process.cwd()): ClientBuild
8989
}
9090
}
9191

92+
const clientEntryJs = clientEntry
93+
? collectTransitiveDeps("virtual:pracht/client").js.map((file) => `/${file}`)
94+
: [];
95+
const islandsEntryJs = islandsEntry
96+
? collectTransitiveDeps("virtual:pracht/islands-client").js.map((file) => `/${file}`)
97+
: [];
98+
99+
// Mirror @pracht/vite-plugin's readClientBuildAssets: expose each entry's
100+
// static import closure (minus the entry file itself) under its virtual
101+
// module id so prerendered pages get the same modulepreload links as
102+
// server-rendered ones.
103+
addEntryDeps(jsManifest, "virtual:pracht/client", clientEntry, clientEntryJs);
104+
addEntryDeps(jsManifest, "virtual:pracht/islands-client", islandsEntry, islandsEntryJs);
105+
92106
return {
93107
clientEntryUrl: clientEntry ? `/${clientEntry.file}` : null,
94-
clientEntryJs: clientEntry
95-
? collectTransitiveDeps("virtual:pracht/client").js.map((file) => `/${file}`)
96-
: [],
108+
clientEntryJs,
97109
islandsEntryUrl: islandsEntry ? `/${islandsEntry.file}` : null,
98-
islandsEntryJs: islandsEntry
99-
? collectTransitiveDeps("virtual:pracht/islands-client").js.map((file) => `/${file}`)
100-
: [],
110+
islandsEntryJs,
101111
cssManifest,
102112
jsManifest,
103113
};
104114
}
105115

116+
function addEntryDeps(
117+
jsManifest: Record<string, string[]>,
118+
entryKey: string,
119+
entry: ViteManifestEntry | undefined,
120+
entryJs: string[],
121+
): void {
122+
if (!entry) return;
123+
const deps = entryJs.filter((url) => url !== `/${entry.file}`);
124+
if (deps.length > 0) {
125+
jsManifest[entryKey] = deps;
126+
}
127+
}
128+
106129
function stripPrachtClientModuleQuery(id: string): string {
107130
const queryStart = id.indexOf("?");
108131
if (queryStart === -1) return id;

packages/cli/test/pracht-cli.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ export const app = defineApp({
340340
jsManifest: {
341341
"src/routes/dashboard.tsx": ["/assets/dashboard.js", "/assets/vendor.js"],
342342
"src/shells/app.tsx": ["/assets/app.js", "/assets/vendor.js"],
343+
"virtual:pracht/client": ["/assets/vendor.js"],
343344
},
344345
},
345346
mode: "manifest",

packages/framework/src/runtime-manifest.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
import type { ModuleImporter, ModuleRegistry } from "./types.ts";
22

3+
// Reserved jsManifest keys under which the build stores the transitive static
4+
// JS imports of the client/islands entry chunks. Without these, the browser
5+
// only discovers the entry's secondary chunks after downloading and parsing
6+
// the entry itself — one extra serial round trip before hydration. Must match
7+
// the virtual module ids in @pracht/vite-plugin (plugin-assets.ts).
8+
export const CLIENT_ENTRY_MANIFEST_KEY = "virtual:pracht/client";
9+
export const ISLANDS_ENTRY_MANIFEST_KEY = "virtual:pracht/islands-client";
10+
11+
/**
12+
* Merge an entry chunk's own static import urls into a page's modulepreload
13+
* list. Entry deps come first — they gate hydration — and duplicates from the
14+
* page's route/shell chunk closure are dropped.
15+
*/
16+
export function mergeEntryPreloadUrls(
17+
jsManifest: Record<string, string[]> | undefined,
18+
entryKey: string,
19+
pageUrls: string[],
20+
): string[] {
21+
const entryUrls = jsManifest?.[entryKey];
22+
if (!entryUrls || entryUrls.length === 0) return pageUrls;
23+
return [...new Set([...entryUrls, ...pageUrls])];
24+
}
25+
326
/** Strip leading `./` and `/` so all module paths share one canonical form. */
427
export function normalizeModulePath(path: string): string {
528
return path.replace(/^\.?\//, "");

packages/framework/src/runtime-response.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import {
1616
} from "./runtime-headers.ts";
1717
import { buildHtmlDocument, htmlResponse } from "./runtime-html.ts";
1818
import {
19+
CLIENT_ENTRY_MANIFEST_KEY,
20+
ISLANDS_ENTRY_MANIFEST_KEY,
21+
mergeEntryPreloadUrls,
1922
resolveManifestEntries,
2023
resolvePageCssUrls,
2124
resolvePageJsUrls,
@@ -214,10 +217,10 @@ export async function renderRouteErrorResponse<TContext>(options: {
214217
options.shellFile,
215218
options.routeArgs.route.file,
216219
);
217-
const modulePreloadUrls = resolvePageJsUrls(
220+
const modulePreloadUrls = mergeEntryPreloadUrls(
218221
options.options.jsManifest,
219-
options.shellFile,
220-
options.routeArgs.route.file,
222+
CLIENT_ENTRY_MANIFEST_KEY,
223+
resolvePageJsUrls(options.options.jsManifest, options.shellFile, options.routeArgs.route.file),
221224
);
222225
const renderToString = await getRenderToStringAsync();
223226

@@ -291,7 +294,11 @@ export async function renderRouteErrorResponse<TContext>(options: {
291294
body,
292295
clientEntryUrl: islandsEntryUrl,
293296
cssUrls,
294-
modulePreloadUrls: [...islandPreloadUrls],
297+
modulePreloadUrls: islandsEntryUrl
298+
? mergeEntryPreloadUrls(options.options.jsManifest, ISLANDS_ENTRY_MANIFEST_KEY, [
299+
...islandPreloadUrls,
300+
])
301+
: [...islandPreloadUrls],
295302
}),
296303
routeErrorWithDiagnostics.status,
297304
documentHeaders,

packages/framework/src/runtime.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import {
1919
type IslandCapture,
2020
} from "./islands-server.ts";
2121
import {
22+
CLIENT_ENTRY_MANIFEST_KEY,
23+
ISLANDS_ENTRY_MANIFEST_KEY,
24+
mergeEntryPreloadUrls,
2225
resolveManifestEntries,
2326
resolvePageCssUrls,
2427
resolvePageJsUrls,
@@ -456,10 +459,10 @@ export async function handlePrachtRequest<TContext>(
456459
match.route.shellFile,
457460
match.route.file,
458461
);
459-
const modulePreloadUrls = resolvePageJsUrls(
462+
const modulePreloadUrls = mergeEntryPreloadUrls(
460463
options.jsManifest,
461-
match.route.shellFile,
462-
match.route.file,
464+
CLIENT_ENTRY_MANIFEST_KEY,
465+
resolvePageJsUrls(options.jsManifest, match.route.shellFile, match.route.file),
463466
);
464467

465468
if (match.route.render === "spa") {
@@ -600,7 +603,11 @@ export async function handlePrachtRequest<TContext>(
600603
body: ssrContent,
601604
clientEntryUrl: islandsEntryUrl,
602605
cssUrls,
603-
modulePreloadUrls: [...islandPreloadUrls],
606+
modulePreloadUrls: islandsEntryUrl
607+
? mergeEntryPreloadUrls(options.jsManifest, ISLANDS_ENTRY_MANIFEST_KEY, [
608+
...islandPreloadUrls,
609+
])
610+
: [...islandPreloadUrls],
604611
speculationRules: getAppSpeculationRules(resolvedApp),
605612
}),
606613
200,

packages/framework/test/runtime.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,6 +1174,47 @@ describe("prerenderApp", () => {
11741174
expect(page.html).toContain('<link rel="modulepreload" href="/assets/vendor-abc123.js">');
11751175
});
11761176

1177+
it("preloads the client entry's own static import closure without duplicates", async () => {
1178+
const app = defineApp({
1179+
routes: [route("/pricing", "./routes/pricing.tsx", { render: "ssg", shell: "public" })],
1180+
shells: {
1181+
public: "./shells/public.tsx",
1182+
},
1183+
});
1184+
1185+
const [page] = await prerenderApp({
1186+
app,
1187+
clientEntryUrl: "/assets/client-abc123.js",
1188+
jsManifest: {
1189+
"src/routes/pricing.tsx": ["/assets/pricing-abc123.js", "/assets/vendor-abc123.js"],
1190+
"src/shells/public.tsx": ["/assets/public-abc123.js", "/assets/vendor-abc123.js"],
1191+
"virtual:pracht/client": ["/assets/runtime-abc123.js", "/assets/vendor-abc123.js"],
1192+
},
1193+
registry: {
1194+
routeModules: {
1195+
"/src/routes/pricing.tsx": async () => ({
1196+
Component: ({ data }) => h("main", null, (data as { plan: string }).plan),
1197+
loader: async () => ({ plan: "MVP" }),
1198+
}),
1199+
},
1200+
shellModules: {
1201+
"/src/shells/public.tsx": async () => ({
1202+
Shell: ({ children }) => h("section", null, children),
1203+
}),
1204+
},
1205+
},
1206+
});
1207+
1208+
// The chunk the entry statically imports is preloaded even though no
1209+
// route/shell references it — the browser would otherwise discover it
1210+
// only after parsing the entry.
1211+
expect(page.html).toContain('<link rel="modulepreload" href="/assets/runtime-abc123.js">');
1212+
// Shared urls between the entry closure and route/shell chunks stay deduped.
1213+
expect(page.html.match(/href="\/assets\/vendor-abc123\.js"/g)).toHaveLength(1);
1214+
// The entry itself is loaded via <script src>, never preloaded.
1215+
expect(page.html).not.toContain('<link rel="modulepreload" href="/assets/client-abc123.js">');
1216+
});
1217+
11771218
it("preserves document headers on prerendered pages", async () => {
11781219
const app = defineApp({
11791220
routes: [route("/pricing", "./routes/pricing.tsx", { render: "ssg", shell: "public" })],

packages/vite-plugin/src/plugin-assets.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ export function readClientBuildAssets(root = process.cwd()): ClientBuildAssets {
5353
}
5454
}
5555

56+
// Store each entry's own static import closure under its virtual module id
57+
// so the runtime can emit modulepreload links for chunks the browser would
58+
// otherwise only discover after parsing the entry (@pracht/core reads these
59+
// via CLIENT_ENTRY_MANIFEST_KEY / ISLANDS_ENTRY_MANIFEST_KEY). The entry
60+
// file itself is excluded — it is already the page's <script src>.
61+
addEntryDeps(manifest, jsManifest, PRACHT_CLIENT_MODULE_ID, clientEntry);
62+
addEntryDeps(manifest, jsManifest, PRACHT_ISLANDS_CLIENT_MODULE_ID, islandsEntry);
63+
5664
return {
5765
clientEntryUrl: clientEntry ? `/${clientEntry.file}` : null,
5866
islandsEntryUrl: islandsEntry ? `/${islandsEntry.file}` : null,
@@ -61,6 +69,19 @@ export function readClientBuildAssets(root = process.cwd()): ClientBuildAssets {
6169
};
6270
}
6371

72+
function addEntryDeps(
73+
manifest: Record<string, ViteManifestEntry>,
74+
jsManifest: Record<string, string[]>,
75+
entryKey: string,
76+
entry: ViteManifestEntry | undefined,
77+
): void {
78+
if (!entry) return;
79+
const deps = collectTransitiveDeps(manifest, entryKey).js.filter((file) => file !== entry.file);
80+
if (deps.length > 0) {
81+
jsManifest[entryKey] = deps.map((file) => `/${file}`);
82+
}
83+
}
84+
6485
// Walk static imports transitively (not dynamicImports — those belong to
6586
// other shells/routes loaded separately). Returns both CSS and JS deps.
6687
function collectTransitiveDeps(

0 commit comments

Comments
 (0)