Skip to content

Commit 8721d05

Browse files
committed
preload modules
1 parent 9d17397 commit 8721d05

File tree

7 files changed

+136
-16
lines changed

7 files changed

+136
-16
lines changed

build.ts

+11-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Glob, fileURLToPath, pathToFileURL } from "bun";
1+
import { Glob, Transpiler, fileURLToPath, pathToFileURL } from "bun";
22
import { basename, join, relative } from "node:path";
33

44
function escapeRegExp(string: string) {
@@ -98,16 +98,24 @@ export async function build({
9898
],
9999
});
100100
if (result.success) {
101+
const transpiler = new Transpiler({ loader: "js" });
101102
const hashed: Record<string, string> = {};
103+
const dependencies: Record<string, string[]> = {};
102104
for (const output of result.outputs) {
105+
const path = relative(outdir, output.path);
103106
if (output.kind === "entry-point" && output.hash) {
104-
const path = relative(outdir, output.path);
105107
hashed[`/${path}`] = output.hash;
106108
}
109+
if (output.kind === "entry-point" || output.kind === "chunk") {
110+
const imports = transpiler.scanImports(await output.text());
111+
dependencies[`/${path}`] = imports
112+
.filter((x) => x.kind === "import-statement")
113+
.map((x) => "/" + join(path, "..", x.path));
114+
}
107115
}
108116
Bun.write(
109117
join(outdir, ".meta.json"),
110-
JSON.stringify({ version: 1, hashed })
118+
JSON.stringify({ version: 2, hashed, dependencies })
111119
);
112120
}
113121
return result;

bun.lockb

-42 Bytes
Binary file not shown.

example/tsconfig.json

