@rangojs/router 0.0.0-experimental.10

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.
Files changed (172) hide show
  1. package/CLAUDE.md +43 -0
  2. package/README.md +19 -0
  3. package/dist/bin/rango.js +227 -0
  4. package/dist/vite/index.js +3039 -0
  5. package/package.json +171 -0
  6. package/skills/caching/SKILL.md +191 -0
  7. package/skills/debug-manifest/SKILL.md +108 -0
  8. package/skills/document-cache/SKILL.md +180 -0
  9. package/skills/fonts/SKILL.md +165 -0
  10. package/skills/hooks/SKILL.md +442 -0
  11. package/skills/intercept/SKILL.md +190 -0
  12. package/skills/layout/SKILL.md +213 -0
  13. package/skills/links/SKILL.md +180 -0
  14. package/skills/loader/SKILL.md +246 -0
  15. package/skills/middleware/SKILL.md +202 -0
  16. package/skills/mime-routes/SKILL.md +124 -0
  17. package/skills/parallel/SKILL.md +228 -0
  18. package/skills/prerender/SKILL.md +283 -0
  19. package/skills/rango/SKILL.md +54 -0
  20. package/skills/response-routes/SKILL.md +358 -0
  21. package/skills/route/SKILL.md +173 -0
  22. package/skills/router-setup/SKILL.md +346 -0
  23. package/skills/tailwind/SKILL.md +129 -0
  24. package/skills/theme/SKILL.md +78 -0
  25. package/skills/typesafety/SKILL.md +394 -0
  26. package/src/__internal.ts +175 -0
  27. package/src/bin/rango.ts +24 -0
  28. package/src/browser/event-controller.ts +876 -0
  29. package/src/browser/index.ts +18 -0
  30. package/src/browser/link-interceptor.ts +121 -0
  31. package/src/browser/lru-cache.ts +69 -0
  32. package/src/browser/merge-segment-loaders.ts +126 -0
  33. package/src/browser/navigation-bridge.ts +913 -0
  34. package/src/browser/navigation-client.ts +165 -0
  35. package/src/browser/navigation-store.ts +823 -0
  36. package/src/browser/partial-update.ts +600 -0
  37. package/src/browser/react/Link.tsx +248 -0
  38. package/src/browser/react/NavigationProvider.tsx +346 -0
  39. package/src/browser/react/ScrollRestoration.tsx +94 -0
  40. package/src/browser/react/context.ts +53 -0
  41. package/src/browser/react/index.ts +52 -0
  42. package/src/browser/react/location-state-shared.ts +120 -0
  43. package/src/browser/react/location-state.ts +62 -0
  44. package/src/browser/react/mount-context.ts +32 -0
  45. package/src/browser/react/use-action.ts +240 -0
  46. package/src/browser/react/use-client-cache.ts +56 -0
  47. package/src/browser/react/use-handle.ts +203 -0
  48. package/src/browser/react/use-href.tsx +40 -0
  49. package/src/browser/react/use-link-status.ts +134 -0
  50. package/src/browser/react/use-mount.ts +31 -0
  51. package/src/browser/react/use-navigation.ts +140 -0
  52. package/src/browser/react/use-segments.ts +188 -0
  53. package/src/browser/request-controller.ts +164 -0
  54. package/src/browser/rsc-router.tsx +352 -0
  55. package/src/browser/scroll-restoration.ts +324 -0
  56. package/src/browser/segment-structure-assert.ts +67 -0
  57. package/src/browser/server-action-bridge.ts +762 -0
  58. package/src/browser/shallow.ts +35 -0
  59. package/src/browser/types.ts +478 -0
  60. package/src/build/generate-manifest.ts +377 -0
  61. package/src/build/generate-route-types.ts +828 -0
  62. package/src/build/index.ts +36 -0
  63. package/src/build/route-trie.ts +239 -0
  64. package/src/cache/cache-scope.ts +563 -0
  65. package/src/cache/cf/cf-cache-store.ts +428 -0
  66. package/src/cache/cf/index.ts +19 -0
  67. package/src/cache/document-cache.ts +340 -0
  68. package/src/cache/index.ts +58 -0
  69. package/src/cache/memory-segment-store.ts +150 -0
  70. package/src/cache/memory-store.ts +253 -0
  71. package/src/cache/types.ts +392 -0
  72. package/src/client.rsc.tsx +83 -0
  73. package/src/client.tsx +643 -0
  74. package/src/component-utils.ts +76 -0
  75. package/src/components/DefaultDocument.tsx +23 -0
  76. package/src/debug.ts +233 -0
  77. package/src/default-error-boundary.tsx +88 -0
  78. package/src/deps/browser.ts +8 -0
  79. package/src/deps/html-stream-client.ts +2 -0
  80. package/src/deps/html-stream-server.ts +2 -0
  81. package/src/deps/rsc.ts +10 -0
  82. package/src/deps/ssr.ts +2 -0
  83. package/src/errors.ts +295 -0
  84. package/src/handle.ts +130 -0
  85. package/src/handles/MetaTags.tsx +193 -0
  86. package/src/handles/index.ts +6 -0
  87. package/src/handles/meta.ts +247 -0
  88. package/src/host/cookie-handler.ts +159 -0
  89. package/src/host/errors.ts +97 -0
  90. package/src/host/index.ts +56 -0
  91. package/src/host/pattern-matcher.ts +214 -0
  92. package/src/host/router.ts +330 -0
  93. package/src/host/testing.ts +79 -0
  94. package/src/host/types.ts +138 -0
  95. package/src/host/utils.ts +25 -0
  96. package/src/href-client.ts +202 -0
  97. package/src/href-context.ts +33 -0
  98. package/src/index.rsc.ts +121 -0
  99. package/src/index.ts +165 -0
  100. package/src/loader.rsc.ts +207 -0
  101. package/src/loader.ts +47 -0
  102. package/src/network-error-thrower.tsx +21 -0
  103. package/src/outlet-context.ts +15 -0
  104. package/src/prerender/param-hash.ts +35 -0
  105. package/src/prerender/store.ts +40 -0
  106. package/src/prerender.ts +156 -0
  107. package/src/reverse.ts +267 -0
  108. package/src/root-error-boundary.tsx +277 -0
  109. package/src/route-content-wrapper.tsx +193 -0
  110. package/src/route-definition.ts +1431 -0
  111. package/src/route-map-builder.ts +242 -0
  112. package/src/route-types.ts +220 -0
  113. package/src/router/error-handling.ts +287 -0
  114. package/src/router/handler-context.ts +158 -0
  115. package/src/router/intercept-resolution.ts +387 -0
  116. package/src/router/loader-resolution.ts +327 -0
  117. package/src/router/manifest.ts +216 -0
  118. package/src/router/match-api.ts +621 -0
  119. package/src/router/match-context.ts +264 -0
  120. package/src/router/match-middleware/background-revalidation.ts +236 -0
  121. package/src/router/match-middleware/cache-lookup.ts +382 -0
  122. package/src/router/match-middleware/cache-store.ts +276 -0
  123. package/src/router/match-middleware/index.ts +81 -0
  124. package/src/router/match-middleware/intercept-resolution.ts +281 -0
  125. package/src/router/match-middleware/segment-resolution.ts +184 -0
  126. package/src/router/match-pipelines.ts +214 -0
  127. package/src/router/match-result.ts +213 -0
  128. package/src/router/metrics.ts +62 -0
  129. package/src/router/middleware.ts +791 -0
  130. package/src/router/pattern-matching.ts +407 -0
  131. package/src/router/revalidation.ts +190 -0
  132. package/src/router/router-context.ts +301 -0
  133. package/src/router/segment-resolution.ts +1315 -0
  134. package/src/router/trie-matching.ts +172 -0
  135. package/src/router/types.ts +163 -0
  136. package/src/router.gen.ts +6 -0
  137. package/src/router.ts +2423 -0
  138. package/src/rsc/handler.ts +1443 -0
  139. package/src/rsc/helpers.ts +64 -0
  140. package/src/rsc/index.ts +56 -0
  141. package/src/rsc/nonce.ts +18 -0
  142. package/src/rsc/types.ts +236 -0
  143. package/src/segment-system.tsx +442 -0
  144. package/src/server/context.ts +466 -0
  145. package/src/server/handle-store.ts +229 -0
  146. package/src/server/loader-registry.ts +174 -0
  147. package/src/server/request-context.ts +554 -0
  148. package/src/server/root-layout.tsx +10 -0
  149. package/src/server/tsconfig.json +14 -0
  150. package/src/server.ts +171 -0
  151. package/src/ssr/index.tsx +296 -0
  152. package/src/theme/ThemeProvider.tsx +291 -0
  153. package/src/theme/ThemeScript.tsx +61 -0
  154. package/src/theme/constants.ts +59 -0
  155. package/src/theme/index.ts +58 -0
  156. package/src/theme/theme-context.ts +70 -0
  157. package/src/theme/theme-script.ts +152 -0
  158. package/src/theme/types.ts +182 -0
  159. package/src/theme/use-theme.ts +44 -0
  160. package/src/types.ts +1757 -0
  161. package/src/urls.gen.ts +8 -0
  162. package/src/urls.ts +1282 -0
  163. package/src/use-loader.tsx +346 -0
  164. package/src/vite/expose-action-id.ts +344 -0
  165. package/src/vite/expose-handle-id.ts +209 -0
  166. package/src/vite/expose-loader-id.ts +426 -0
  167. package/src/vite/expose-location-state-id.ts +177 -0
  168. package/src/vite/expose-prerender-handler-id.ts +429 -0
  169. package/src/vite/index.ts +2068 -0
  170. package/src/vite/package-resolution.ts +125 -0
  171. package/src/vite/version.d.ts +12 -0
  172. package/src/vite/virtual-entries.ts +114 -0
