@ivogt/rsc-router 0.0.0-experimental.1

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 (123) hide show
  1. package/README.md +19 -0
  2. package/package.json +131 -0
  3. package/src/__mocks__/version.ts +6 -0
  4. package/src/__tests__/route-definition.test.ts +63 -0
  5. package/src/browser/event-controller.ts +876 -0
  6. package/src/browser/index.ts +18 -0
  7. package/src/browser/link-interceptor.ts +121 -0
  8. package/src/browser/lru-cache.ts +69 -0
  9. package/src/browser/merge-segment-loaders.ts +126 -0
  10. package/src/browser/navigation-bridge.ts +891 -0
  11. package/src/browser/navigation-client.ts +155 -0
  12. package/src/browser/navigation-store.ts +823 -0
  13. package/src/browser/partial-update.ts +545 -0
  14. package/src/browser/react/Link.tsx +248 -0
  15. package/src/browser/react/NavigationProvider.tsx +228 -0
  16. package/src/browser/react/ScrollRestoration.tsx +94 -0
  17. package/src/browser/react/context.ts +53 -0
  18. package/src/browser/react/index.ts +52 -0
  19. package/src/browser/react/location-state-shared.ts +120 -0
  20. package/src/browser/react/location-state.ts +62 -0
  21. package/src/browser/react/use-action.ts +240 -0
  22. package/src/browser/react/use-client-cache.ts +56 -0
  23. package/src/browser/react/use-handle.ts +178 -0
  24. package/src/browser/react/use-link-status.ts +134 -0
  25. package/src/browser/react/use-navigation.ts +150 -0
  26. package/src/browser/react/use-segments.ts +188 -0
  27. package/src/browser/request-controller.ts +149 -0
  28. package/src/browser/rsc-router.tsx +310 -0
  29. package/src/browser/scroll-restoration.ts +324 -0
  30. package/src/browser/server-action-bridge.ts +747 -0
  31. package/src/browser/shallow.ts +35 -0
  32. package/src/browser/types.ts +443 -0
  33. package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
  34. package/src/cache/__tests__/memory-store.test.ts +484 -0
  35. package/src/cache/cache-scope.ts +565 -0
  36. package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
  37. package/src/cache/cf/cf-cache-store.ts +274 -0
  38. package/src/cache/cf/index.ts +19 -0
  39. package/src/cache/index.ts +52 -0
  40. package/src/cache/memory-segment-store.ts +150 -0
  41. package/src/cache/memory-store.ts +253 -0
  42. package/src/cache/types.ts +366 -0
  43. package/src/client.rsc.tsx +88 -0
  44. package/src/client.tsx +609 -0
  45. package/src/components/DefaultDocument.tsx +20 -0
  46. package/src/default-error-boundary.tsx +88 -0
  47. package/src/deps/browser.ts +8 -0
  48. package/src/deps/html-stream-client.ts +2 -0
  49. package/src/deps/html-stream-server.ts +2 -0
  50. package/src/deps/rsc.ts +10 -0
  51. package/src/deps/ssr.ts +2 -0
  52. package/src/errors.ts +259 -0
  53. package/src/handle.ts +120 -0
  54. package/src/handles/MetaTags.tsx +178 -0
  55. package/src/handles/index.ts +6 -0
  56. package/src/handles/meta.ts +247 -0
  57. package/src/href-client.ts +128 -0
  58. package/src/href.ts +139 -0
  59. package/src/index.rsc.ts +69 -0
  60. package/src/index.ts +84 -0
  61. package/src/loader.rsc.ts +204 -0
  62. package/src/loader.ts +47 -0
  63. package/src/network-error-thrower.tsx +21 -0
  64. package/src/outlet-context.ts +15 -0
  65. package/src/root-error-boundary.tsx +277 -0
  66. package/src/route-content-wrapper.tsx +198 -0
  67. package/src/route-definition.ts +1333 -0
  68. package/src/route-map-builder.ts +140 -0
  69. package/src/route-types.ts +148 -0
  70. package/src/route-utils.ts +89 -0
  71. package/src/router/__tests__/match-context.test.ts +104 -0
  72. package/src/router/__tests__/match-pipelines.test.ts +537 -0
  73. package/src/router/__tests__/match-result.test.ts +566 -0
  74. package/src/router/__tests__/on-error.test.ts +935 -0
  75. package/src/router/__tests__/pattern-matching.test.ts +577 -0
  76. package/src/router/error-handling.ts +287 -0
  77. package/src/router/handler-context.ts +60 -0
  78. package/src/router/loader-resolution.ts +326 -0
  79. package/src/router/manifest.ts +116 -0
  80. package/src/router/match-context.ts +261 -0
  81. package/src/router/match-middleware/background-revalidation.ts +236 -0
  82. package/src/router/match-middleware/cache-lookup.ts +261 -0
  83. package/src/router/match-middleware/cache-store.ts +250 -0
  84. package/src/router/match-middleware/index.ts +81 -0
  85. package/src/router/match-middleware/intercept-resolution.ts +268 -0
  86. package/src/router/match-middleware/segment-resolution.ts +174 -0
  87. package/src/router/match-pipelines.ts +214 -0
  88. package/src/router/match-result.ts +212 -0
  89. package/src/router/metrics.ts +62 -0
  90. package/src/router/middleware.test.ts +1355 -0
  91. package/src/router/middleware.ts +748 -0
  92. package/src/router/pattern-matching.ts +271 -0
  93. package/src/router/revalidation.ts +190 -0
  94. package/src/router/router-context.ts +299 -0
  95. package/src/router/types.ts +96 -0
  96. package/src/router.ts +3484 -0
  97. package/src/rsc/__tests__/helpers.test.ts +175 -0
  98. package/src/rsc/handler.ts +942 -0
  99. package/src/rsc/helpers.ts +64 -0
  100. package/src/rsc/index.ts +56 -0
  101. package/src/rsc/nonce.ts +18 -0
  102. package/src/rsc/types.ts +225 -0
  103. package/src/segment-system.tsx +405 -0
  104. package/src/server/__tests__/request-context.test.ts +171 -0
  105. package/src/server/context.ts +340 -0
  106. package/src/server/handle-store.ts +230 -0
  107. package/src/server/loader-registry.ts +174 -0
  108. package/src/server/request-context.ts +470 -0
  109. package/src/server/root-layout.tsx +10 -0
  110. package/src/server/tsconfig.json +14 -0
  111. package/src/server.ts +126 -0
  112. package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
  113. package/src/ssr/index.tsx +215 -0
  114. package/src/types.ts +1473 -0
  115. package/src/use-loader.tsx +346 -0
  116. package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
  117. package/src/vite/expose-action-id.ts +344 -0
  118. package/src/vite/expose-handle-id.ts +209 -0
  119. package/src/vite/expose-loader-id.ts +357 -0
  120. package/src/vite/expose-location-state-id.ts +177 -0
  121. package/src/vite/index.ts +608 -0
  122. package/src/vite/version.d.ts +12 -0
  123. package/src/vite/virtual-entries.ts +109 -0