+26
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,31 @@
11
{
22
"compilerOptions": {
3+
// Enable latest features
4+
"lib": ["ESNext", "DOM"],
5+
"target": "ESNext",
6+
"module": "ESNext",
7+
"moduleDetection": "force",
8+
"jsx": "react-jsx",
9+
"allowJs": true,
10+
11+
"types": ["react/canary", "react-dom/canary"],
12+
13+
// Bundler mode
14+
"moduleResolution": "bundler",
15+
"allowImportingTsExtensions": true,
16+
"verbatimModuleSyntax": true,
17+
"noEmit": true,
18+
19+
// Best practices
20+
"strict": true,
21+
"skipLibCheck": true,
22+
"noFallthroughCasesInSwitch": true,
23+
24+
// Some stricter flags (disabled by default)
25+
"noUnusedLocals": false,
26+
"noUnusedParameters": false,
27+
"noPropertyAccessFromIndexSignature": false,
28+
329
"paths": {
430
"bun-react-ssr": [".."],
531
"bun-react-ssr/*": ["../*"]

index.tsx

+52-11
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@ import { FileSystemRouter } from "bun";
22
import { NJSON } from "next-json";
33
import { statSync } from "node:fs";
44
import { join, relative } from "node:path";
5+
import { preloadModule } from "react-dom";
56
import { renderToReadableStream } from "react-dom/server";
67
import { ClientOnlyError } from "./client";
8+
import { MetaContext, PreloadModule } from "./preload";
79

810
export class StaticRouters {
911
readonly server: FileSystemRouter;
1012
readonly client: FileSystemRouter;
13+
readonly #routes: Map<string, string>;
1114
readonly #routes_dump: string;
15+
readonly #dependencies: Record<string, string[]>;
1216
readonly #hashed: Record<string, string>;
1317

1418
constructor(
@@ -24,17 +28,19 @@ export class StaticRouters {
2428
dir: join(baseDir, buildDir, pageDir),
2529
style: "nextjs",
2630
});
27-
this.#hashed = require(join(baseDir, buildDir, ".meta.json")).hashed;
28-
this.#routes_dump = NJSON.stringify(
29-
Object.fromEntries(
30-
Object.entries(this.client.routes).map(([path, filePath]) => {
31-
let target = "/" + relative(join(baseDir, buildDir), filePath);
32-
if (this.#hashed[target]) target += `?${this.#hashed[target]}`;
33-
return [path, target];
34-
})
35-
),
36-
{ omitStack: true }
31+
const parsed = require(join(baseDir, buildDir, ".meta.json"));
32+
this.#hashed = parsed.hashed;
33+
this.#dependencies = parsed.dependencies;
34+
this.#routes = new Map(
35+
Object.entries(this.client.routes).map(([path, filePath]) => {
36+
let target = "/" + relative(join(baseDir, buildDir), filePath);
37+
if (this.#hashed[target]) target += `?${this.#hashed[target]}`;
38+
return [path, target];
39+
})
3740
);
41+
this.#routes_dump = NJSON.stringify(Object.fromEntries(this.#routes), {
42+
omitStack: true,
43+
});
3844
}
3945

4046
async serve<T = void>(
@@ -101,7 +107,14 @@ export class StaticRouters {
101107
}
102108
const stream = await renderToReadableStream(
103109
<Shell route={serverSide.pathname + search} {...staticProps} {...result}>
104-
<module.default {...result?.props} />
110+
<MetaContext.Provider
111+
value={{ hash: this.#hashed, dependencies: this.#dependencies }}
112+
>
113+
<PreloadModule
114+
module={this.#routes.get(serverSide.pathname)!.split("?")[0]}
115+
/>
116+
<module.default {...result?.props} />
117+
</MetaContext.Provider>
105118
</Shell>,
106119
{
107120
signal: request.signal,
@@ -140,6 +153,34 @@ export class StaticRouters {
140153
}
141154
}
142155

156+
function DirectPreloadModule({
157+
target,
158+
dependencies,
159+
}: {
160+
target: string;
161+
dependencies: Record<string, string[]>;
162+
}) {
163+
preloadModule(target, { as: "script" });
164+
preloadModule(target, { as: "script" });
165+
for (const dep of walkDependencies(target, dependencies)) {
166+
preloadModule(dep, { as: "script" });
167+
preloadModule(dep, { as: "script" });
168+
}
169+
return null;
170+
}
171+
172+
function* walkDependencies(
173+
target: string,
174+
dependencies: Record<string, string[]>
175+
): Generator<string> {
176+
if (dependencies[target]) {
177+
for (const dep of dependencies[target]) {
178+
yield dep;
179+
yield* walkDependencies(dep, dependencies);
180+
}
181+
}
182+
}
183+
143184
export async function serveFromDir(config: {
144185
directory: string;
145186
path: string;

package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
},
2323
"peerDependencies": {
2424
"typescript": "^5.2.2",
25-
"react": "^19.0.0-rc-cc1ec60d0d-20240607",
26-
"react-dom": "^19.0.0-rc-cc1ec60d0d-20240607"
25+
"react": "19.0.0-rc-1eaccd82-20240816",
26+
"react-dom": "19.0.0-rc-1eaccd82-20240816"
2727
},
2828
"dependencies": {
2929
"next-json": "^0.2.3"

preload.tsx

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { createContext, use } from "react";
2+
import { preloadModule, type PreloadModuleOptions } from "react-dom";
3+
4+
// @ignore
5+
export const MetaContext = createContext<{
6+
hash: Record<string, string>;
7+
dependencies: Record<string, string[]>;
8+
}>({ hash: {}, dependencies: {} });
9+
10+
function* walkDependencies(
11+
target: string,
12+
dependencies: Record<string, string[]>
13+
): Generator<string> {
14+
if (dependencies[target]) {
15+
for (const dep of dependencies[target]) {
16+
yield dep;
17+
yield* walkDependencies(dep, dependencies);
18+
}
19+
}
20+
}
21+
22+
function generateHashedName(name: string, hash: Record<string, string>) {
23+
return hash[name] ? `${name}?${hash[name]}` : name;
24+
}
25+
26+
export function PreloadModule({
27+
module,
28+
...options
29+
}: { module: string } & Partial<PreloadModuleOptions>) {
30+
if (typeof window === "undefined") {
31+
try {
32+
const meta = use(MetaContext);
33+
preloadModule(generateHashedName(module, meta.hash), {
34+
as: "script",
35+
...options,
36+
});
37+
for (const dep of walkDependencies(module, meta.dependencies)) {
38+
preloadModule(dep, { as: "script", ...options });
39+
}
40+
} catch {}
41+
}
42+
return null;
43+
}

tsconfig.json

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
"jsx": "react-jsx",
99
"allowJs": true,
1010

11+
"types": ["react/canary", "react-dom/canary"],
12+
1113
// Bundler mode
1214
"moduleResolution": "bundler",
1315
"allowImportingTsExtensions": true,

0 commit comments

Comments
 (0)