@@ -0,0 +1,188 @@
1
+ "use client";
2
+
3
+ import { useContext, useState, useEffect, useRef } from "react";
4
+ import { NavigationStoreContext } from "./context.js";
5
+
6
+ /**
7
+ * Segments state returned by useSegments hook
8
+ */
9
+ export interface SegmentsState {
10
+ /** URL path segments (e.g., /shop/products/123 → ["shop", "products", "123"]) */
11
+ path: readonly string[];
12
+ /** Matched segment IDs in order (layouts and routes only, e.g., ["L0", "L0L1", "L0L1R0"]) */
13
+ segmentIds: readonly string[];
14
+ /** Current URL location */
15
+ location: URL;
16
+ }
17
+
18
+ /**
19
+ * SSR module-level state.
20
+ * Populated by initSegmentsSync before React renders.
21
+ * Used by useState initializer during SSR.
22
+ */
23
+ let ssrSegmentOrder: string[] = [];
24
+ let ssrPathname: string = "/";
25
+
26
+ /**
27
+ * Filter segment IDs to only include routes and layouts.
28
+ * Excludes parallels (contain .@) and loaders (contain D followed by digit).
29
+ */
30
+ function filterSegmentOrder(matched: string[]): string[] {
31
+ return matched.filter((id) => {
32
+ if (id.includes(".@")) return false;
33
+ if (/D\d+\./.test(id)) return false;
34
+ return true;
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Initialize segments data synchronously for SSR.
40
+ * Called before rendering to populate state for useState initializer.
41
+ *
42
+ * @param matched - Segment order from RSC metadata
43
+ * @param pathname - Current pathname
44
+ */
45
+ export function initSegmentsSync(matched?: string[], pathname?: string): void {
46
+ ssrSegmentOrder = filterSegmentOrder(matched ?? []);
47
+ ssrPathname = pathname ?? "/";
48
+ }
49
+
50
+ /**
51
+ * Shallow equality check for selector results
52
+ */
53
+ function shallowEqual<T>(a: T, b: T): boolean {
54
+ if (Object.is(a, b)) return true;
55
+ if (
56
+ typeof a !== "object" ||
57
+ a === null ||
58
+ typeof b !== "object" ||
59
+ b === null
60
+ ) {
61
+ return false;
62
+ }
63
+ const keysA = Object.keys(a);
64
+ const keysB = Object.keys(b);
65
+ if (keysA.length !== keysB.length) return false;
66
+ for (const key of keysA) {
67
+ if (
68
+ !Object.hasOwn(b, key) ||
69
+ !Object.is((a as any)[key], (b as any)[key])
70
+ ) {
71
+ return false;
72
+ }
73
+ }
74
+ return true;
75
+ }
76
+
77
+ /**
78
+ * Parse pathname into path segments
79
+ * /shop/products/123 → ["shop", "products", "123"]
80
+ */
81
+ function parsePathname(pathname: string): string[] {
82
+ return pathname.split("/").filter(Boolean);
83
+ }
84
+
85
+ /**
86
+ * Build segments state from event controller
87
+ */
88
+ function buildSegmentsState(
89
+ location: URL,
90
+ segmentOrder: string[]
91
+ ): SegmentsState {
92
+ return {
93
+ path: parsePathname(location.pathname),
94
+ segmentIds: segmentOrder,
95
+ location,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Build SSR state from module-level variables
101
+ */
102
+ function buildSsrState(): SegmentsState {
103
+ const location = new URL(ssrPathname, "http://localhost");
104
+ return {
105
+ path: parsePathname(ssrPathname),
106
+ segmentIds: ssrSegmentOrder,
107
+ location,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Hook to access current route segments with optional selector for performance
113
+ *
114
+ * Provides information about the current URL path and matched route segments.
115
+ * Uses the event controller for reactive state management.
116
+ *
117
+ * @example
118
+ * ```tsx
119
+ * // Get full segments state
120
+ * const { path, segmentIds, location } = useSegments();
121
+ *
122
+ * // Use selector for specific values (better performance)
123
+ * const path = useSegments(s => s.path);
124
+ * const isShopRoute = useSegments(s => s.path[0] === "shop");
125
+ * ```
126
+ */
127
+ export function useSegments(): SegmentsState;
128
+ export function useSegments<T>(selector: (state: SegmentsState) => T): T;
129
+ export function useSegments<T>(
130
+ selector?: (state: SegmentsState) => T
131
+ ): T | SegmentsState {
132
+ const ctx = useContext(NavigationStoreContext);
133
+
134
+ // Build initial state from SSR module state or event controller
135
+ const [state, setState] = useState<T | SegmentsState>(() => {
136
+ // During SSR or when no context, use module-level SSR state
137
+ if (typeof document === "undefined" || !ctx) {
138
+ const ssrState = buildSsrState();
139
+ return selector ? selector(ssrState) : ssrState;
140
+ }
141
+ // On client with context, use event controller state
142
+ const navState = ctx.eventController.getState();
143
+ const handleState = ctx.eventController.getHandleState();
144
+ const segmentsState = buildSegmentsState(
145
+ navState.location as URL,
146
+ handleState.segmentOrder
147
+ );
148
+ return selector ? selector(segmentsState) : segmentsState;
149
+ });
150
+
151
+ const prevState = useRef(state);
152
+
153
+ // Subscribe to both navigation state and handle state changes
154
+ useEffect(() => {
155
+ if (!ctx) {
156
+ return;
157
+ }
158
+
159
+ const updateState = () => {
160
+ const navState = ctx.eventController.getState();
161
+ const handleState = ctx.eventController.getHandleState();
162
+ const segmentsState = buildSegmentsState(
163
+ navState.location as URL,
164
+ handleState.segmentOrder
165
+ );
166
+ const nextSelected = selector ? selector(segmentsState) : segmentsState;
167
+
168
+ if (!shallowEqual(nextSelected, prevState.current)) {
169
+ prevState.current = nextSelected;
170
+ setState(nextSelected);
171
+ }
172
+ };
173
+
174
+ // Initial update in case SSR state differs from client state
175
+ updateState();
176
+
177
+ // Subscribe to both state sources
178
+ const unsubscribeNav = ctx.eventController.subscribe(updateState);
179
+ const unsubscribeHandles = ctx.eventController.subscribeToHandles(updateState);
180
+
181
+ return () => {
182
+ unsubscribeNav();
183
+ unsubscribeHandles();
184
+ };
185
+ }, [selector]);
186
+
187
+ return state as T | SegmentsState;
188
+ }
@@ -0,0 +1,164 @@
1
+ import type { RequestController, DisposableAbortController } from "./types.js";
2
+
3
+ // Polyfill Symbol.dispose for Safari and older browsers
4
+ if (typeof Symbol.dispose === "undefined") {
5
+ (Symbol as any).dispose = Symbol("Symbol.dispose");
6
+ }
7
+
8
+ /**
9
+ * Create a request controller for managing concurrent abort controllers
10
+ *
11
+ * This utility helps manage concurrent navigation requests by providing
12
+ * a way to abort all pending requests when a new navigation starts.
13
+ *
14
+ * @returns RequestController instance
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * const controller = createRequestController();
19
+ *
20
+ * // Start a new request
21
+ * const abortController = controller.create();
22
+ * fetch(url, { signal: abortController.signal });
23
+ *
24
+ * // Abort all pending requests (e.g., when starting new navigation)
25
+ * controller.abortAll();
26
+ *
27
+ * // Clean up completed request
28
+ * controller.remove(abortController);
29
+ * ```
30
+ */
31
+ export function createRequestController(): RequestController {
32
+ // Navigation controllers - aborted on new navigation
33
+ // Using WeakRef to allow GC if controller is no longer referenced elsewhere
34
+ const controllers: WeakRef<AbortController>[] = [];
35
+ // Action controllers - NOT aborted by navigation, only by errors
36
+ const actionControllers: WeakRef<AbortController>[] = [];
37
+
38
+ /**
39
+ * Remove stale (garbage collected) refs from an array
40
+ */
41
+ function pruneStaleRefs(refs: WeakRef<AbortController>[]): void {
42
+ for (let i = refs.length - 1; i >= 0; i--) {
43
+ if (!refs[i].deref()) {
44
+ refs.splice(i, 1);
45
+ }
46
+ }
47
+ }
48
+
49
+ return {
50
+ /**
51
+ * Create a new abort controller and track it for navigation
52
+ *
53
+ * @returns A new AbortController
54
+ */
55
+ create(): AbortController {
56
+ const controller = new AbortController();
57
+ controllers.push(new WeakRef(controller));
58
+ console.log(
59
+ `[Browser] Created abort controller, total: ${controllers.length}`,
60
+ );
61
+ return controller;
62
+ },
63
+
64
+ /**
65
+ * Create a disposable abort controller for navigation use with `using` keyword
66
+ *
67
+ * The controller will be automatically removed from tracking when
68
+ * it goes out of scope, regardless of how the scope is exited.
69
+ *
70
+ * @returns A DisposableAbortController
71
+ *
72
+ * @example
73
+ * ```typescript
74
+ * async function handleNavigation() {
75
+ * requestController.abortAll();
76
+ * using { controller } = requestController.createDisposable();
77
+ * // ... use controller.signal ...
78
+ * // controller is automatically removed on scope exit
79
+ * }
80
+ * ```
81
+ */
82
+ createDisposable(): DisposableAbortController {
83
+ const controller = this.create();
84
+ return {
85
+ controller,
86
+ [Symbol.dispose]: () => {
87
+ this.remove(controller);
88
+ },
89
+ };
90
+ },
91
+
92
+ /**
93
+ * Create a disposable abort controller for actions
94
+ *
95
+ * Action controllers are NOT aborted by navigation - they complete
96
+ * independently. Only aborted by abortAllActions() on error.
97
+ *
98
+ * @returns A DisposableAbortController
99
+ */
100
+ createActionDisposable(): DisposableAbortController {
101
+ const controller = new AbortController();
102
+ const ref = new WeakRef(controller);
103
+ actionControllers.push(ref);
104
+ console.log(
105
+ `[Browser] Created action controller, total: ${actionControllers.length}`,
106
+ );
107
+ return {
108
+ controller,
109
+ [Symbol.dispose]: () => {
110
+ const index = actionControllers.indexOf(ref);
111
+ if (index !== -1) {
112
+ actionControllers.splice(index, 1);
113
+ console.log(
114
+ `[Browser] Removed action controller, remaining: ${actionControllers.length}`,
115
+ );
116
+ }
117
+ },
118
+ };
119
+ },
120
+
121
+ /**
122
+ * Abort all navigation controllers (NOT actions)
123
+ *
124
+ * Called when starting new navigation. Actions continue
125
+ * to complete in the background.
126
+ */
127
+ abortAll(): void {
128
+ controllers.forEach((ref) => ref.deref()?.abort());
129
+ controllers.length = 0;
130
+ console.log(`[Browser] Aborted all navigation controllers`);
131
+ },
132
+
133
+ /**
134
+ * Abort all action controllers
135
+ *
136
+ * Called when an action error occurs - prevents other actions
137
+ * from completing and overwriting the error UI.
138
+ */
139
+ abortAllActions(): void {
140
+ actionControllers.forEach((ref) => ref.deref()?.abort());
141
+ actionControllers.length = 0;
142
+ console.log(`[Browser] Aborted all action controllers`);
143
+ },
144
+
145
+ /**
146
+ * Remove a specific controller from tracking
147
+ *
148
+ * Call this when a request completes successfully.
149
+ *
150
+ * @param controller - The controller to remove
151
+ */
152
+ remove(controller: AbortController): void {
153
+ // Prune any stale refs while searching
154
+ pruneStaleRefs(controllers);
155
+ const index = controllers.findIndex((ref) => ref.deref() === controller);
156
+ if (index !== -1) {
157
+ controllers.splice(index, 1);
158
+ console.log(
159
+ `[Browser] Removed abort controller, remaining: ${controllers.length}`,
160
+ );
161
+ }
162
+ },
163
+ };
164
+ }
@@ -0,0 +1,352 @@
1
+ import React from "react";
2
+ import {
3
+ renderSegments as baseRenderSegments,
4
+ type RenderSegmentsOptions,
5
+ } from "../segment-system.js";
6
+ import {
7
+ createNavigationStore,
8
+ generateHistoryKey,
9
+ } from "./navigation-store.js";
10
+ import { createEventController } from "./event-controller.js";
11
+ import { createNavigationClient } from "./navigation-client.js";
12
+ import { createServerActionBridge } from "./server-action-bridge.js";
13
+ import { createNavigationBridge } from "./navigation-bridge.js";
14
+ import { NavigationProvider, initHandleDataSync, initSegmentsSync } from "./react/index.js";
15
+ import { initThemeConfigSync } from "../theme/theme-context.js";
16
+ import type {
17
+ RscPayload,
18
+ RscBrowserDependencies,
19
+ ResolvedSegment,
20
+ NavigationStore,
21
+ NavigationBridge,
22
+ } from "./types.js";
23
+ import type { EventController } from "./event-controller.js";
24
+ import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
25
+
26
+ // Vite HMR types are provided by vite/client
27
+
28
+ /**
29
+ * Options for initializing the browser app
30
+ */
31
+ export interface InitBrowserAppOptions {
32
+ /**
33
+ * RSC stream containing the initial payload (from rsc-html-stream/client)
34
+ */
35
+ rscStream: ReadableStream<Uint8Array>;
36
+
37
+ /**
38
+ * RSC browser dependencies from @vitejs/plugin-rsc/browser
39
+ */
40
+ deps: RscBrowserDependencies;
41
+
42
+ /**
43
+ * Optional store configuration
44
+ */
45
+ storeOptions?: {
46
+ /**
47
+ * Maximum number of history entries to cache
48
+ * @default 10
49
+ */
50
+ cacheSize?: number;
51
+ };
52
+
53
+ /**
54
+ * Enable global link interception for SPA navigation.
55
+ * When enabled, clicks on same-origin anchor elements are intercepted
56
+ * and handled via client-side navigation instead of full page loads.
57
+ *
58
+ * Links rendered with the Link component handle their own navigation
59
+ * regardless of this setting.
60
+ *
61
+ * Set to false to disable global interception and rely solely on
62
+ * Link components for SPA navigation.
63
+ *
64
+ * @default true
65
+ */
66
+ linkInterception?: boolean;
67
+
68
+ /**
69
+ * Theme configuration from router.
70
+ * When provided, enables theme support via useTheme hook.
71
+ * Pass router.themeConfig here to enable theme features.
72
+ *
73
+ * @example
74
+ * ```tsx
75
+ * import { router } from "./router.js";
76
+ *
77
+ * await initBrowserApp({
78
+ * rscStream,
79
+ * deps: rscBrowser,
80
+ * themeConfig: router.themeConfig,
81
+ * initialTheme: document.documentElement.className.includes("dark") ? "dark" : "light",
82
+ * });
83
+ * ```
84
+ */
85
+ themeConfig?: ResolvedThemeConfig | null;
86
+
87
+ /**
88
+ * Initial theme from server (typically read from cookie).
89
+ * Only used when themeConfig is provided.
90
+ */
91
+ initialTheme?: Theme;
92
+
93
+ }
94
+
95
+ /**
96
+ * Result from initializing the browser app
97
+ */
98
+ export interface BrowserAppContext {
99
+ store: NavigationStore;
100
+ eventController: EventController;
101
+ bridge: NavigationBridge;
102
+ initialPayload: RscPayload;
103
+ initialTree: React.ReactNode | Promise<React.ReactNode>;
104
+ /** Theme configuration (null if theme not enabled) */
105
+ themeConfig?: ResolvedThemeConfig | null;
106
+ /** Initial theme from server */
107
+ initialTheme?: Theme;
108
+ /** Whether connection warmup is enabled */
109
+ warmupEnabled?: boolean;
110
+ }
111
+
112
+ // Module-level state for the initialized app
113
+ let browserAppContext: BrowserAppContext | null = null;
114
+
115
+ /**
116
+ * Initialize the browser app. Must be called before rendering RSCRouter.
117
+ *
118
+ * This function:
119
+ * - Loads the initial RSC payload from the stream
120
+ * - Creates the navigation store and event controller
121
+ * - Sets up action and navigation bridges
122
+ * - Configures HMR support
123
+ */
124
+ export async function initBrowserApp(
125
+ options: InitBrowserAppOptions
126
+ ): Promise<BrowserAppContext> {
127
+ const { rscStream, deps, storeOptions, linkInterception = true, themeConfig, initialTheme } = options;
128
+
129
+ // Load initial payload from SSR-injected __FLIGHT_DATA__
130
+ const initialPayload =
131
+ await deps.createFromReadableStream<RscPayload>(rscStream);
132
+
133
+ // Extract themeConfig and initialTheme from payload if not explicitly provided
134
+ // This allows virtual entries to work without importing the router
135
+ const effectiveThemeConfig = themeConfig ?? initialPayload.metadata?.themeConfig ?? null;
136
+ const effectiveInitialTheme = initialTheme ?? initialPayload.metadata?.initialTheme;
137
+
138
+ // Get initial segments and compute history key from current URL
139
+ const initialSegments = (initialPayload.metadata?.segments ??
140
+ []) as ResolvedSegment[];
141
+ const initialHistoryKey = generateHistoryKey(window.location.href);
142
+
143
+ // Create navigation store with history-based caching
144
+ const store = createNavigationStore({
145
+ initialLocation: window.location,
146
+ initialSegmentIds: initialSegments.map((s) => s.id),
147
+ initialHistoryKey,
148
+ initialSegments,
149
+ ...(storeOptions?.cacheSize && { cacheSize: storeOptions.cacheSize }),
150
+ });
151
+
152
+ // Create event controller for reactive state management
153
+ const eventController = createEventController({
154
+ initialLocation: new URL(window.location.href),
155
+ });
156
+
157
+ // Initialize segments state BEFORE hydration to avoid mismatch
158
+ initSegmentsSync(initialPayload.metadata?.matched, initialPayload.metadata?.pathname);
159
+
160
+ // Initialize theme config for MetaTags (must match SSR state)
161
+ initThemeConfigSync(effectiveThemeConfig);
162
+
163
+ // Initialize event controller with segment order (even without handles)
164
+ eventController.setHandleData({}, initialPayload.metadata?.matched);
165
+
166
+ // Initialize handle data from initial payload BEFORE hydration
167
+ // This ensures useHandle returns correct data during hydration to avoid mismatch
168
+ // The handles property is an async generator that yields on each push
169
+ if (initialPayload.metadata?.handles) {
170
+ const handlesGenerator = initialPayload.metadata.handles;
171
+ let lastHandleData: Record<string, Record<string, unknown[]>> = {};
172
+ for await (const handleData of handlesGenerator) {
173
+ lastHandleData = handleData;
174
+ }
175
+ // Initialize both event controller AND module-level SSR state for hydration compatibility
176
+ eventController.setHandleData(lastHandleData, initialPayload.metadata?.matched);
177
+ initHandleDataSync(lastHandleData, initialPayload.metadata?.matched);
178
+
179
+ // Update the initial cache entry with the processed handleData
180
+ // The cache entry was created by createNavigationStore but without handleData
181
+ store.updateCacheHandleData(initialHistoryKey, lastHandleData);
182
+ }
183
+
184
+
185
+ // Create composable utilities
186
+ const client = createNavigationClient(deps);
187
+
188
+ // Extract rootLayout and version from metadata for browser-side re-renders
189
+ const rootLayout = initialPayload.metadata?.rootLayout;
190
+ const version = initialPayload.metadata?.version;
191
+
192
+ // Create a bound renderSegments that includes rootLayout
193
+ const renderSegments = (
194
+ segments: ResolvedSegment[],
195
+ options?: RenderSegmentsOptions
196
+ ) => baseRenderSegments(segments, { ...options, rootLayout });
197
+
198
+ // Setup server action bridge
199
+ const actionBridge = createServerActionBridge({
200
+ store,
201
+ eventController,
202
+ client,
203
+ deps,
204
+ onUpdate: (update) => store.emitUpdate(update),
205
+ renderSegments,
206
+ version,
207
+ });
208
+ actionBridge.register();
209
+
210
+ // Setup navigation bridge
211
+ const navigationBridge = createNavigationBridge({
212
+ store,
213
+ eventController,
214
+ client,
215
+ onUpdate: (update) => store.emitUpdate(update),
216
+ renderSegments,
217
+ version,
218
+ });
219
+
220
+ // Optionally enable global link interception
221
+ if (linkInterception) {
222
+ navigationBridge.registerLinkInterception();
223
+ }
224
+
225
+ // Build initial tree with rootLayout
226
+ const initialTree = renderSegments(initialPayload.metadata!.segments);
227
+
228
+ // Setup HMR
229
+ if (import.meta.hot) {
230
+ import.meta.hot.on("rsc:update", async () => {
231
+ console.log("[RSCRouter] HMR: Server update, refetching RSC");
232
+
233
+ const handle = eventController.startNavigation(window.location.href, {
234
+ replace: true,
235
+ });
236
+ const streamingToken = handle.startStreaming();
237
+
238
+ try {
239
+ const { payload, streamComplete } = await client.fetchPartial({
240
+ targetUrl: window.location.href,
241
+ segmentIds: [],
242
+ previousUrl: store.getSegmentState().currentUrl,
243
+ hmr: true,
244
+ });
245
+
246
+ if (payload.metadata?.isPartial) {
247
+ const segments = payload.metadata.segments || [];
248
+ const matched = payload.metadata.matched || [];
249
+
250
+ store.setSegmentIds(matched);
251
+ store.setCurrentUrl(window.location.href);
252
+
253
+ const historyKey = generateHistoryKey(window.location.href);
254
+ store.setHistoryKey(historyKey);
255
+ const currentHandleData = eventController.getHandleState().data;
256
+ store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
257
+
258
+ store.emitUpdate({
259
+ root: renderSegments(segments),
260
+ metadata: payload.metadata,
261
+ });
262
+ }
263
+
264
+ await streamComplete;
265
+ } finally {
266
+ streamingToken.end();
267
+ }
268
+ handle.complete(new URL(window.location.href));
269
+ console.log("[RSCRouter] HMR: RSC stream complete");
270
+ });
271
+ }
272
+
273
+ // Store context for RSCRouter component
274
+ const context: BrowserAppContext = {
275
+ store,
276
+ eventController,
277
+ bridge: navigationBridge,
278
+ initialPayload,
279
+ initialTree,
280
+ themeConfig: effectiveThemeConfig,
281
+ initialTheme: effectiveInitialTheme,
282
+ warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
283
+ };
284
+ browserAppContext = context;
285
+
286
+ return context;
287
+ }
288
+
289
+ /**
290
+ * Get the browser app context. Throws if initBrowserApp hasn't been called.
291
+ */
292
+ export function getBrowserAppContext(): BrowserAppContext {
293
+ if (!browserAppContext) {
294
+ throw new Error(
295
+ "RSCRouter: initBrowserApp() must be called before rendering RSCRouter"
296
+ );
297
+ }
298
+ return browserAppContext;
299
+ }
300
+
301
+ /**
302
+ * Reset the browser app context (for testing)
303
+ */
304
+ export function resetBrowserAppContext(): void {
305
+ browserAppContext = null;
306
+ }
307
+
308
+ /**
309
+ * Props for the RSCRouter component
310
+ */
311
+ export interface RSCRouterProps {}
312
+
313
+ /**
314
+ * RSCRouter component - renders the RSC router with all internal wiring.
315
+ *
316
+ * Must be called after initBrowserApp() has completed.
317
+ *
318
+ * @example
319
+ * ```tsx
320
+ * import { initBrowserApp, RSCRouter } from "rsc-router/browser";
321
+ * import { rscStream } from "rsc-html-stream/client";
322
+ * import * as rscBrowser from "@vitejs/plugin-rsc/browser";
323
+ *
324
+ * async function main() {
325
+ * await initBrowserApp({ rscStream, deps: rscBrowser });
326
+ *
327
+ * hydrateRoot(
328
+ * document,
329
+ * <React.StrictMode>
330
+ * <RSCRouter />
331
+ * </React.StrictMode>
332
+ * );
333
+ * }
334
+ * main();
335
+ * ```
336
+ */
337
+ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
338
+ const { store, eventController, bridge, initialPayload, initialTree, themeConfig, initialTheme, warmupEnabled } =
339
+ getBrowserAppContext();
340
+
341
+ return (
342
+ <NavigationProvider
343
+ store={store}
344
+ eventController={eventController}
345
+ initialPayload={{ ...initialPayload, root: initialTree }}
346
+ bridge={bridge}
347
+ themeConfig={themeConfig}
348
+ initialTheme={initialTheme}
349
+ warmupEnabled={warmupEnabled}
350
+ />
351
+ );
352
+ }