@@ -0,0 +1,18 @@
1
+ // ============================================================================
2
+ // Browser Module - Browser entry point for RSC Router
3
+ // ============================================================================
4
+ //
5
+ // Usage:
6
+ // import { initBrowserApp, RSCRouter } from "rsc-router/browser";
7
+ //
8
+ // For React components (Link, useNavigation, etc.):
9
+ // import { Link, useNavigation, useAction, href } from "rsc-router/client";
10
+ //
11
+ // ============================================================================
12
+
13
+ // Browser app initialization
14
+ export {
15
+ initBrowserApp,
16
+ RSCRouter,
17
+ type InitBrowserAppOptions,
18
+ } from "./rsc-router.js";
@@ -0,0 +1,121 @@
1
+ import type { LinkInterceptorOptions, NavigateOptions } from "./types.js";
2
+
3
+ /**
4
+ * Default link interception predicate
5
+ *
6
+ * Returns true if the link should be intercepted for SPA navigation.
7
+ * Filters out:
8
+ * - Cross-origin links
9
+ * - Links with download attribute
10
+ * - Links with target other than _self
11
+ * - Links with data-no-intercept attribute
12
+ *
13
+ * @param link - The anchor element to check
14
+ * @returns true if the link should be intercepted
15
+ */
16
+ export function defaultShouldIntercept(link: HTMLAnchorElement): boolean {
17
+ // Only intercept same-origin links
18
+ if (link.origin !== window.location.origin) {
19
+ return false;
20
+ }
21
+
22
+ // Don't intercept if it has download attribute
23
+ if (link.hasAttribute("download")) {
24
+ return false;
25
+ }
26
+
27
+ // Don't intercept if target is set to something other than _self
28
+ if (link.target && link.target !== "_self") {
29
+ return false;
30
+ }
31
+
32
+ // Don't intercept if explicitly disabled
33
+ if (link.getAttribute("data-no-intercept") === "true") {
34
+ return false;
35
+ }
36
+
37
+ // Don't intercept Link component anchors - they handle their own navigation
38
+ if (link.hasAttribute("data-link-component")) {
39
+ return false;
40
+ }
41
+
42
+ // Don't intercept external links
43
+ if (link.hasAttribute("data-external")) {
44
+ return false;
45
+ }
46
+
47
+ return true;
48
+ }
49
+
50
+ /**
51
+ * Set up link interception for SPA navigation
52
+ *
53
+ * Attaches a global click handler to intercept clicks on anchor elements
54
+ * and call the onNavigate callback instead of performing a full page load.
55
+ *
56
+ * @param onNavigate - Callback when a link should navigate via SPA
57
+ * @param options - Configuration options
58
+ * @returns Cleanup function to remove the event listener
59
+ *
60
+ * @example
61
+ * ```typescript
62
+ * const cleanup = setupLinkInterception((url) => {
63
+ * window.history.pushState({}, "", url);
64
+ * fetchPartialUpdate(url);
65
+ * });
66
+ *
67
+ * // Later, to clean up:
68
+ * cleanup();
69
+ * ```
70
+ */
71
+ export function setupLinkInterception(
72
+ onNavigate: (url: string, options?: NavigateOptions) => void,
73
+ options?: LinkInterceptorOptions
74
+ ): () => void {
75
+ const shouldIntercept = options?.shouldIntercept ?? defaultShouldIntercept;
76
+
77
+ const handleClick = (event: MouseEvent) => {
78
+ // If event was already handled by Link component (or other handler), skip
79
+ if (event.defaultPrevented) {
80
+ return;
81
+ }
82
+
83
+ const target = event.target as HTMLElement;
84
+ const link = target.closest("a");
85
+
86
+ if (!link || !shouldIntercept(link)) {
87
+ return;
88
+ }
89
+
90
+ // Don't intercept if modifier keys are pressed (open in new tab, etc.)
91
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
92
+ return;
93
+ }
94
+
95
+ event.preventDefault();
96
+ const href = link.href;
97
+
98
+ // Read navigation options from data attributes (set by Link component)
99
+ const scrollAttr = link.getAttribute("data-scroll");
100
+ const replaceAttr = link.getAttribute("data-replace");
101
+
102
+ const navigateOptions: NavigateOptions = {};
103
+ if (scrollAttr === "false") {
104
+ navigateOptions.scroll = false;
105
+ }
106
+ if (replaceAttr === "true") {
107
+ navigateOptions.replace = true;
108
+ }
109
+
110
+ onNavigate(href, navigateOptions);
111
+ };
112
+
113
+ document.addEventListener("click", handleClick);
114
+
115
+ console.log("[Browser] Link interception enabled");
116
+
117
+ return () => {
118
+ document.removeEventListener("click", handleClick);
119
+ console.log("[Browser] Link interception disabled");
120
+ };
121
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Simple LRU (Least Recently Used) cache implementation
3
+ * Used for caching navigation segments to enable instant back/forward
4
+ */
5
+ export class LRUCache<K, V> {
6
+ private cache = new Map<K, V>();
7
+ private maxSize: number;
8
+
9
+ constructor(maxSize: number) {
10
+ this.maxSize = maxSize;
11
+ }
12
+
13
+ get(key: K): V | undefined {
14
+ if (!this.cache.has(key)) {
15
+ return undefined;
16
+ }
17
+
18
+ // Move to end (most recently used)
19
+ const value = this.cache.get(key)!;
20
+ this.cache.delete(key);
21
+ this.cache.set(key, value);
22
+ return value;
23
+ }
24
+
25
+ set(key: K, value: V): void {
26
+ // If key exists, delete it first to update position
27
+ if (this.cache.has(key)) {
28
+ this.cache.delete(key);
29
+ }
30
+
31
+ this.cache.set(key, value);
32
+
33
+ // Evict oldest entries if over capacity
34
+ while (this.cache.size > this.maxSize) {
35
+ const oldestKey = this.cache.keys().next().value;
36
+ if (oldestKey !== undefined) {
37
+ this.cache.delete(oldestKey);
38
+ }
39
+ }
40
+ }
41
+
42
+ has(key: K): boolean {
43
+ if (!this.cache.has(key)) {
44
+ return false;
45
+ }
46
+
47
+ // Move to end (most recently used) - same as get()
48
+ const value = this.cache.get(key)!;
49
+ this.cache.delete(key);
50
+ this.cache.set(key, value);
51
+ return true;
52
+ }
53
+
54
+ delete(key: K): boolean {
55
+ return this.cache.delete(key);
56
+ }
57
+
58
+ clear(): void {
59
+ this.cache.clear();
60
+ }
61
+
62
+ keys(): IterableIterator<K> {
63
+ return this.cache.keys();
64
+ }
65
+
66
+ get size(): number {
67
+ return this.cache.size;
68
+ }
69
+ }
@@ -0,0 +1,126 @@
1
+ import type { ResolvedSegment } from "./types.js";
2
+
3
+ /**
4
+ * Merge partial loader data from server with cached loader data.
5
+ *
6
+ * During partial revalidation (stale or action), the server may return only
7
+ * some loaders that pass the revalidation check. The component still needs
8
+ * all loader data, so we merge fresh data with cached data.
9
+ *
10
+ * @param fromServer - Segment returned from server with partial loaders
11
+ * @param fromCache - Cached segment with full loader data
12
+ * @returns Merged segment with complete loader data
13
+ */
14
+ export function mergeSegmentLoaders(
15
+ fromServer: ResolvedSegment,
16
+ fromCache: ResolvedSegment
17
+ ): ResolvedSegment {
18
+ const serverLoaderIds = fromServer.loaderIds || [];
19
+ const cachedLoaderIds = fromCache.loaderIds || [];
20
+
21
+ console.log(
22
+ `[Browser] Merging partial loaders: server has ${serverLoaderIds.join(", ")}, cache has ${cachedLoaderIds.join(", ")}`
23
+ );
24
+
25
+ return {
26
+ ...fromCache,
27
+ // Keep cached component (server's might be a fresh Promise that needs the loaders)
28
+ component: fromCache.component,
29
+ // Merge loader data - await both and combine
30
+ loaderDataPromise: Promise.all([
31
+ fromServer.loaderDataPromise!,
32
+ fromCache.loaderDataPromise!,
33
+ ]).then(([newData, cachedData]) => {
34
+ // Build merged array: use new data for updated loaders, cached for rest
35
+ return cachedLoaderIds.map((id: string, i: number) => {
36
+ const newIndex = serverLoaderIds.indexOf(id);
37
+ if (newIndex !== -1) {
38
+ return (newData as any[])[newIndex]; // Use fresh data
39
+ }
40
+ return (cachedData as any[])[i]; // Use cached data
41
+ });
42
+ }),
43
+ // Keep all loader IDs from cache
44
+ loaderIds: fromCache.loaderIds,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Check if segments need loader merging during partial revalidation.
50
+ *
51
+ * Returns true when:
52
+ * - Server returned fewer loaders than cached (partial revalidation)
53
+ * - Both segments have loader data promises
54
+ */
55
+ export function needsLoaderMerge(
56
+ fromServer: ResolvedSegment,
57
+ fromCache: ResolvedSegment | undefined
58
+ ): fromCache is ResolvedSegment {
59
+ return !!(
60
+ fromCache &&
61
+ fromServer.loaderIds &&
62
+ fromCache.loaderIds &&
63
+ fromServer.loaderIds.length < fromCache.loaderIds.length &&
64
+ fromServer.loaderDataPromise &&
65
+ fromCache.loaderDataPromise
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Insert diff segments that aren't in the matched array into allSegments.
71
+ *
72
+ * During consolidation fetch for concurrent actions, loader segments may be
73
+ * excluded from the request. The server returns them in the diff but not in
74
+ * the matched array. This function inserts them at the correct position
75
+ * (after their parent layout segment).
76
+ *
77
+ * Loader segment IDs follow the pattern: {parentLayoutId}D{index}.{loaderId}
78
+ * Example: M9L0L1D0.actionCounter has parent layout M9L0L1
79
+ *
80
+ * @param allSegments - Mutable array of segments to insert into
81
+ * @param diff - Array of segment IDs that changed (from server response)
82
+ * @param matchedIdSet - Set of segment IDs from matched array
83
+ * @param newSegmentMap - Map of segment ID to segment data from server
84
+ */
85
+ export function insertMissingDiffSegments(
86
+ allSegments: ResolvedSegment[],
87
+ diff: string[] | undefined,
88
+ matchedIdSet: Set<string>,
89
+ newSegmentMap: Map<string, ResolvedSegment>
90
+ ): void {
91
+ if (!diff || diff.length === 0) return;
92
+
93
+ diff.forEach((diffId: string) => {
94
+ if (!matchedIdSet.has(diffId)) {
95
+ const fromServer = newSegmentMap.get(diffId);
96
+ if (fromServer) {
97
+ // Loader segment IDs have pattern like M9L0L1D0.actionCounter
98
+ // Parent layout ID is the prefix before D\d+ (e.g., M9L0L1)
99
+ const loaderMatch = diffId.match(/^(.+?)D\d+\./);
100
+ if (loaderMatch) {
101
+ const parentLayoutId = loaderMatch[1];
102
+ const parentIndex = allSegments.findIndex(
103
+ (s) => s.id === parentLayoutId
104
+ );
105
+ if (parentIndex !== -1) {
106
+ // Insert loader segment right after its parent layout
107
+ allSegments.splice(parentIndex + 1, 0, fromServer);
108
+ console.log(
109
+ `[Browser] Inserted diff segment ${diffId} after ${parentLayoutId}`
110
+ );
111
+ } else {
112
+ // Fallback: append to end if parent not found
113
+ allSegments.push(fromServer);
114
+ console.warn(
115
+ `[Browser] Appended diff segment ${diffId} (parent ${parentLayoutId} not found)`
116
+ );
117
+ }
118
+ } else {
119
+ // Non-loader diff segment not in matched - append to end
120
+ allSegments.push(fromServer);
121
+ console.log(`[Browser] Appended diff segment ${diffId}`);
122
+ }
123
+ }
124
+ }
125
+ });
126
+ }