@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,134 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useState,
|
|
7
|
+
useEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useOptimistic,
|
|
10
|
+
startTransition,
|
|
11
|
+
type Context,
|
|
12
|
+
} from "react";
|
|
13
|
+
import { NavigationStoreContext } from "./context.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Context for Link component to provide its destination URL
|
|
17
|
+
* Used by useLinkStatus to determine if this specific link is pending
|
|
18
|
+
*/
|
|
19
|
+
export const LinkContext: Context<string | null> = createContext<string | null>(null);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Link status returned by useLinkStatus hook
|
|
23
|
+
*/
|
|
24
|
+
export interface LinkStatus {
|
|
25
|
+
/** Whether navigation to this link's destination is in progress */
|
|
26
|
+
pending: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Normalize URL for comparison
|
|
31
|
+
* Handles relative URLs and ensures consistent format
|
|
32
|
+
*/
|
|
33
|
+
function normalizeUrl(url: string, origin: string): string {
|
|
34
|
+
try {
|
|
35
|
+
const parsed = new URL(url, origin);
|
|
36
|
+
// Return pathname + search + hash for comparison
|
|
37
|
+
return parsed.pathname + parsed.search + parsed.hash;
|
|
38
|
+
} catch {
|
|
39
|
+
return url;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if this link's destination matches the pending navigation URL
|
|
45
|
+
*/
|
|
46
|
+
function isPendingFor(
|
|
47
|
+
linkTo: string | null,
|
|
48
|
+
pendingUrl: string | null,
|
|
49
|
+
origin: string
|
|
50
|
+
): boolean {
|
|
51
|
+
if (linkTo === null || pendingUrl === null) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return normalizeUrl(pendingUrl, origin) === normalizeUrl(linkTo, origin);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Hook to track the pending state of a Link component
|
|
59
|
+
*
|
|
60
|
+
* Must be used inside a Link component. Returns `{ pending: true }`
|
|
61
|
+
* when navigation to this link's destination is in progress.
|
|
62
|
+
*
|
|
63
|
+
* Useful for showing inline loading indicators on individual links.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```tsx
|
|
67
|
+
* function LoadingIndicator() {
|
|
68
|
+
* const { pending } = useLinkStatus();
|
|
69
|
+
* return pending ? <Spinner /> : null;
|
|
70
|
+
* }
|
|
71
|
+
*
|
|
72
|
+
* // In your component:
|
|
73
|
+
* <Link to="/dashboard">
|
|
74
|
+
* Dashboard
|
|
75
|
+
* <LoadingIndicator />
|
|
76
|
+
* </Link>
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function useLinkStatus(): LinkStatus {
|
|
80
|
+
const linkTo = useContext(LinkContext);
|
|
81
|
+
const ctx = useContext(NavigationStoreContext);
|
|
82
|
+
|
|
83
|
+
// Get origin for URL normalization (stable across renders)
|
|
84
|
+
const origin = typeof window !== "undefined"
|
|
85
|
+
? window.location.origin
|
|
86
|
+
: "http://localhost";
|
|
87
|
+
|
|
88
|
+
// Base state for useOptimistic
|
|
89
|
+
const [basePending, setBasePending] = useState<boolean>(() => {
|
|
90
|
+
if (!ctx || linkTo === null) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const state = ctx.eventController.getState();
|
|
94
|
+
return isPendingFor(linkTo, state.pendingUrl, origin);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const prevPending = useRef(basePending);
|
|
98
|
+
|
|
99
|
+
// useOptimistic allows immediate updates during transitions
|
|
100
|
+
const [pending, setOptimisticPending] = useOptimistic(basePending);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!ctx || linkTo === null) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Subscribe to navigation state changes
|
|
108
|
+
return ctx.eventController.subscribe(() => {
|
|
109
|
+
const state = ctx.eventController.getState();
|
|
110
|
+
const isPending = isPendingFor(linkTo, state.pendingUrl, origin);
|
|
111
|
+
|
|
112
|
+
if (isPending !== prevPending.current) {
|
|
113
|
+
prevPending.current = isPending;
|
|
114
|
+
|
|
115
|
+
// Use optimistic update for immediate feedback during navigation
|
|
116
|
+
if (state.state !== "idle") {
|
|
117
|
+
startTransition(() => {
|
|
118
|
+
setOptimisticPending(isPending);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Always update base state
|
|
123
|
+
setBasePending(isPending);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}, [linkTo, origin]);
|
|
127
|
+
|
|
128
|
+
// If not inside a Link, return not pending
|
|
129
|
+
if (linkTo === null) {
|
|
130
|
+
return { pending: false };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { pending };
|
|
134
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useContext,
|
|
5
|
+
useState,
|
|
6
|
+
useEffect,
|
|
7
|
+
useOptimistic,
|
|
8
|
+
startTransition,
|
|
9
|
+
useRef,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { NavigationStoreContext } from "./context.js";
|
|
12
|
+
import type { PublicNavigationState, NavigateOptions } from "../types.js";
|
|
13
|
+
import type { DerivedNavigationState } from "../event-controller.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Shallow equality check for selector results
|
|
17
|
+
*/
|
|
18
|
+
function shallowEqual<T>(a: T, b: T): boolean {
|
|
19
|
+
if (Object.is(a, b)) return true;
|
|
20
|
+
if (
|
|
21
|
+
typeof a !== "object" ||
|
|
22
|
+
a === null ||
|
|
23
|
+
typeof b !== "object" ||
|
|
24
|
+
b === null
|
|
25
|
+
) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
const keysA = Object.keys(a);
|
|
29
|
+
const keysB = Object.keys(b);
|
|
30
|
+
if (keysA.length !== keysB.length) return false;
|
|
31
|
+
for (const key of keysA) {
|
|
32
|
+
if (
|
|
33
|
+
!Object.hasOwn(b, key) ||
|
|
34
|
+
!Object.is((a as any)[key], (b as any)[key])
|
|
35
|
+
) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// SSR-safe default state (public version without internal properties)
|
|
43
|
+
const SSR_DEFAULT_STATE: PublicNavigationState = {
|
|
44
|
+
state: "idle",
|
|
45
|
+
isStreaming: false,
|
|
46
|
+
location: new URL("/", "http://localhost"),
|
|
47
|
+
pendingUrl: null,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Convert derived state to public version (strips inflightActions)
|
|
52
|
+
*/
|
|
53
|
+
function toPublicState(state: DerivedNavigationState): PublicNavigationState {
|
|
54
|
+
const { inflightActions: _, ...publicState } = state;
|
|
55
|
+
return publicState;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// No-op functions for SSR
|
|
59
|
+
const noopNavigate = async () => {};
|
|
60
|
+
const noopRefresh = async () => {};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Navigation methods returned by useNavigation
|
|
64
|
+
*/
|
|
65
|
+
export interface NavigationMethods {
|
|
66
|
+
navigate: (url: string, options?: NavigateOptions) => Promise<void>;
|
|
67
|
+
refresh: () => Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Full value returned when no selector is provided
|
|
72
|
+
*/
|
|
73
|
+
export type NavigationValue = PublicNavigationState & NavigationMethods;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Hook to access navigation state with optional selector for performance
|
|
77
|
+
*
|
|
78
|
+
* Uses the event controller for reactive state management.
|
|
79
|
+
* State is derived from source of truth (currentNavigation, inflightActions).
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```tsx
|
|
83
|
+
* const state = useNavigation(nav => nav.state);
|
|
84
|
+
* const isLoading = useNavigation(nav => nav.state === 'loading');
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export function useNavigation(): NavigationValue;
|
|
88
|
+
export function useNavigation<T>(
|
|
89
|
+
selector: (state: PublicNavigationState) => T
|
|
90
|
+
): T;
|
|
91
|
+
export function useNavigation<T>(
|
|
92
|
+
selector?: (state: PublicNavigationState) => T
|
|
93
|
+
): T | NavigationValue {
|
|
94
|
+
const ctx = useContext(NavigationStoreContext);
|
|
95
|
+
|
|
96
|
+
// Base state for useOptimistic
|
|
97
|
+
const [baseValue, setBaseValue] = useState<T | PublicNavigationState>(() => {
|
|
98
|
+
if (typeof document === "undefined" || !ctx) {
|
|
99
|
+
return selector ? selector(SSR_DEFAULT_STATE) : SSR_DEFAULT_STATE;
|
|
100
|
+
}
|
|
101
|
+
const publicState = toPublicState(ctx.eventController.getState());
|
|
102
|
+
return selector ? selector(publicState) : publicState;
|
|
103
|
+
});
|
|
104
|
+
const prevState = useRef(baseValue);
|
|
105
|
+
|
|
106
|
+
// useOptimistic allows immediate updates during transitions/actions
|
|
107
|
+
const [value, setOptimisticValue] = useOptimistic(baseValue);
|
|
108
|
+
|
|
109
|
+
// Subscribe to event controller state changes (only runs on client)
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!ctx) return;
|
|
112
|
+
|
|
113
|
+
// Subscribe to updates from event controller
|
|
114
|
+
return ctx.eventController.subscribe(() => {
|
|
115
|
+
const currentState = ctx.eventController.getState();
|
|
116
|
+
const publicState = toPublicState(currentState);
|
|
117
|
+
const nextSelected = selector ? selector(publicState) : publicState;
|
|
118
|
+
|
|
119
|
+
// Check if selected value has changed
|
|
120
|
+
if (!shallowEqual(nextSelected, prevState.current)) {
|
|
121
|
+
prevState.current = nextSelected;
|
|
122
|
+
|
|
123
|
+
// Check if any actions are in progress for optimistic updates
|
|
124
|
+
const hasInflightActions =
|
|
125
|
+
ctx.eventController.getInflightActions().size > 0;
|
|
126
|
+
|
|
127
|
+
if (hasInflightActions || publicState.state !== "idle") {
|
|
128
|
+
// Use optimistic update for immediate feedback during transitions
|
|
129
|
+
startTransition(() => {
|
|
130
|
+
setOptimisticValue(nextSelected);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Always update base state so UI reflects current state
|
|
135
|
+
setBaseValue(nextSelected);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}, [selector]);
|
|
139
|
+
|
|
140
|
+
// If no selector, include navigation methods
|
|
141
|
+
if (!selector) {
|
|
142
|
+
return {
|
|
143
|
+
...(value as PublicNavigationState),
|
|
144
|
+
navigate: ctx?.navigate ?? noopNavigate,
|
|
145
|
+
refresh: ctx?.refresh ?? noopRefresh,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return value as T;
|
|
150
|
+
}
|
|
@@ -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,149 @@
|
|
|
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
|
+
const controllers: AbortController[] = [];
|
|
34
|
+
// Action controllers - NOT aborted by navigation, only by errors
|
|
35
|
+
const actionControllers: AbortController[] = [];
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
/**
|
|
39
|
+
* Create a new abort controller and track it for navigation
|
|
40
|
+
*
|
|
41
|
+
* @returns A new AbortController
|
|
42
|
+
*/
|
|
43
|
+
create(): AbortController {
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
controllers.push(controller);
|
|
46
|
+
console.log(
|
|
47
|
+
`[Browser] Created abort controller, total: ${controllers.length}`
|
|
48
|
+
);
|
|
49
|
+
return controller;
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create a disposable abort controller for navigation use with `using` keyword
|
|
54
|
+
*
|
|
55
|
+
* The controller will be automatically removed from tracking when
|
|
56
|
+
* it goes out of scope, regardless of how the scope is exited.
|
|
57
|
+
*
|
|
58
|
+
* @returns A DisposableAbortController
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```typescript
|
|
62
|
+
* async function handleNavigation() {
|
|
63
|
+
* requestController.abortAll();
|
|
64
|
+
* using { controller } = requestController.createDisposable();
|
|
65
|
+
* // ... use controller.signal ...
|
|
66
|
+
* // controller is automatically removed on scope exit
|
|
67
|
+
* }
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
createDisposable(): DisposableAbortController {
|
|
71
|
+
const controller = this.create();
|
|
72
|
+
return {
|
|
73
|
+
controller,
|
|
74
|
+
[Symbol.dispose]: () => {
|
|
75
|
+
this.remove(controller);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create a disposable abort controller for actions
|
|
82
|
+
*
|
|
83
|
+
* Action controllers are NOT aborted by navigation - they complete
|
|
84
|
+
* independently. Only aborted by abortAllActions() on error.
|
|
85
|
+
*
|
|
86
|
+
* @returns A DisposableAbortController
|
|
87
|
+
*/
|
|
88
|
+
createActionDisposable(): DisposableAbortController {
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
actionControllers.push(controller);
|
|
91
|
+
console.log(
|
|
92
|
+
`[Browser] Created action controller, total: ${actionControllers.length}`
|
|
93
|
+
);
|
|
94
|
+
return {
|
|
95
|
+
controller,
|
|
96
|
+
[Symbol.dispose]: () => {
|
|
97
|
+
const index = actionControllers.indexOf(controller);
|
|
98
|
+
if (index !== -1) {
|
|
99
|
+
actionControllers.splice(index, 1);
|
|
100
|
+
console.log(
|
|
101
|
+
`[Browser] Removed action controller, remaining: ${actionControllers.length}`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Abort all navigation controllers (NOT actions)
|
|
110
|
+
*
|
|
111
|
+
* Called when starting new navigation. Actions continue
|
|
112
|
+
* to complete in the background.
|
|
113
|
+
*/
|
|
114
|
+
abortAll(): void {
|
|
115
|
+
controllers.forEach((controller) => controller.abort());
|
|
116
|
+
controllers.length = 0;
|
|
117
|
+
console.log(`[Browser] Aborted all navigation controllers`);
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Abort all action controllers
|
|
122
|
+
*
|
|
123
|
+
* Called when an action error occurs - prevents other actions
|
|
124
|
+
* from completing and overwriting the error UI.
|
|
125
|
+
*/
|
|
126
|
+
abortAllActions(): void {
|
|
127
|
+
actionControllers.forEach((controller) => controller.abort());
|
|
128
|
+
actionControllers.length = 0;
|
|
129
|
+
console.log(`[Browser] Aborted all action controllers`);
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Remove a specific controller from tracking
|
|
134
|
+
*
|
|
135
|
+
* Call this when a request completes successfully.
|
|
136
|
+
*
|
|
137
|
+
* @param controller - The controller to remove
|
|
138
|
+
*/
|
|
139
|
+
remove(controller: AbortController): void {
|
|
140
|
+
const index = controllers.indexOf(controller);
|
|
141
|
+
if (index !== -1) {
|
|
142
|
+
controllers.splice(index, 1);
|
|
143
|
+
console.log(
|
|
144
|
+
`[Browser] Removed abort controller, remaining: ${controllers.length}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|