@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.
- package/README.md +19 -0
- package/package.json +131 -0
- package/src/__mocks__/version.ts +6 -0
- package/src/__tests__/route-definition.test.ts +63 -0
- package/src/browser/event-controller.ts +876 -0
- package/src/browser/index.ts +18 -0
- package/src/browser/link-interceptor.ts +121 -0
- package/src/browser/lru-cache.ts +69 -0
- package/src/browser/merge-segment-loaders.ts +126 -0
- package/src/browser/navigation-bridge.ts +891 -0
- package/src/browser/navigation-client.ts +155 -0
- package/src/browser/navigation-store.ts +823 -0
- package/src/browser/partial-update.ts +545 -0
- package/src/browser/react/Link.tsx +248 -0
- package/src/browser/react/NavigationProvider.tsx +228 -0
- package/src/browser/react/ScrollRestoration.tsx +94 -0
- package/src/browser/react/context.ts +53 -0
- package/src/browser/react/index.ts +52 -0
- package/src/browser/react/location-state-shared.ts +120 -0
- package/src/browser/react/location-state.ts +62 -0
- package/src/browser/react/use-action.ts +240 -0
- package/src/browser/react/use-client-cache.ts +56 -0
- package/src/browser/react/use-handle.ts +178 -0
- package/src/browser/react/use-link-status.ts +134 -0
- package/src/browser/react/use-navigation.ts +150 -0
- package/src/browser/react/use-segments.ts +188 -0
- package/src/browser/request-controller.ts +149 -0
- package/src/browser/rsc-router.tsx +310 -0
- package/src/browser/scroll-restoration.ts +324 -0
- package/src/browser/server-action-bridge.ts +747 -0
- package/src/browser/shallow.ts +35 -0
- package/src/browser/types.ts +443 -0
- package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
- package/src/cache/__tests__/memory-store.test.ts +484 -0
- package/src/cache/cache-scope.ts +565 -0
- package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
- package/src/cache/cf/cf-cache-store.ts +274 -0
- package/src/cache/cf/index.ts +19 -0
- package/src/cache/index.ts +52 -0
- package/src/cache/memory-segment-store.ts +150 -0
- package/src/cache/memory-store.ts +253 -0
- package/src/cache/types.ts +366 -0
- package/src/client.rsc.tsx +88 -0
- package/src/client.tsx +609 -0
- package/src/components/DefaultDocument.tsx +20 -0
- package/src/default-error-boundary.tsx +88 -0
- package/src/deps/browser.ts +8 -0
- package/src/deps/html-stream-client.ts +2 -0
- package/src/deps/html-stream-server.ts +2 -0
- package/src/deps/rsc.ts +10 -0
- package/src/deps/ssr.ts +2 -0
- package/src/errors.ts +259 -0
- package/src/handle.ts +120 -0
- package/src/handles/MetaTags.tsx +178 -0
- package/src/handles/index.ts +6 -0
- package/src/handles/meta.ts +247 -0
- package/src/href-client.ts +128 -0
- package/src/href.ts +139 -0
- package/src/index.rsc.ts +69 -0
- package/src/index.ts +84 -0
- package/src/loader.rsc.ts +204 -0
- package/src/loader.ts +47 -0
- package/src/network-error-thrower.tsx +21 -0
- package/src/outlet-context.ts +15 -0
- package/src/root-error-boundary.tsx +277 -0
- package/src/route-content-wrapper.tsx +198 -0
- package/src/route-definition.ts +1333 -0
- package/src/route-map-builder.ts +140 -0
- package/src/route-types.ts +148 -0
- package/src/route-utils.ts +89 -0
- package/src/router/__tests__/match-context.test.ts +104 -0
- package/src/router/__tests__/match-pipelines.test.ts +537 -0
- package/src/router/__tests__/match-result.test.ts +566 -0
- package/src/router/__tests__/on-error.test.ts +935 -0
- package/src/router/__tests__/pattern-matching.test.ts +577 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/handler-context.ts +60 -0
- package/src/router/loader-resolution.ts +326 -0
- package/src/router/manifest.ts +116 -0
- package/src/router/match-context.ts +261 -0
- package/src/router/match-middleware/background-revalidation.ts +236 -0
- package/src/router/match-middleware/cache-lookup.ts +261 -0
- package/src/router/match-middleware/cache-store.ts +250 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +268 -0
- package/src/router/match-middleware/segment-resolution.ts +174 -0
- package/src/router/match-pipelines.ts +214 -0
- package/src/router/match-result.ts +212 -0
- package/src/router/metrics.ts +62 -0
- package/src/router/middleware.test.ts +1355 -0
- package/src/router/middleware.ts +748 -0
- package/src/router/pattern-matching.ts +271 -0
- package/src/router/revalidation.ts +190 -0
- package/src/router/router-context.ts +299 -0
- package/src/router/types.ts +96 -0
- package/src/router.ts +3484 -0
- package/src/rsc/__tests__/helpers.test.ts +175 -0
- package/src/rsc/handler.ts +942 -0
- package/src/rsc/helpers.ts +64 -0
- package/src/rsc/index.ts +56 -0
- package/src/rsc/nonce.ts +18 -0
- package/src/rsc/types.ts +225 -0
- package/src/segment-system.tsx +405 -0
- package/src/server/__tests__/request-context.test.ts +171 -0
- package/src/server/context.ts +340 -0
- package/src/server/handle-store.ts +230 -0
- package/src/server/loader-registry.ts +174 -0
- package/src/server/request-context.ts +470 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +126 -0
- package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
- package/src/ssr/index.tsx +215 -0
- package/src/types.ts +1473 -0
- package/src/use-loader.tsx +346 -0
- package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
- package/src/vite/expose-action-id.ts +344 -0
- package/src/vite/expose-handle-id.ts +209 -0
- package/src/vite/expose-loader-id.ts +357 -0
- package/src/vite/expose-location-state-id.ts +177 -0
- package/src/vite/index.ts +608 -0
- package/src/vite/version.d.ts +12 -0
- 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
|
+
}
|