react-bun-ssr 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,17 @@
1
1
  # react-bun-ssr
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/react-bun-ssr)](https://www.npmjs.com/package/react-bun-ssr)
4
+ [![CI](https://github.com/react-formation/react-bun-ssr/actions/workflows/ci.yml/badge.svg)](https://github.com/react-formation/react-bun-ssr/actions/workflows/ci.yml)
5
+
3
6
  `react-bun-ssr` is a Bun-native SSR React framework with file-based routing, loaders, actions, middleware, streaming, soft navigation, and first-class markdown routes.
4
7
 
8
+ > **Stability: Experimental (early alpha).**
9
+ > Expect breaking changes across minor releases while core APIs and ergonomics are still being shaped.
10
+
5
11
  - Documentation: https://react-bun-ssr.dev/docs
6
12
  - API reference: https://react-bun-ssr.dev/docs/api/overview
7
13
  - Blog: https://react-bun-ssr.dev/blog
14
+ - Changelog: [CHANGELOG.md](./CHANGELOG.md)
8
15
  - Repository: https://github.com/react-formation/react-bun-ssr
9
16
 
10
17
  ## Why react-bun-ssr?
@@ -31,6 +38,11 @@ Prerequisites:
31
38
  - Bun `>= 1.3.10`
32
39
  - `rbssr` available on PATH in the workflow you use to start a new app
33
40
 
41
+ Try in browser:
42
+
43
+ - StackBlitz (primary): https://stackblitz.com/github/react-formation/react-bun-ssr/tree/main/examples/sandbox-starter
44
+ - CodeSandbox (fallback): https://codesandbox.io/s/github/react-formation/react-bun-ssr/tree/main/examples/sandbox-starter
45
+
34
46
  Minimal setup:
35
47
 
36
48
  ```bash
@@ -57,9 +69,15 @@ For the full setup walkthrough, read the installation guide:
57
69
  `rbssr init` scaffolds a small Bun-first SSR app:
58
70
 
59
71
  ```text
72
+ package.json
73
+ tsconfig.json
74
+ .gitignore
60
75
  app/
61
76
  root.tsx
77
+ root.module.css
62
78
  middleware.ts
79
+ public/
80
+ favicon.svg
63
81
  routes/
64
82
  index.tsx
65
83
  api/
@@ -67,8 +85,13 @@ app/
67
85
  rbssr.config.ts
68
86
  ```
69
87
 
88
+ - `package.json`: Bun scripts and framework/runtime dependencies
89
+ - `tsconfig.json`: starter TypeScript config for Bun + JSX
90
+ - `.gitignore`: minimal app-level ignore rules
70
91
  - `app/root.tsx`: document shell and top-level layout
92
+ - `app/root.module.css`: starter CSS Module for layout and base presentation
71
93
  - `app/middleware.ts`: global request pipeline hook
94
+ - `app/public/favicon.svg`: starter public asset
72
95
  - `app/routes/index.tsx`: first SSR page route
73
96
  - `app/routes/api/health.ts`: first API route
74
97
  - `rbssr.config.ts`: runtime configuration entrypoint
@@ -141,6 +164,7 @@ This repository contains both the framework and the official docs site built wit
141
164
  ```bash
142
165
  git clone git@github.com:react-formation/react-bun-ssr.git
143
166
  cd react-bun-ssr
167
+ bun link
144
168
  bun install
145
169
  bun run docs:dev
146
170
  ```
@@ -178,20 +202,3 @@ Contributions should keep framework behavior, docs, tests, and generated artifac
178
202
  - The release workflow derives the published package version from the Git tag and rewrites `package.json` in the release job before publishing.
179
203
  - npm publishing uses trusted publishing with GitHub OIDC instead of an `NPM_TOKEN`.
180
204
  - npm package settings must have a trusted publisher configured for `react-formation / react-bun-ssr / release.yml`.
181
-
182
- ## Deploying
183
-
184
- Fly.io deployment support is already documented and used by this project.
185
-
186
- Happy path:
187
-
188
- ```bash
189
- fly auth login
190
- fly deploy
191
- ```
192
-
193
- Full deployment docs:
194
-
195
- - https://react-bun-ssr.dev/docs/deployment/bun-deployment
196
- - https://react-bun-ssr.dev/docs/deployment/configuration
197
- - https://react-bun-ssr.dev/docs/deployment/troubleshooting
@@ -43,6 +43,10 @@ function toAbsoluteAppPath(appDir: string, relativePath: string): string {
43
43
  return path.join(appDir, relativePath);
44
44
  }
45
45
 
46
+ function toNormalizedWatchPath(fileName?: string | Buffer | null): string {
47
+ return typeof fileName === "string" ? normalizeSlashes(fileName) : "";
48
+ }
49
+
46
50
  interface DevHotData {
47
51
  bunServer?: Bun.Server<undefined>;
48
52
  reloadToken?: number;
@@ -88,9 +92,11 @@ export async function runHotDevChild(options: {
88
92
  let nextClientBuildReason: DevReloadReason | null = null;
89
93
  let stopping = false;
90
94
 
91
- const watchers: FSWatcher[] = [];
92
95
  let structuralSyncTimer: ReturnType<typeof setTimeout> | undefined;
93
96
  let structuralSyncQueue: Promise<void> = Promise.resolve();
97
+ let routesWatcher: FSWatcher | null = null;
98
+ let appWatcher: FSWatcher | null = null;
99
+ let configWatcher: FSWatcher | null = null;
94
100
 
95
101
  const publishReload = (reason: DevReloadReason): void => {
96
102
  reloadToken += 1;
@@ -253,11 +259,7 @@ export async function runHotDevChild(options: {
253
259
  process.exit(RBSSR_DEV_RESTART_EXIT_CODE);
254
260
  };
255
261
 
256
- const handleAppEvent = (eventType: string, fileName?: string | Buffer | null): void => {
257
- const relativePath = typeof fileName === "string"
258
- ? normalizeSlashes(fileName)
259
- : "";
260
-
262
+ const handleAppEvent = (eventType: string, relativePath: string): void => {
261
263
  if (!relativePath) {
262
264
  scheduleStructuralSync();
263
265
  return;
@@ -289,12 +291,31 @@ export async function runHotDevChild(options: {
289
291
  });
290
292
  };
291
293
 
292
- const addWatcher = (watcher: FSWatcher | null): void => {
293
- if (watcher) {
294
- watchers.push(watcher);
294
+ const ensureRoutesWatcher = async (): Promise<void> => {
295
+ if (routesWatcher || !(await existsPath(resolved.routesDir))) {
296
+ return;
297
+ }
298
+
299
+ try {
300
+ routesWatcher = watch(resolved.routesDir, { recursive: true }, (eventType, fileName) => {
301
+ const nestedPath = toNormalizedWatchPath(fileName);
302
+ const relativePath = nestedPath ? `routes/${nestedPath}` : "routes";
303
+ handleAppEvent(eventType, relativePath);
304
+ });
305
+ } catch {
306
+ log(`recursive route watching unavailable for ${resolved.routesDir}; route topology updates may require a restart`);
295
307
  }
296
308
  };
297
309
 
310
+ const refreshRoutesWatcher = async (): Promise<void> => {
311
+ if (routesWatcher) {
312
+ routesWatcher.close();
313
+ routesWatcher = null;
314
+ }
315
+
316
+ await ensureRoutesWatcher();
317
+ };
318
+
298
319
  const cleanup = async (options: {
299
320
  preserveServer: boolean;
300
321
  }): Promise<void> => {
@@ -303,9 +324,12 @@ export async function runHotDevChild(options: {
303
324
  structuralSyncTimer = undefined;
304
325
  }
305
326
 
306
- for (const watcher of watchers.splice(0, watchers.length)) {
307
- watcher.close();
308
- }
327
+ routesWatcher?.close();
328
+ routesWatcher = null;
329
+ appWatcher?.close();
330
+ appWatcher = null;
331
+ configWatcher?.close();
332
+ configWatcher = null;
309
333
 
310
334
  if (clientWatch) {
311
335
  await clientWatch.stop();
@@ -318,31 +342,38 @@ export async function runHotDevChild(options: {
318
342
  }
319
343
  };
320
344
 
345
+ await refreshRoutesWatcher();
346
+
321
347
  try {
322
- addWatcher(
323
- watch(resolved.appDir, { recursive: true }, (eventType, fileName) => {
324
- handleAppEvent(eventType, fileName);
325
- }),
326
- );
348
+ appWatcher = watch(resolved.appDir, (eventType, fileName) => {
349
+ const relativePath = toNormalizedWatchPath(fileName);
350
+ if (relativePath === "routes" && eventType === "rename") {
351
+ void refreshRoutesWatcher();
352
+ }
353
+
354
+ handleAppEvent(eventType, relativePath);
355
+ });
327
356
  } catch {
328
- log(`recursive file watching unavailable for ${resolved.appDir}; dev route topology updates may require a restart`);
357
+ log(`top-level app watching unavailable for ${resolved.appDir}; route topology updates may require a restart`);
329
358
  }
330
359
 
331
360
  try {
332
- addWatcher(
333
- watch(options.cwd, (eventType, fileName) => {
334
- if (typeof fileName !== "string" || !isConfigFileName(fileName)) {
335
- return;
336
- }
337
- if (eventType === "rename" || eventType === "change") {
338
- void restartForConfigChange();
339
- }
340
- }),
341
- );
361
+ configWatcher = watch(options.cwd, (eventType, fileName) => {
362
+ const configFileName = toNormalizedWatchPath(fileName);
363
+ if (!configFileName || !isConfigFileName(configFileName)) {
364
+ return;
365
+ }
366
+ if (eventType === "rename" || eventType === "change") {
367
+ void restartForConfigChange();
368
+ }
369
+ });
342
370
  } catch {
343
371
  log(`config file watching unavailable for ${options.cwd}; config changes may require a manual restart`);
344
372
  }
345
373
 
374
+ await enqueueStructuralSync("bootstrap");
375
+ await refreshRoutesWatcher();
376
+
346
377
  if (import.meta.hot) {
347
378
  import.meta.hot.dispose(async (data: DevHotData) => {
348
379
  data.bunServer = bunServer;
@@ -352,8 +383,6 @@ export async function runHotDevChild(options: {
352
383
  });
353
384
  }
354
385
 
355
- await enqueueStructuralSync("bootstrap");
356
-
357
386
  if (hotData.bunServer && bunServer) {
358
387
  publishReload("server-runtime");
359
388
  }
@@ -6,6 +6,23 @@ interface ScaffoldFile {
6
6
  content: string;
7
7
  }
8
8
 
9
+ interface FrameworkPackageManifest {
10
+ version?: string;
11
+ devDependencies?: Record<string, string>;
12
+ }
13
+
14
+ const DEFAULT_FRAMEWORK_VERSION = "0.0.0";
15
+ const DEFAULT_TYPESCRIPT_VERSION = "^5";
16
+ const DEFAULT_BUN_TYPES_VERSION = "latest";
17
+ const DEFAULT_REACT_TYPES_VERSION = "^19";
18
+ const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
19
+ <rect width="96" height="96" rx="20" fill="#111827"/>
20
+ <path d="M25 28h20c13.807 0 25 11.193 25 25S58.807 78 45 78H25V28Zm18 40c8.837 0 16-7.163 16-16s-7.163-16-16-16h-8v32h8Z" fill="#f9fafb"/>
21
+ </svg>
22
+ `;
23
+
24
+ let frameworkPackageManifestPromise: Promise<FrameworkPackageManifest> | null = null;
25
+
9
26
  async function writeIfMissing(filePath: string, content: string, force: boolean): Promise<void> {
10
27
  if (!force && await existsPath(filePath)) {
11
28
  return;
@@ -14,8 +31,115 @@ async function writeIfMissing(filePath: string, content: string, force: boolean)
14
31
  await writeText(filePath, content);
15
32
  }
16
33
 
17
- function templateFiles(cwd: string): ScaffoldFile[] {
34
+ function getFrameworkPackageManifest(): Promise<FrameworkPackageManifest> {
35
+ if (!frameworkPackageManifestPromise) {
36
+ const packageJsonPath = path.resolve(import.meta.dir, "../../package.json");
37
+ frameworkPackageManifestPromise = Bun.file(packageJsonPath).json() as Promise<FrameworkPackageManifest>;
38
+ }
39
+
40
+ return frameworkPackageManifestPromise;
41
+ }
42
+
43
+ function normalizePackageName(cwd: string): string {
44
+ const baseName = path.basename(path.resolve(cwd));
45
+ const normalized = baseName
46
+ .toLowerCase()
47
+ .replace(/[^a-z0-9._-]+/g, "-")
48
+ .replace(/[._-]{2,}/g, "-")
49
+ .replace(/^[._-]+|[._-]+$/g, "");
50
+
51
+ return normalized || "rbssr-app";
52
+ }
53
+
54
+ function createPackageJsonContent(options: {
55
+ cwd: string;
56
+ frameworkVersion: string;
57
+ typescriptVersion: string;
58
+ bunTypesVersion: string;
59
+ reactTypesVersion: string;
60
+ reactDomTypesVersion: string;
61
+ }): string {
62
+ const packageJson = {
63
+ name: normalizePackageName(options.cwd),
64
+ version: "0.0.0",
65
+ private: true,
66
+ type: "module",
67
+ scripts: {
68
+ dev: "rbssr dev",
69
+ build: "rbssr build",
70
+ start: "rbssr start",
71
+ typecheck: "bunx tsc --noEmit",
72
+ },
73
+ dependencies: {
74
+ "react-bun-ssr": options.frameworkVersion,
75
+ react: "^19",
76
+ "react-dom": "^19",
77
+ },
78
+ devDependencies: {
79
+ "@types/react": options.reactTypesVersion,
80
+ "@types/react-dom": options.reactDomTypesVersion,
81
+ "bun-types": options.bunTypesVersion,
82
+ typescript: options.typescriptVersion,
83
+ },
84
+ };
85
+
86
+ return `${JSON.stringify(packageJson, null, 2)}\n`;
87
+ }
88
+
89
+ function createTsconfigContent(): string {
90
+ const tsconfig = {
91
+ compilerOptions: {
92
+ target: "ESNext",
93
+ module: "Preserve",
94
+ moduleResolution: "Bundler",
95
+ jsx: "react-jsx",
96
+ strict: true,
97
+ types: ["bun-types"],
98
+ },
99
+ include: ["app", "rbssr.config.ts"],
100
+ };
101
+
102
+ return `${JSON.stringify(tsconfig, null, 2)}\n`;
103
+ }
104
+
105
+ function createGitignoreContent(): string {
106
+ return `node_modules
107
+ dist
108
+ .rbssr
109
+ .env
110
+ .env.local
111
+ .DS_Store
112
+ `;
113
+ }
114
+
115
+ async function templateFiles(cwd: string): Promise<ScaffoldFile[]> {
116
+ const frameworkPackage = await getFrameworkPackageManifest();
117
+ const frameworkVersion = frameworkPackage.version ?? DEFAULT_FRAMEWORK_VERSION;
118
+ const typescriptVersion = frameworkPackage.devDependencies?.typescript ?? DEFAULT_TYPESCRIPT_VERSION;
119
+ const bunTypesVersion = frameworkPackage.devDependencies?.["bun-types"] ?? DEFAULT_BUN_TYPES_VERSION;
120
+ const reactTypesVersion = frameworkPackage.devDependencies?.["@types/react"] ?? DEFAULT_REACT_TYPES_VERSION;
121
+ const reactDomTypesVersion = frameworkPackage.devDependencies?.["@types/react-dom"] ?? DEFAULT_REACT_TYPES_VERSION;
122
+
18
123
  return [
124
+ {
125
+ filePath: path.join(cwd, "package.json"),
126
+ content: createPackageJsonContent({
127
+ cwd,
128
+ frameworkVersion,
129
+ typescriptVersion,
130
+ bunTypesVersion,
131
+ reactTypesVersion,
132
+ reactDomTypesVersion,
133
+ }),
134
+ },
135
+ {
136
+ filePath: path.join(cwd, "tsconfig.json"),
137
+ content: createTsconfigContent(),
138
+ },
139
+ {
140
+ filePath: path.join(cwd, ".gitignore"),
141
+ content: createGitignoreContent(),
142
+ },
19
143
  {
20
144
  filePath: path.join(cwd, "rbssr.config.ts"),
21
145
  content: `import { defineConfig } from "react-bun-ssr";
@@ -29,14 +153,17 @@ export default defineConfig({
29
153
  {
30
154
  filePath: path.join(cwd, "app/root.tsx"),
31
155
  content: `import { Outlet } from "react-bun-ssr/route";
156
+ import styles from "./root.module.css";
32
157
 
33
158
  export default function RootLayout() {
34
159
  return (
35
- <main className="shell">
36
- <header className="top">
160
+ <main className={styles.shell}>
161
+ <header className={styles.top}>
37
162
  <h1>react-bun-ssr</h1>
38
163
  </header>
39
- <Outlet />
164
+ <section className={styles.content}>
165
+ <Outlet />
166
+ </section>
40
167
  </main>
41
168
  );
42
169
  }
@@ -44,6 +171,53 @@ export default function RootLayout() {
44
171
  export function head() {
45
172
  return <title>react-bun-ssr app</title>;
46
173
  }
174
+ `,
175
+ },
176
+ {
177
+ filePath: path.join(cwd, "app/root.module.css"),
178
+ content: `:global(*) {
179
+ box-sizing: border-box;
180
+ }
181
+
182
+ :global(html) {
183
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
184
+ background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%);
185
+ color: #0f172a;
186
+ }
187
+
188
+ :global(body) {
189
+ margin: 0;
190
+ }
191
+
192
+ .shell {
193
+ min-height: 100vh;
194
+ width: min(100%, 72rem);
195
+ margin: 0 auto;
196
+ padding: 3rem 1.5rem 4rem;
197
+ }
198
+
199
+ .top {
200
+ display: flex;
201
+ align-items: center;
202
+ justify-content: space-between;
203
+ margin-bottom: 2rem;
204
+ }
205
+
206
+ .top h1 {
207
+ margin: 0;
208
+ font-size: clamp(2rem, 5vw, 3.5rem);
209
+ line-height: 1;
210
+ letter-spacing: -0.04em;
211
+ }
212
+
213
+ .content {
214
+ padding: 1.5rem;
215
+ border: 1px solid rgba(15, 23, 42, 0.08);
216
+ border-radius: 1.5rem;
217
+ background: rgba(255, 255, 255, 0.84);
218
+ box-shadow: 0 24px 60px rgba(15, 23, 42, 0.12);
219
+ backdrop-filter: blur(18px);
220
+ }
47
221
  `,
48
222
  },
49
223
  {
@@ -87,11 +261,15 @@ export const middleware: Middleware = async (ctx, next) => {
87
261
  };
88
262
  `,
89
263
  },
264
+ {
265
+ filePath: path.join(cwd, "app/public/favicon.svg"),
266
+ content: FAVICON_SVG,
267
+ },
90
268
  ];
91
269
  }
92
270
 
93
271
  export async function scaffoldApp(cwd: string, options: { force: boolean }): Promise<void> {
94
- for (const file of templateFiles(cwd)) {
272
+ for (const file of await templateFiles(cwd)) {
95
273
  await writeIfMissing(file.filePath, file.content, options.force);
96
274
  }
97
275
  }
@@ -17,11 +17,10 @@ import {
17
17
  type NavigationHistoryMode,
18
18
  } from "./navigation-api";
19
19
  import {
20
- RBSSR_HEAD_MARKER_END_ATTR,
21
- RBSSR_HEAD_MARKER_START_ATTR,
22
20
  RBSSR_PAYLOAD_SCRIPT_ID,
23
21
  RBSSR_ROUTER_SCRIPT_ID,
24
22
  } from "./runtime-constants";
23
+ import { replaceManagedHead } from "./head-reconcile";
25
24
  import {
26
25
  createCatchAppTree,
27
26
  createErrorAppTree,
@@ -569,220 +568,6 @@ function createFallbackNotFoundRoute(rootModule: RouteModule): RouteModule {
569
568
  };
570
569
  }
571
570
 
572
- function nodeSignature(node: Node): string {
573
- if (node.nodeType === Node.TEXT_NODE) {
574
- return `text:${node.textContent ?? ""}`;
575
- }
576
-
577
- if (node.nodeType === Node.COMMENT_NODE) {
578
- return `comment:${node.textContent ?? ""}`;
579
- }
580
-
581
- if (node.nodeType !== Node.ELEMENT_NODE) {
582
- return `node:${node.nodeType}`;
583
- }
584
-
585
- const element = node as Element;
586
- const attrs = Array.from(element.attributes)
587
- .map(attribute => `${attribute.name}=${attribute.value}`)
588
- .sort((a, b) => a.localeCompare(b))
589
- .join("|");
590
-
591
- return `element:${element.tagName.toLowerCase()}:${attrs}:${element.innerHTML}`;
592
- }
593
-
594
- function isIgnorableTextNode(node: Node): boolean {
595
- return node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").trim().length === 0;
596
- }
597
-
598
- function getManagedHeadNodes(startMarker: Element, endMarker: Element): Node[] {
599
- const nodes: Node[] = [];
600
- let cursor = startMarker.nextSibling;
601
- while (cursor && cursor !== endMarker) {
602
- nodes.push(cursor);
603
- cursor = cursor.nextSibling;
604
- }
605
- return nodes;
606
- }
607
-
608
- function removeNode(node: Node): void {
609
- if (node.parentNode) {
610
- node.parentNode.removeChild(node);
611
- }
612
- }
613
-
614
- function isStylesheetLinkNode(node: Node): node is HTMLLinkElement {
615
- if (node.nodeType !== Node.ELEMENT_NODE) {
616
- return false;
617
- }
618
-
619
- const element = node as Element;
620
- return (
621
- element.tagName.toLowerCase() === "link"
622
- && (element.getAttribute("rel")?.toLowerCase() ?? "") === "stylesheet"
623
- && Boolean(element.getAttribute("href"))
624
- );
625
- }
626
-
627
- function toAbsoluteHref(href: string): string {
628
- return new URL(href, document.baseURI).toString();
629
- }
630
-
631
- function waitForStylesheetLoad(link: HTMLLinkElement): Promise<void> {
632
- const sheet = link.sheet;
633
- if (sheet) {
634
- return Promise.resolve();
635
- }
636
-
637
- return new Promise(resolve => {
638
- const finish = () => {
639
- link.removeEventListener("load", finish);
640
- link.removeEventListener("error", finish);
641
- resolve();
642
- };
643
-
644
- link.addEventListener("load", finish, { once: true });
645
- link.addEventListener("error", finish, { once: true });
646
- });
647
- }
648
-
649
- async function reconcileStylesheetLinks(options: {
650
- head: HTMLHeadElement;
651
- desiredStylesheetHrefs: string[];
652
- }): Promise<void> {
653
- const desiredAbsoluteHrefs = options.desiredStylesheetHrefs.map(toAbsoluteHref);
654
- const existingLinks = Array.from(
655
- options.head.querySelectorAll('link[rel="stylesheet"][href]'),
656
- ) as HTMLLinkElement[];
657
-
658
- const existingByAbsoluteHref = new Map<string, HTMLLinkElement[]>();
659
- for (const link of existingLinks) {
660
- const href = link.getAttribute("href");
661
- if (!href) {
662
- continue;
663
- }
664
- const absoluteHref = toAbsoluteHref(href);
665
- const list = existingByAbsoluteHref.get(absoluteHref) ?? [];
666
- list.push(link);
667
- existingByAbsoluteHref.set(absoluteHref, list);
668
- }
669
-
670
- const waitForLoads: Promise<void>[] = [];
671
- for (let index = 0; index < options.desiredStylesheetHrefs.length; index += 1) {
672
- const href = options.desiredStylesheetHrefs[index]!;
673
- const absoluteHref = desiredAbsoluteHrefs[index]!;
674
- const existing = existingByAbsoluteHref.get(absoluteHref)?.[0];
675
- if (existing) {
676
- waitForLoads.push(waitForStylesheetLoad(existing));
677
- continue;
678
- }
679
-
680
- const link = document.createElement("link");
681
- link.setAttribute("rel", "stylesheet");
682
- link.setAttribute("href", href);
683
- options.head.appendChild(link);
684
- waitForLoads.push(waitForStylesheetLoad(link));
685
- }
686
-
687
- const seen = new Set<string>();
688
- for (const link of Array.from(options.head.querySelectorAll('link[rel="stylesheet"][href]'))) {
689
- const href = link.getAttribute("href");
690
- if (!href) {
691
- continue;
692
- }
693
-
694
- const absoluteHref = toAbsoluteHref(href);
695
- if (seen.has(absoluteHref)) {
696
- removeNode(link);
697
- continue;
698
- }
699
-
700
- seen.add(absoluteHref);
701
- }
702
-
703
- await Promise.all(waitForLoads);
704
- }
705
-
706
- async function replaceManagedHead(headHtml: string): Promise<void> {
707
- const head = document.head;
708
- const startMarker = head.querySelector(`meta[${RBSSR_HEAD_MARKER_START_ATTR}]`);
709
- const endMarker = head.querySelector(`meta[${RBSSR_HEAD_MARKER_END_ATTR}]`);
710
-
711
- if (!startMarker || !endMarker || startMarker === endMarker) {
712
- return;
713
- }
714
-
715
- const template = document.createElement("template");
716
- template.innerHTML = headHtml;
717
-
718
- const desiredStylesheetHrefs = Array.from(template.content.querySelectorAll('link[rel="stylesheet"][href]'))
719
- .map(link => link.getAttribute("href"))
720
- .filter((value): value is string => Boolean(value));
721
- for (const styleNode of Array.from(template.content.querySelectorAll('link[rel="stylesheet"][href]'))) {
722
- removeNode(styleNode);
723
- }
724
-
725
- const desiredNodes = Array.from(template.content.childNodes).filter(node => !isIgnorableTextNode(node));
726
- const currentNodes = getManagedHeadNodes(startMarker, endMarker).filter(node => {
727
- if (isIgnorableTextNode(node)) {
728
- return false;
729
- }
730
-
731
- if (isStylesheetLinkNode(node)) {
732
- return false;
733
- }
734
-
735
- return true;
736
- });
737
- const unusedCurrentNodes = new Set(currentNodes);
738
-
739
- let cursor = startMarker.nextSibling;
740
-
741
- for (const desiredNode of desiredNodes) {
742
- while (cursor && cursor !== endMarker && isIgnorableTextNode(cursor)) {
743
- const next = cursor.nextSibling;
744
- removeNode(cursor);
745
- cursor = next;
746
- }
747
-
748
- const desiredSignature = nodeSignature(desiredNode);
749
-
750
- if (cursor && cursor !== endMarker && nodeSignature(cursor) === desiredSignature) {
751
- unusedCurrentNodes.delete(cursor);
752
- cursor = cursor.nextSibling;
753
- continue;
754
- }
755
-
756
- let matchedNode: Node | null = null;
757
- for (const currentNode of currentNodes) {
758
- if (!unusedCurrentNodes.has(currentNode)) {
759
- continue;
760
- }
761
- if (nodeSignature(currentNode) === desiredSignature) {
762
- matchedNode = currentNode;
763
- break;
764
- }
765
- }
766
-
767
- if (matchedNode) {
768
- unusedCurrentNodes.delete(matchedNode);
769
- head.insertBefore(matchedNode, cursor ?? endMarker);
770
- continue;
771
- }
772
-
773
- head.insertBefore(desiredNode.cloneNode(true), cursor ?? endMarker);
774
- }
775
-
776
- for (const leftover of unusedCurrentNodes) {
777
- removeNode(leftover);
778
- }
779
-
780
- await reconcileStylesheetLinks({
781
- head,
782
- desiredStylesheetHrefs,
783
- });
784
- }
785
-
786
571
  async function renderTransitionInitial(
787
572
  chunk: TransitionInitialChunk,
788
573
  toUrl: URL,
@@ -0,0 +1,38 @@
1
+ const DOCTYPE = new TextEncoder().encode("<!doctype html>");
2
+
3
+ export function prependDoctypeStream(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
4
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
5
+
6
+ return new ReadableStream<Uint8Array>({
7
+ async start(controller) {
8
+ controller.enqueue(DOCTYPE);
9
+ reader = stream.getReader();
10
+
11
+ try {
12
+ while (true) {
13
+ const result = await reader.read();
14
+ if (result.done) {
15
+ break;
16
+ }
17
+ controller.enqueue(result.value);
18
+ }
19
+ controller.close();
20
+ } catch (error) {
21
+ controller.error(error);
22
+ } finally {
23
+ const activeReader = reader;
24
+ reader = null;
25
+ activeReader?.releaseLock();
26
+ }
27
+ },
28
+ async cancel(reason) {
29
+ const activeReader = reader;
30
+ if (activeReader) {
31
+ await activeReader.cancel(reason);
32
+ return;
33
+ }
34
+
35
+ await stream.cancel(reason);
36
+ },
37
+ });
38
+ }
@@ -0,0 +1,270 @@
1
+ import {
2
+ RBSSR_HEAD_MARKER_END_ATTR,
3
+ RBSSR_HEAD_MARKER_START_ATTR,
4
+ } from "./runtime-constants";
5
+
6
+ const ELEMENT_NODE = 1;
7
+ const TEXT_NODE = 3;
8
+ const COMMENT_NODE = 8;
9
+
10
+ export function isContentInsensitiveHeadTag(tagName: string): boolean {
11
+ const normalized = tagName.toLowerCase();
12
+ return normalized === "script" || normalized === "style" || normalized === "noscript";
13
+ }
14
+
15
+ function normalizeNodeText(value: string): string {
16
+ return value.replace(/\s+/g, " ").trim();
17
+ }
18
+
19
+ export function nodeSignature(node: Node): string {
20
+ if (node.nodeType === TEXT_NODE) {
21
+ return `text:${node.textContent ?? ""}`;
22
+ }
23
+
24
+ if (node.nodeType === COMMENT_NODE) {
25
+ return `comment:${node.textContent ?? ""}`;
26
+ }
27
+
28
+ if (node.nodeType !== ELEMENT_NODE) {
29
+ return `node:${node.nodeType}`;
30
+ }
31
+
32
+ const element = node as Element;
33
+ const tagName = element.tagName.toLowerCase();
34
+ const attrs = Array.from(element.attributes)
35
+ .map(attribute => `${attribute.name}=${attribute.value}`)
36
+ .sort((a, b) => a.localeCompare(b))
37
+ .join("|");
38
+
39
+ if (isContentInsensitiveHeadTag(tagName)) {
40
+ return `element:${tagName}:${attrs}`;
41
+ }
42
+
43
+ if (tagName === "title") {
44
+ return `element:${tagName}:${attrs}:${normalizeNodeText(element.textContent ?? "")}`;
45
+ }
46
+
47
+ // Keep generic element identity structural and cheap: no innerHTML serialization.
48
+ return `element:${tagName}:${attrs}:${normalizeNodeText(element.textContent ?? "")}:${element.childElementCount}`;
49
+ }
50
+
51
+ function isIgnorableTextNode(node: Node): boolean {
52
+ return node.nodeType === TEXT_NODE && (node.textContent ?? "").trim().length === 0;
53
+ }
54
+
55
+ function getManagedHeadNodes(startMarker: Element, endMarker: Element): Node[] {
56
+ const nodes: Node[] = [];
57
+ let cursor = startMarker.nextSibling;
58
+ while (cursor && cursor !== endMarker) {
59
+ nodes.push(cursor);
60
+ cursor = cursor.nextSibling;
61
+ }
62
+ return nodes;
63
+ }
64
+
65
+ function removeNode(node: Node): void {
66
+ if (node.parentNode) {
67
+ node.parentNode.removeChild(node);
68
+ }
69
+ }
70
+
71
+ function isStylesheetLinkNode(node: Node): node is HTMLLinkElement {
72
+ if (node.nodeType !== ELEMENT_NODE) {
73
+ return false;
74
+ }
75
+
76
+ const element = node as Element;
77
+ return (
78
+ element.tagName.toLowerCase() === "link"
79
+ && (element.getAttribute("rel")?.toLowerCase() ?? "") === "stylesheet"
80
+ && Boolean(element.getAttribute("href"))
81
+ );
82
+ }
83
+
84
+ function toAbsoluteHref(href: string, baseUri: string): string {
85
+ return new URL(href, baseUri).toString();
86
+ }
87
+
88
+ function resolveBaseUri(documentRef: Document): string {
89
+ const candidate = documentRef.baseURI;
90
+ if (typeof candidate === "string" && candidate.length > 0) {
91
+ try {
92
+ const parsed = new URL(candidate);
93
+ if (parsed.protocol !== "about:") {
94
+ return candidate;
95
+ }
96
+ } catch {
97
+ // fall back below
98
+ }
99
+ }
100
+
101
+ return "http://localhost/";
102
+ }
103
+
104
+ function waitForStylesheetLoad(link: HTMLLinkElement): Promise<void> {
105
+ if (!link.ownerDocument.defaultView || link.sheet) {
106
+ return Promise.resolve();
107
+ }
108
+
109
+ return new Promise(resolve => {
110
+ const finish = () => {
111
+ link.removeEventListener("load", finish);
112
+ link.removeEventListener("error", finish);
113
+ resolve();
114
+ };
115
+
116
+ link.addEventListener("load", finish, { once: true });
117
+ link.addEventListener("error", finish, { once: true });
118
+ });
119
+ }
120
+
121
+ async function reconcileStylesheetLinks(options: {
122
+ head: HTMLHeadElement;
123
+ desiredStylesheetHrefs: string[];
124
+ baseUri: string;
125
+ }): Promise<void> {
126
+ const desiredAbsoluteHrefs = options.desiredStylesheetHrefs.map(href => toAbsoluteHref(href, options.baseUri));
127
+ const existingLinks = Array.from(
128
+ options.head.querySelectorAll('link[rel="stylesheet"][href]'),
129
+ ) as HTMLLinkElement[];
130
+
131
+ const existingByAbsoluteHref = new Map<string, HTMLLinkElement[]>();
132
+ for (const link of existingLinks) {
133
+ const href = link.getAttribute("href");
134
+ if (!href) {
135
+ continue;
136
+ }
137
+ const absoluteHref = toAbsoluteHref(href, options.baseUri);
138
+ const list = existingByAbsoluteHref.get(absoluteHref) ?? [];
139
+ list.push(link);
140
+ existingByAbsoluteHref.set(absoluteHref, list);
141
+ }
142
+
143
+ const waitForLoads: Promise<void>[] = [];
144
+ for (let index = 0; index < options.desiredStylesheetHrefs.length; index += 1) {
145
+ const href = options.desiredStylesheetHrefs[index]!;
146
+ const absoluteHref = desiredAbsoluteHrefs[index]!;
147
+ const existing = existingByAbsoluteHref.get(absoluteHref)?.[0];
148
+ if (existing) {
149
+ waitForLoads.push(waitForStylesheetLoad(existing));
150
+ continue;
151
+ }
152
+
153
+ const link = options.head.ownerDocument.createElement("link");
154
+ link.setAttribute("rel", "stylesheet");
155
+ link.setAttribute("href", href);
156
+ options.head.appendChild(link);
157
+ waitForLoads.push(waitForStylesheetLoad(link));
158
+ }
159
+
160
+ const seen = new Set<string>();
161
+ for (const link of Array.from(options.head.querySelectorAll('link[rel="stylesheet"][href]'))) {
162
+ const href = link.getAttribute("href");
163
+ if (!href) {
164
+ continue;
165
+ }
166
+
167
+ const absoluteHref = toAbsoluteHref(href, options.baseUri);
168
+ if (seen.has(absoluteHref)) {
169
+ removeNode(link);
170
+ continue;
171
+ }
172
+
173
+ seen.add(absoluteHref);
174
+ }
175
+
176
+ await Promise.all(waitForLoads);
177
+ }
178
+
179
+ export async function replaceManagedHead(
180
+ headHtml: string,
181
+ options: {
182
+ documentRef?: Document;
183
+ startMarkerAttr?: string;
184
+ endMarkerAttr?: string;
185
+ } = {},
186
+ ): Promise<void> {
187
+ const documentRef = options.documentRef ?? document;
188
+ const startMarkerAttr = options.startMarkerAttr ?? RBSSR_HEAD_MARKER_START_ATTR;
189
+ const endMarkerAttr = options.endMarkerAttr ?? RBSSR_HEAD_MARKER_END_ATTR;
190
+
191
+ const head = documentRef.head;
192
+ const startMarker = head.querySelector(`meta[${startMarkerAttr}]`);
193
+ const endMarker = head.querySelector(`meta[${endMarkerAttr}]`);
194
+
195
+ if (!startMarker || !endMarker || startMarker === endMarker) {
196
+ return;
197
+ }
198
+
199
+ const template = documentRef.createElement("template");
200
+ template.innerHTML = headHtml;
201
+
202
+ const desiredStylesheetHrefs = Array.from(template.content.querySelectorAll('link[rel="stylesheet"][href]'))
203
+ .map(link => link.getAttribute("href"))
204
+ .filter((value): value is string => Boolean(value));
205
+ for (const styleNode of Array.from(template.content.querySelectorAll('link[rel="stylesheet"][href]'))) {
206
+ removeNode(styleNode);
207
+ }
208
+
209
+ const desiredNodes = Array.from(template.content.childNodes).filter(node => !isIgnorableTextNode(node));
210
+ const currentNodes = getManagedHeadNodes(startMarker, endMarker).filter(node => {
211
+ if (isIgnorableTextNode(node)) {
212
+ return false;
213
+ }
214
+
215
+ if (isStylesheetLinkNode(node)) {
216
+ return false;
217
+ }
218
+
219
+ return true;
220
+ });
221
+ const unusedCurrentNodes = new Set(currentNodes);
222
+
223
+ let cursor = startMarker.nextSibling;
224
+
225
+ // Structural identity only: avoid subtree HTML serialization in signature checks.
226
+ for (const desiredNode of desiredNodes) {
227
+ while (cursor && cursor !== endMarker && isIgnorableTextNode(cursor)) {
228
+ const next = cursor.nextSibling;
229
+ removeNode(cursor);
230
+ cursor = next;
231
+ }
232
+
233
+ const desiredSignature = nodeSignature(desiredNode);
234
+
235
+ if (cursor && cursor !== endMarker && nodeSignature(cursor) === desiredSignature) {
236
+ unusedCurrentNodes.delete(cursor);
237
+ cursor = cursor.nextSibling;
238
+ continue;
239
+ }
240
+
241
+ let matchedNode: Node | null = null;
242
+ for (const currentNode of currentNodes) {
243
+ if (!unusedCurrentNodes.has(currentNode)) {
244
+ continue;
245
+ }
246
+ if (nodeSignature(currentNode) === desiredSignature) {
247
+ matchedNode = currentNode;
248
+ break;
249
+ }
250
+ }
251
+
252
+ if (matchedNode) {
253
+ unusedCurrentNodes.delete(matchedNode);
254
+ head.insertBefore(matchedNode, cursor ?? endMarker);
255
+ continue;
256
+ }
257
+
258
+ head.insertBefore(desiredNode.cloneNode(true), cursor ?? endMarker);
259
+ }
260
+
261
+ for (const leftover of unusedCurrentNodes) {
262
+ removeNode(leftover);
263
+ }
264
+
265
+ await reconcileStylesheetLinks({
266
+ head,
267
+ desiredStylesheetHrefs,
268
+ baseUri: resolveBaseUri(documentRef),
269
+ });
270
+ }
@@ -80,7 +80,7 @@ export function sha256Short(input: HashInput): string {
80
80
  }
81
81
 
82
82
  export async function ensureDir(dirPath: string): Promise<void> {
83
- runPosix(["mkdir", "-p", dirPath], `Failed to create directory: ${dirPath}`);
83
+ await runPosix(["mkdir", "-p", dirPath], `Failed to create directory: ${dirPath}`);
84
84
  }
85
85
 
86
86
  export async function ensureCleanDir(dirPath: string): Promise<void> {
@@ -89,7 +89,7 @@ export async function ensureCleanDir(dirPath: string): Promise<void> {
89
89
  }
90
90
 
91
91
  export async function removePath(targetPath: string): Promise<void> {
92
- runPosix(["rm", "-rf", targetPath], `Failed to remove path: ${targetPath}`);
92
+ await runPosix(["rm", "-rf", targetPath], `Failed to remove path: ${targetPath}`);
93
93
  }
94
94
 
95
95
  export async function listEntries(dirPath: string): Promise<FileEntry[]> {
@@ -127,20 +127,23 @@ export async function makeTempDir(prefix: string): Promise<string> {
127
127
  return dirPath;
128
128
  }
129
129
 
130
- function runPosix(cmd: string[], context: string): void {
131
- const result = Bun.spawnSync({
130
+ async function runPosix(cmd: string[], context: string): Promise<void> {
131
+ const result = Bun.spawn({
132
132
  cmd,
133
133
  stdout: "pipe",
134
134
  stderr: "pipe",
135
135
  });
136
136
 
137
- if (result.exitCode === 0) {
137
+ const exitCode = await result.exited;
138
+ if (exitCode === 0) {
138
139
  return;
139
140
  }
140
141
 
141
- const decoder = new TextDecoder();
142
- const stderr = result.stderr.length > 0 ? decoder.decode(result.stderr).trim() : "";
143
- const stdout = result.stdout.length > 0 ? decoder.decode(result.stdout).trim() : "";
144
- const details = stderr || stdout || `exit code ${result.exitCode}`;
142
+ const [stderr, stdout] = await Promise.all([
143
+ result.stderr ? new Response(result.stderr).text() : Promise.resolve(""),
144
+ result.stdout ? new Response(result.stdout).text() : Promise.resolve(""),
145
+ ]);
146
+
147
+ const details = stderr.trim() || stdout.trim() || `exit code ${exitCode}`;
145
148
  throw new Error(`[io] ${context} (${cmd.join(" ")}): ${details}`);
146
149
  }
@@ -6,8 +6,10 @@ import type {
6
6
  RouteSegment,
7
7
  } from "./types";
8
8
 
9
- // Bun FileSystemRouter is the runtime matcher used by the server.
10
- // This matcher is retained for lightweight unit coverage and internal utilities.
9
+ // Bun FileSystemRouter is the runtime matcher used by the server for projected routes.
10
+ // This matcher is used by server fallbacks and client transition matching.
11
+ // It intentionally does first-match linear scanning and expects routes to be pre-ordered
12
+ // by specificity (higher score first, then longer segment length, then routePath).
11
13
  function normalizePathname(pathname: string): string[] {
12
14
  if (!pathname || pathname === "/") {
13
15
  return [];
@@ -10,6 +10,7 @@ import {
10
10
  } from "react";
11
11
  import { renderToReadableStream, renderToStaticMarkup, renderToString } from "react-dom/server";
12
12
  import type { DeferredSettleEntry } from "./deferred";
13
+ import { prependDoctypeStream } from "./doctype-stream";
13
14
  import type {
14
15
  ClientRouterSnapshot,
15
16
  HydrationDocumentAssets,
@@ -361,36 +362,6 @@ function HtmlDocument(options: {
361
362
  );
362
363
  }
363
364
 
364
- function prependDoctype(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
365
- const encoder = new TextEncoder();
366
- const doctype = encoder.encode("<!doctype html>");
367
-
368
- return new ReadableStream<Uint8Array>({
369
- async start(controller) {
370
- controller.enqueue(doctype);
371
- const reader = stream.getReader();
372
-
373
- try {
374
- while (true) {
375
- const result = await reader.read();
376
- if (result.done) {
377
- break;
378
- }
379
- controller.enqueue(result.value);
380
- }
381
- controller.close();
382
- } catch (error) {
383
- controller.error(error);
384
- } finally {
385
- reader.releaseLock();
386
- }
387
- },
388
- cancel(reason) {
389
- void stream.cancel(reason);
390
- },
391
- });
392
- }
393
-
394
365
  export async function renderDocumentStream(options: {
395
366
  appTree: ReactElement;
396
367
  payload: RenderPayload;
@@ -410,7 +381,7 @@ export async function renderDocumentStream(options: {
410
381
  />,
411
382
  );
412
383
 
413
- return prependDoctype(stream);
384
+ return prependDoctypeStream(stream);
414
385
  }
415
386
 
416
387
  export function renderDocument(options: {
@@ -0,0 +1,23 @@
1
+ import type { RouteSegment } from "./types";
2
+
3
+ export interface RouteSpecificityComparable {
4
+ score: number;
5
+ segments: RouteSegment[];
6
+ routePath: string;
7
+ }
8
+
9
+ export function sortRoutesBySpecificity<T extends RouteSpecificityComparable>(
10
+ routes: T[],
11
+ ): T[] {
12
+ return routes.sort((a, b) => {
13
+ if (a.score !== b.score) {
14
+ return b.score - a.score;
15
+ }
16
+
17
+ if (a.segments.length !== b.segments.length) {
18
+ return b.segments.length - a.segments.length;
19
+ }
20
+
21
+ return a.routePath.localeCompare(b.routePath);
22
+ });
23
+ }
@@ -14,6 +14,7 @@ import {
14
14
  toRouteId,
15
15
  trimFileExtension,
16
16
  } from "./utils";
17
+ import { sortRoutesBySpecificity } from "./route-order";
17
18
 
18
19
  const PAGE_FILE_RE = /\.(tsx|jsx|ts|js|md|mdx)$/;
19
20
  const LAYOUT_FILE_RE = /\.(tsx|jsx|ts|js)$/;
@@ -103,22 +104,6 @@ function getAncestorDirs(relativeDir: string): string[] {
103
104
  return result;
104
105
  }
105
106
 
106
- function sortRoutes<T extends { score: number; segments: RouteSegment[]; routePath: string }>(
107
- routes: T[],
108
- ): T[] {
109
- return routes.sort((a, b) => {
110
- if (a.score !== b.score) {
111
- return b.score - a.score;
112
- }
113
-
114
- if (a.segments.length !== b.segments.length) {
115
- return b.segments.length - a.segments.length;
116
- }
117
-
118
- return a.routePath.localeCompare(b.routePath);
119
- });
120
- }
121
-
122
107
  export async function scanRoutes(
123
108
  routesDir: string,
124
109
  options: {
@@ -236,7 +221,7 @@ export async function scanRoutes(
236
221
  const pageRoutes = await Promise.all(pageRouteTasks);
237
222
 
238
223
  return {
239
- pages: sortRoutes(pageRoutes),
240
- api: sortRoutes(apiRoutes),
224
+ pages: sortRoutesBySpecificity(pageRoutes),
225
+ api: sortRoutesBySpecificity(apiRoutes),
241
226
  };
242
227
  }
@@ -17,6 +17,33 @@ export interface RouterNavigateInfo {
17
17
 
18
18
  export type RouterNavigateListener = (nextUrl: URL) => void;
19
19
 
20
+ export interface RouterNavigateListenerStore {
21
+ clearRenderListeners(): void;
22
+ registerRenderListener(listener: RouterNavigateListener): void;
23
+ promoteRenderListenersToActive(): void;
24
+ getActiveListeners(): readonly RouterNavigateListener[];
25
+ }
26
+
27
+ export function createRouterNavigateListenerStore(): RouterNavigateListenerStore {
28
+ let renderListeners: RouterNavigateListener[] = [];
29
+ let activeListeners: RouterNavigateListener[] = [];
30
+
31
+ return {
32
+ clearRenderListeners() {
33
+ renderListeners = [];
34
+ },
35
+ registerRenderListener(listener) {
36
+ renderListeners.push(listener);
37
+ },
38
+ promoteRenderListenersToActive() {
39
+ activeListeners = renderListeners;
40
+ },
41
+ getActiveListeners() {
42
+ return activeListeners;
43
+ },
44
+ };
45
+ }
46
+
20
47
  export function notifyRouterNavigateListeners(
21
48
  listeners: readonly RouterNavigateListener[],
22
49
  nextUrl: URL,
@@ -102,14 +129,22 @@ function createClientRouter(onNavigate: Router["onNavigate"]): Router {
102
129
  }
103
130
 
104
131
  export function useRouter(): Router {
105
- const navigateListenersRef = useRef<RouterNavigateListener[]>([]);
132
+ const navigateListenerStoreRef = useRef<RouterNavigateListenerStore | null>(null);
133
+ if (navigateListenerStoreRef.current === null) {
134
+ navigateListenerStoreRef.current = createRouterNavigateListenerStore();
135
+ }
136
+ const navigateListenerStore = navigateListenerStoreRef.current;
106
137
  const didEmitInitialNavigationRef = useRef(false);
107
- navigateListenersRef.current = [];
138
+ navigateListenerStore.clearRenderListeners();
108
139
 
109
140
  const onNavigate = useCallback<Router["onNavigate"]>((listener) => {
110
- navigateListenersRef.current.push(listener);
141
+ navigateListenerStoreRef.current?.registerRenderListener(listener);
111
142
  }, []);
112
143
 
144
+ useEffect(() => {
145
+ navigateListenerStoreRef.current?.promoteRenderListenersToActive();
146
+ });
147
+
113
148
  useEffect(() => {
114
149
  if (typeof window === "undefined") {
115
150
  return;
@@ -118,7 +153,7 @@ export function useRouter(): Router {
118
153
  if (!didEmitInitialNavigationRef.current) {
119
154
  didEmitInitialNavigationRef.current = true;
120
155
  notifyRouterNavigateListeners(
121
- navigateListenersRef.current,
156
+ navigateListenerStore.getActiveListeners(),
122
157
  new URL(window.location.href),
123
158
  );
124
159
  }
@@ -133,7 +168,10 @@ export function useRouter(): Router {
133
168
  }
134
169
 
135
170
  unsubscribe = runtime.subscribeToNavigation((info) => {
136
- notifyRouterNavigateListeners(navigateListenersRef.current, info.nextUrl);
171
+ notifyRouterNavigateListeners(
172
+ navigateListenerStoreRef.current?.getActiveListeners() ?? [],
173
+ info.nextUrl,
174
+ );
137
175
  });
138
176
  })
139
177
  .catch(() => undefined);
@@ -68,6 +68,7 @@ import {
68
68
  sanitizeErrorMessage,
69
69
  stableHash,
70
70
  } from "./utils";
71
+ import { sortRoutesBySpecificity } from "./route-order";
71
72
 
72
73
  type ResponseKind = "static" | "html" | "api" | "internal-dev" | "internal-transition";
73
74
 
@@ -374,8 +375,8 @@ function resolveAllRouteAssets(options: {
374
375
  return options.runtimeOptions.buildManifest?.routes ?? {};
375
376
  }
376
377
 
377
- function toClientRouteSnapshots(routes: PageRouteDefinition[]): ClientRouteSnapshot[] {
378
- return routes.map(route => ({
378
+ export function toClientRouteSnapshots(routes: PageRouteDefinition[]): ClientRouteSnapshot[] {
379
+ return sortRoutesBySpecificity([...routes]).map(route => ({
379
380
  id: route.id,
380
381
  routePath: route.routePath,
381
382
  segments: route.segments,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-bun-ssr",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -12,7 +12,23 @@
12
12
  "type": "git",
13
13
  "url": "git+https://github.com/react-formation/react-bun-ssr.git"
14
14
  },
15
- "homepage": "https://github.com/react-formation/react-bun-ssr",
15
+ "description": "Bun-native SSR React framework with file-based routing, loaders, actions, streaming SSR. No Node adapter layer.",
16
+ "keywords": [
17
+ "bun",
18
+ "ssr",
19
+ "react",
20
+ "framework",
21
+ "file-based-routing",
22
+ "streaming",
23
+ "hydration",
24
+ "loaders",
25
+ "actions",
26
+ "markdown",
27
+ "rbssr",
28
+ "server-rendering",
29
+ "bun-native"
30
+ ],
31
+ "homepage": "https://react-bun-ssr.dev",
16
32
  "bugs": {
17
33
  "url": "https://github.com/react-formation/react-bun-ssr/issues"
18
34
  },
@@ -27,6 +43,7 @@
27
43
  "framework",
28
44
  "README.md"
29
45
  ],
46
+ "license": "MIT",
30
47
  "bin": {
31
48
  "rbssr": "bin/rbssr.ts"
32
49
  },
@@ -67,6 +84,7 @@
67
84
  "react-dom": "^19"
68
85
  },
69
86
  "devDependencies": {
87
+ "@happy-dom/global-registrator": "^20.8.3",
70
88
  "@playwright/test": "^1.54.2",
71
89
  "@types/react": "^19",
72
90
  "@types/react-dom": "^19",