@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,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intercept Resolution Middleware
|
|
3
|
+
*
|
|
4
|
+
* Resolves intercept (modal slot) segments for soft navigation.
|
|
5
|
+
* Yields intercept segments after main route segments.
|
|
6
|
+
*
|
|
7
|
+
* FLOW DIAGRAM
|
|
8
|
+
* ============
|
|
9
|
+
*
|
|
10
|
+
* source (from segment-resolution)
|
|
11
|
+
* |
|
|
12
|
+
* v
|
|
13
|
+
* +---------------------------+
|
|
14
|
+
* | Collect + yield source | Pass through main segments
|
|
15
|
+
* | segments[] |
|
|
16
|
+
* +---------------------------+
|
|
17
|
+
* |
|
|
18
|
+
* v
|
|
19
|
+
* +---------------------+
|
|
20
|
+
* | isFullMatch? |──yes──> return (no intercepts on doc requests)
|
|
21
|
+
* +---------------------+
|
|
22
|
+
* | no
|
|
23
|
+
* v
|
|
24
|
+
* +---------------------+
|
|
25
|
+
* | Has interceptResult |──no───> return
|
|
26
|
+
* | AND not cached? |
|
|
27
|
+
* +---------------------+
|
|
28
|
+
* | yes
|
|
29
|
+
* v
|
|
30
|
+
* +----------------------+ +----------------------------+
|
|
31
|
+
* | Fresh intercept? |yes>| resolveInterceptEntry() |
|
|
32
|
+
* | (!cacheHit or | | - middleware, loaders, UI |
|
|
33
|
+
* | no intercept segs) | +----------------------------+
|
|
34
|
+
* +----------------------+ |
|
|
35
|
+
* | no v
|
|
36
|
+
* v yield intercept segments
|
|
37
|
+
* +----------------------------+ |
|
|
38
|
+
* | Cache hit with intercept | |
|
|
39
|
+
* | handleCacheHitIntercept() | |
|
|
40
|
+
* | - Extract from cache | |
|
|
41
|
+
* | - Re-resolve loaders only | |
|
|
42
|
+
* +----------------------------+ |
|
|
43
|
+
* | |
|
|
44
|
+
* +-------------------------------+
|
|
45
|
+
* |
|
|
46
|
+
* v
|
|
47
|
+
* +---------------------------+
|
|
48
|
+
* | Update state: |
|
|
49
|
+
* | - interceptSegments |
|
|
50
|
+
* | - slots[slotName] |
|
|
51
|
+
* +---------------------------+
|
|
52
|
+
* |
|
|
53
|
+
* v
|
|
54
|
+
* next middleware
|
|
55
|
+
*
|
|
56
|
+
*
|
|
57
|
+
* INTERCEPT SCENARIOS
|
|
58
|
+
* ===================
|
|
59
|
+
*
|
|
60
|
+
* 1. Fresh intercept (no cache):
|
|
61
|
+
* - Full resolution of intercept entry
|
|
62
|
+
* - Resolves middleware, loaders, and component
|
|
63
|
+
* - Yields all intercept segments
|
|
64
|
+
*
|
|
65
|
+
* 2. Cache hit with intercept:
|
|
66
|
+
* - Extracts intercept segments from cached data
|
|
67
|
+
* - Re-resolves ONLY loaders for fresh data
|
|
68
|
+
* - Keeps cached component/layout
|
|
69
|
+
*
|
|
70
|
+
* 3. No intercept:
|
|
71
|
+
* - Passes through unchanged
|
|
72
|
+
* - No intercept segments yielded
|
|
73
|
+
*
|
|
74
|
+
*
|
|
75
|
+
* WHAT ARE INTERCEPTS?
|
|
76
|
+
* ====================
|
|
77
|
+
*
|
|
78
|
+
* Intercepts enable "soft navigation" patterns like modals:
|
|
79
|
+
*
|
|
80
|
+
* 1. User clicks a link (e.g., /photos/123)
|
|
81
|
+
* 2. Instead of full navigation, content renders in a modal slot
|
|
82
|
+
* 3. Background page remains visible and interactive
|
|
83
|
+
* 4. Hard navigation (direct URL) shows full page
|
|
84
|
+
*
|
|
85
|
+
* Configuration:
|
|
86
|
+
* intercept("@modal", "photos", <PhotoModal />, () => [...])
|
|
87
|
+
*
|
|
88
|
+
* The intercept resolves to segments that render in the named slot
|
|
89
|
+
* instead of replacing the main content.
|
|
90
|
+
*
|
|
91
|
+
*
|
|
92
|
+
* SLOT STRUCTURE
|
|
93
|
+
* ==============
|
|
94
|
+
*
|
|
95
|
+
* state.slots[slotName] = {
|
|
96
|
+
* active: true,
|
|
97
|
+
* segments: [...intercept segments]
|
|
98
|
+
* }
|
|
99
|
+
*
|
|
100
|
+
* The client uses this to:
|
|
101
|
+
* 1. Keep current page segments
|
|
102
|
+
* 2. Render intercept segments in named <Outlet name="@modal" />
|
|
103
|
+
*/
|
|
104
|
+
import type { ResolvedSegment } from "../../types.js";
|
|
105
|
+
import type { MatchContext, MatchPipelineState } from "../match-context.js";
|
|
106
|
+
import { getRouterContext } from "../router-context.js";
|
|
107
|
+
import type { GeneratorMiddleware } from "./cache-lookup.js";
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Creates intercept resolution middleware
|
|
111
|
+
*
|
|
112
|
+
* If ctx.interceptResult exists and we're not in a cache-hit-with-intercept scenario:
|
|
113
|
+
* - Resolves intercept segments
|
|
114
|
+
* - Updates state.interceptSegments
|
|
115
|
+
* - Updates state.slots with the intercept slot
|
|
116
|
+
* - Yields intercept segments after main segments
|
|
117
|
+
*/
|
|
118
|
+
export function withInterceptResolution<TEnv>(
|
|
119
|
+
ctx: MatchContext<TEnv>,
|
|
120
|
+
state: MatchPipelineState
|
|
121
|
+
): GeneratorMiddleware<ResolvedSegment> {
|
|
122
|
+
return async function* (
|
|
123
|
+
source: AsyncGenerator<ResolvedSegment>
|
|
124
|
+
): AsyncGenerator<ResolvedSegment> {
|
|
125
|
+
// First, yield all segments from the source (main segment resolution or cache)
|
|
126
|
+
const segments: ResolvedSegment[] = [];
|
|
127
|
+
for await (const segment of source) {
|
|
128
|
+
segments.push(segment);
|
|
129
|
+
yield segment;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Skip intercept resolution for full match (document requests don't have intercepts)
|
|
133
|
+
if (ctx.isFullMatch) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Skip intercept resolution if:
|
|
138
|
+
// 1. No intercept result
|
|
139
|
+
// 2. Already have intercept segments (from cache hit with intercept key)
|
|
140
|
+
// 3. Cache hit with intercept key
|
|
141
|
+
const skipInterceptResolution =
|
|
142
|
+
!ctx.interceptResult ||
|
|
143
|
+
state.interceptSegments.length > 0 ||
|
|
144
|
+
(state.cacheHit && ctx.isIntercept);
|
|
145
|
+
|
|
146
|
+
if (skipInterceptResolution) {
|
|
147
|
+
// For cache hit with intercept, extract intercept segments from cached data for slots
|
|
148
|
+
// and re-resolve loaders for fresh data
|
|
149
|
+
if (ctx.interceptResult && state.cacheHit && ctx.isIntercept) {
|
|
150
|
+
await handleCacheHitIntercept(ctx, state, segments);
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Resolve intercept segments
|
|
156
|
+
const { resolveInterceptEntry } = getRouterContext<TEnv>();
|
|
157
|
+
|
|
158
|
+
const slotName = ctx.interceptResult!.intercept.slotName;
|
|
159
|
+
console.log(
|
|
160
|
+
`[Router.matchPartial] Found intercept for "${ctx.localRouteName}" -> slot "${slotName}"`
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// Resolve intercept entry (middleware, loaders, handler)
|
|
164
|
+
const Store = ctx.Store;
|
|
165
|
+
const interceptSegments = await Store.run(() =>
|
|
166
|
+
resolveInterceptEntry(
|
|
167
|
+
ctx.interceptResult!.intercept,
|
|
168
|
+
ctx.interceptResult!.entry,
|
|
169
|
+
ctx.matched.params,
|
|
170
|
+
ctx.handlerContext,
|
|
171
|
+
true, // belongsToRoute
|
|
172
|
+
{
|
|
173
|
+
clientSegmentIds: ctx.clientSegmentSet,
|
|
174
|
+
prevParams: ctx.prevParams,
|
|
175
|
+
request: ctx.request,
|
|
176
|
+
prevUrl: ctx.prevUrl,
|
|
177
|
+
nextUrl: ctx.url,
|
|
178
|
+
routeKey: ctx.routeKey,
|
|
179
|
+
actionContext: ctx.actionContext,
|
|
180
|
+
stale: ctx.stale,
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// Update state
|
|
186
|
+
state.interceptSegments = interceptSegments;
|
|
187
|
+
state.slots[slotName] = {
|
|
188
|
+
active: true,
|
|
189
|
+
segments: interceptSegments,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Yield intercept segments
|
|
193
|
+
for (const segment of interceptSegments) {
|
|
194
|
+
yield segment;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Handle cache hit with intercept scenario
|
|
201
|
+
*
|
|
202
|
+
* Extract intercept segments from cached data and re-resolve loaders for fresh data.
|
|
203
|
+
*/
|
|
204
|
+
async function handleCacheHitIntercept<TEnv>(
|
|
205
|
+
ctx: MatchContext<TEnv>,
|
|
206
|
+
state: MatchPipelineState,
|
|
207
|
+
segments: ResolvedSegment[]
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
if (!ctx.interceptResult) return;
|
|
210
|
+
|
|
211
|
+
const { resolveInterceptLoadersOnly } = getRouterContext<TEnv>();
|
|
212
|
+
|
|
213
|
+
const slotName = ctx.interceptResult.intercept.slotName;
|
|
214
|
+
|
|
215
|
+
// Find intercept segments from cached segments (namespace starts with "intercept:")
|
|
216
|
+
const interceptSegments = segments.filter((s) =>
|
|
217
|
+
s.namespace?.startsWith("intercept:")
|
|
218
|
+
);
|
|
219
|
+
state.interceptSegments = interceptSegments;
|
|
220
|
+
|
|
221
|
+
// Re-resolve intercept loaders for fresh data on cache hit
|
|
222
|
+
// This keeps cached component/layout but fetches fresh loader data
|
|
223
|
+
if (resolveInterceptLoadersOnly) {
|
|
224
|
+
const Store = ctx.Store;
|
|
225
|
+
const freshLoaderResult = await Store.run(() =>
|
|
226
|
+
resolveInterceptLoadersOnly(
|
|
227
|
+
ctx.interceptResult!.intercept,
|
|
228
|
+
ctx.interceptResult!.entry,
|
|
229
|
+
ctx.matched.params,
|
|
230
|
+
ctx.handlerContext,
|
|
231
|
+
true, // belongsToRoute
|
|
232
|
+
{
|
|
233
|
+
clientSegmentIds: ctx.clientSegmentSet,
|
|
234
|
+
prevParams: ctx.prevParams,
|
|
235
|
+
request: ctx.request,
|
|
236
|
+
prevUrl: ctx.prevUrl,
|
|
237
|
+
nextUrl: ctx.url,
|
|
238
|
+
routeKey: ctx.routeKey,
|
|
239
|
+
actionContext: ctx.actionContext,
|
|
240
|
+
stale: ctx.stale,
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
// Update intercept segment's loaderDataPromise with fresh data
|
|
246
|
+
if (freshLoaderResult) {
|
|
247
|
+
const interceptMainSegment = interceptSegments.find(
|
|
248
|
+
(s) => s.type === "parallel" && s.slot
|
|
249
|
+
);
|
|
250
|
+
if (interceptMainSegment) {
|
|
251
|
+
interceptMainSegment.loaderDataPromise = freshLoaderResult.loaderDataPromise;
|
|
252
|
+
interceptMainSegment.loaderIds = freshLoaderResult.loaderIds;
|
|
253
|
+
console.log(
|
|
254
|
+
`[Router.matchPartial] Cache HIT + fresh loaders for intercept "${ctx.localRouteName}" -> slot "${slotName}"`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
console.log(
|
|
259
|
+
`[Router.matchPartial] Cache HIT for intercept "${ctx.localRouteName}" -> slot "${slotName}" (no loader revalidation)`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
state.slots[slotName] = {
|
|
265
|
+
active: true,
|
|
266
|
+
segments: interceptSegments,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Segment Resolution Middleware
|
|
3
|
+
*
|
|
4
|
+
* Resolves route segments when cache misses. Skips if cache hit.
|
|
5
|
+
*
|
|
6
|
+
* FLOW DIAGRAM
|
|
7
|
+
* ============
|
|
8
|
+
*
|
|
9
|
+
* source (from cache-lookup)
|
|
10
|
+
* |
|
|
11
|
+
* v
|
|
12
|
+
* +---------------------------+
|
|
13
|
+
* | Iterate source first! | <-- CRITICAL: Must drain source
|
|
14
|
+
* | yield* source | to let cache-lookup run
|
|
15
|
+
* +---------------------------+
|
|
16
|
+
* |
|
|
17
|
+
* v
|
|
18
|
+
* +---------------------+
|
|
19
|
+
* | state.cacheHit? |──yes──> return (cache already yielded)
|
|
20
|
+
* +---------------------+
|
|
21
|
+
* | no
|
|
22
|
+
* v
|
|
23
|
+
* +---------------------+
|
|
24
|
+
* | isFullMatch? |
|
|
25
|
+
* +---------------------+
|
|
26
|
+
* |
|
|
27
|
+
* +-----+-----+
|
|
28
|
+
* | |
|
|
29
|
+
* yes no
|
|
30
|
+
* | |
|
|
31
|
+
* v v
|
|
32
|
+
* resolveAll resolveAllWithRevalidation
|
|
33
|
+
* Segments Segments
|
|
34
|
+
* | |
|
|
35
|
+
* | | (compares with prev state)
|
|
36
|
+
* | | (handles null components)
|
|
37
|
+
* | |
|
|
38
|
+
* +-----------+
|
|
39
|
+
* |
|
|
40
|
+
* v
|
|
41
|
+
* +---------------------------+
|
|
42
|
+
* | Update state: |
|
|
43
|
+
* | - state.segments |
|
|
44
|
+
* | - state.matchedIds |
|
|
45
|
+
* +---------------------------+
|
|
46
|
+
* |
|
|
47
|
+
* v
|
|
48
|
+
* yield all resolved segments
|
|
49
|
+
* |
|
|
50
|
+
* v
|
|
51
|
+
* next middleware
|
|
52
|
+
*
|
|
53
|
+
*
|
|
54
|
+
* RESOLUTION MODES
|
|
55
|
+
* ================
|
|
56
|
+
*
|
|
57
|
+
* Full Match (document request):
|
|
58
|
+
* - Uses resolveAllSegments()
|
|
59
|
+
* - No revalidation logic (nothing to compare against)
|
|
60
|
+
* - Simple resolution of all route entries
|
|
61
|
+
*
|
|
62
|
+
* Partial Match (navigation):
|
|
63
|
+
* - Uses resolveAllSegmentsWithRevalidation()
|
|
64
|
+
* - Compares current vs previous params/URL
|
|
65
|
+
* - Sets component = null for segments client already has
|
|
66
|
+
* - Respects custom revalidation rules
|
|
67
|
+
*
|
|
68
|
+
*
|
|
69
|
+
* CRITICAL: SOURCE ITERATION
|
|
70
|
+
* ==========================
|
|
71
|
+
*
|
|
72
|
+
* The middleware MUST iterate the source generator before checking cacheHit:
|
|
73
|
+
*
|
|
74
|
+
* for await (const segment of source) { yield segment; }
|
|
75
|
+
*
|
|
76
|
+
* This is because:
|
|
77
|
+
* 1. Generator middleware are lazy (don't execute until iterated)
|
|
78
|
+
* 2. cache-lookup sets state.cacheHit during iteration
|
|
79
|
+
* 3. Without draining source first, cache-lookup never runs
|
|
80
|
+
*
|
|
81
|
+
* Incorrect pattern:
|
|
82
|
+
* if (!state.cacheHit) { ... } // cacheHit still false!
|
|
83
|
+
* yield* source; // Too late, already resolved
|
|
84
|
+
*
|
|
85
|
+
* Correct pattern:
|
|
86
|
+
* yield* source; // Let cache-lookup set cacheHit
|
|
87
|
+
* if (state.cacheHit) return; // Now we can check
|
|
88
|
+
*/
|
|
89
|
+
import type { ResolvedSegment } from "../../types.js";
|
|
90
|
+
import type { MatchContext, MatchPipelineState } from "../match-context.js";
|
|
91
|
+
import { getRouterContext } from "../router-context.js";
|
|
92
|
+
import type { GeneratorMiddleware } from "./cache-lookup.js";
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Creates segment resolution middleware
|
|
96
|
+
*
|
|
97
|
+
* Only runs on cache miss (state.cacheHit === false).
|
|
98
|
+
* Uses resolveAllSegmentsWithRevalidation from RouterContext to resolve segments.
|
|
99
|
+
*/
|
|
100
|
+
export function withSegmentResolution<TEnv>(
|
|
101
|
+
ctx: MatchContext<TEnv>,
|
|
102
|
+
state: MatchPipelineState
|
|
103
|
+
): GeneratorMiddleware<ResolvedSegment> {
|
|
104
|
+
return async function* (
|
|
105
|
+
source: AsyncGenerator<ResolvedSegment>
|
|
106
|
+
): AsyncGenerator<ResolvedSegment> {
|
|
107
|
+
// IMPORTANT: Always iterate source first to give cache-lookup a chance
|
|
108
|
+
// to run and set state.cacheHit. Without this, cache-lookup never executes!
|
|
109
|
+
for await (const segment of source) {
|
|
110
|
+
yield segment;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// If cache hit, segments were already yielded by cache lookup
|
|
114
|
+
if (state.cacheHit) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { resolveAllSegmentsWithRevalidation, resolveAllSegments } =
|
|
119
|
+
getRouterContext<TEnv>();
|
|
120
|
+
|
|
121
|
+
const Store = ctx.Store;
|
|
122
|
+
|
|
123
|
+
if (ctx.isFullMatch) {
|
|
124
|
+
// Full match (document request) - simple resolution without revalidation
|
|
125
|
+
const segments = await Store.run(() =>
|
|
126
|
+
resolveAllSegments(
|
|
127
|
+
ctx.entries,
|
|
128
|
+
ctx.routeKey,
|
|
129
|
+
ctx.matched.params,
|
|
130
|
+
ctx.handlerContext,
|
|
131
|
+
ctx.loaderPromises
|
|
132
|
+
)
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// Update state with resolved segments
|
|
136
|
+
state.segments = segments;
|
|
137
|
+
state.matchedIds = segments.map((s) => s.id);
|
|
138
|
+
|
|
139
|
+
// Yield all resolved segments
|
|
140
|
+
for (const segment of segments) {
|
|
141
|
+
yield segment;
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
// Partial match (navigation) - resolution with revalidation logic
|
|
145
|
+
const result = await Store.run(() =>
|
|
146
|
+
resolveAllSegmentsWithRevalidation(
|
|
147
|
+
ctx.entries,
|
|
148
|
+
ctx.routeKey,
|
|
149
|
+
ctx.matched.params,
|
|
150
|
+
ctx.handlerContext,
|
|
151
|
+
ctx.clientSegmentSet,
|
|
152
|
+
ctx.prevParams,
|
|
153
|
+
ctx.request,
|
|
154
|
+
ctx.prevUrl,
|
|
155
|
+
ctx.url,
|
|
156
|
+
ctx.loaderPromises,
|
|
157
|
+
ctx.actionContext,
|
|
158
|
+
ctx.interceptResult,
|
|
159
|
+
ctx.localRouteName,
|
|
160
|
+
ctx.pathname
|
|
161
|
+
)
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Update state with resolved segments
|
|
165
|
+
state.segments = result.segments;
|
|
166
|
+
state.matchedIds = result.matchedIds;
|
|
167
|
+
|
|
168
|
+
// Yield all resolved segments
|
|
169
|
+
for (const segment of result.segments) {
|
|
170
|
+
yield segment;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Match Pipelines
|
|
3
|
+
*
|
|
4
|
+
* Composes async generator middleware into pipelines for route matching.
|
|
5
|
+
* The pipeline transforms navigation requests into resolved UI segments.
|
|
6
|
+
*
|
|
7
|
+
* PIPELINE ARCHITECTURE OVERVIEW
|
|
8
|
+
* ==============================
|
|
9
|
+
*
|
|
10
|
+
* The router uses a pipeline of async generator middleware to process requests.
|
|
11
|
+
* Each middleware can:
|
|
12
|
+
* 1. Produce segments (yield)
|
|
13
|
+
* 2. Transform segments from upstream
|
|
14
|
+
* 3. Observe segments without modifying them
|
|
15
|
+
* 4. Trigger side effects (caching, background revalidation)
|
|
16
|
+
*
|
|
17
|
+
* REQUEST FLOW DIAGRAM
|
|
18
|
+
* ====================
|
|
19
|
+
*
|
|
20
|
+
* Navigation Request
|
|
21
|
+
* |
|
|
22
|
+
* v
|
|
23
|
+
* +------------------+
|
|
24
|
+
* | Create Context | MatchContext: routes, params, client state
|
|
25
|
+
* +------------------+
|
|
26
|
+
* |
|
|
27
|
+
* v
|
|
28
|
+
* +------------------+
|
|
29
|
+
* | Select Pipeline | Full (document) vs Partial (navigation)
|
|
30
|
+
* +------------------+
|
|
31
|
+
* |
|
|
32
|
+
* v
|
|
33
|
+
* ==================== PIPELINE EXECUTION ====================
|
|
34
|
+
* | |
|
|
35
|
+
* | empty() ─────> [1] ─────> [2] ─────> [3] ─────> [4] ───>|───> segments
|
|
36
|
+
* | | | | | | |
|
|
37
|
+
* | | cache | segment |intercept | cache | bg |
|
|
38
|
+
* | | lookup | resolve | resolve | store | reval |
|
|
39
|
+
* | |
|
|
40
|
+
* ============================================================
|
|
41
|
+
* |
|
|
42
|
+
* v
|
|
43
|
+
* +------------------+
|
|
44
|
+
* | Collect Result | Filter segments, build MatchResult
|
|
45
|
+
* +------------------+
|
|
46
|
+
* |
|
|
47
|
+
* v
|
|
48
|
+
* RSC Stream Response
|
|
49
|
+
*
|
|
50
|
+
*
|
|
51
|
+
* MIDDLEWARE EXECUTION ORDER
|
|
52
|
+
* ==========================
|
|
53
|
+
*
|
|
54
|
+
* Middleware compose in reverse order (rightmost = innermost, runs first):
|
|
55
|
+
*
|
|
56
|
+
* compose(A, B, C)(source) => source -> C -> B -> A -> output
|
|
57
|
+
*
|
|
58
|
+
* For the partial match pipeline:
|
|
59
|
+
*
|
|
60
|
+
* compose(
|
|
61
|
+
* withBackgroundRevalidation, // [5] Outermost - triggers SWR
|
|
62
|
+
* withCacheStore, // [4] Stores segments in cache
|
|
63
|
+
* withInterceptResolution, // [3] Resolves intercept segments
|
|
64
|
+
* withSegmentResolution, // [2] Resolves on cache miss
|
|
65
|
+
* withCacheLookup // [1] Innermost - checks cache first
|
|
66
|
+
* )
|
|
67
|
+
*
|
|
68
|
+
* Execution flow for cache MISS:
|
|
69
|
+
*
|
|
70
|
+
* empty() yields nothing
|
|
71
|
+
* -> [1] cache-lookup: no cache, passes through
|
|
72
|
+
* -> [2] segment-resolution: resolves segments, yields them
|
|
73
|
+
* -> [3] intercept-resolution: resolves intercepts, yields them
|
|
74
|
+
* -> [4] cache-store: observes all, stores in cache
|
|
75
|
+
* -> [5] bg-revalidation: no-op (wasn't stale)
|
|
76
|
+
* -> output: all segments
|
|
77
|
+
*
|
|
78
|
+
* Execution flow for cache HIT (stale):
|
|
79
|
+
*
|
|
80
|
+
* empty() yields nothing
|
|
81
|
+
* -> [1] cache-lookup: HIT! yields cached segments + fresh loaders
|
|
82
|
+
* -> [2] segment-resolution: sees cacheHit=true, skips
|
|
83
|
+
* -> [3] intercept-resolution: extracts intercepts from cache
|
|
84
|
+
* -> [4] cache-store: sees cacheHit=true, skips
|
|
85
|
+
* -> [5] bg-revalidation: triggers waitUntil() to revalidate
|
|
86
|
+
* -> output: cached segments + fresh loader data
|
|
87
|
+
*
|
|
88
|
+
*
|
|
89
|
+
* TWO PIPELINE VARIANTS
|
|
90
|
+
* =====================
|
|
91
|
+
*
|
|
92
|
+
* 1. createMatchPipeline (Full Match)
|
|
93
|
+
* - Used for document requests (initial page load)
|
|
94
|
+
* - No revalidation logic (no previous state to compare)
|
|
95
|
+
* - Simpler segment resolution
|
|
96
|
+
*
|
|
97
|
+
* 2. createMatchPartialPipeline (Partial Match)
|
|
98
|
+
* - Used for client-side navigation
|
|
99
|
+
* - Includes revalidation for SWR
|
|
100
|
+
* - Compares with previous params/URL
|
|
101
|
+
* - Supports intercepts (soft navigation modals)
|
|
102
|
+
*/
|
|
103
|
+
import type { ResolvedSegment } from "../types.js";
|
|
104
|
+
import type { MatchContext, MatchPipelineState } from "./match-context.js";
|
|
105
|
+
import type { GeneratorMiddleware } from "./match-middleware/index.js";
|
|
106
|
+
import {
|
|
107
|
+
withBackgroundRevalidation,
|
|
108
|
+
withCacheLookup,
|
|
109
|
+
withCacheStore,
|
|
110
|
+
withInterceptResolution,
|
|
111
|
+
withSegmentResolution,
|
|
112
|
+
} from "./match-middleware/index.js";
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Compose multiple async generator middleware into a single middleware
|
|
116
|
+
*
|
|
117
|
+
* Middleware are applied in reverse order (rightmost runs first, innermost).
|
|
118
|
+
* For the pipeline:
|
|
119
|
+
* compose(A, B, C)(source)
|
|
120
|
+
*
|
|
121
|
+
* The flow is: source -> C -> B -> A -> output
|
|
122
|
+
* Where C is the innermost (runs first on input) and A is outermost (runs last).
|
|
123
|
+
*/
|
|
124
|
+
export function compose<T>(
|
|
125
|
+
...middleware: GeneratorMiddleware<T>[]
|
|
126
|
+
): GeneratorMiddleware<T> {
|
|
127
|
+
if (middleware.length === 0) {
|
|
128
|
+
return (source) => source;
|
|
129
|
+
}
|
|
130
|
+
if (middleware.length === 1) {
|
|
131
|
+
return middleware[0];
|
|
132
|
+
}
|
|
133
|
+
return (source) => {
|
|
134
|
+
// Apply middleware in reverse order (rightmost first)
|
|
135
|
+
return middleware.reduceRight((prev, fn) => fn(prev), source);
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Create an empty async generator (source for pipeline)
|
|
141
|
+
*/
|
|
142
|
+
export async function* empty<T>(): AsyncGenerator<T> {
|
|
143
|
+
// Yields nothing - used as the initial source for the pipeline
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Create the match partial pipeline
|
|
148
|
+
*
|
|
149
|
+
* Pipeline order (innermost to outermost):
|
|
150
|
+
* 1. cache-lookup - Check cache first, yield cached segments if hit
|
|
151
|
+
* 2. segment-resolution - Resolve segments if cache miss
|
|
152
|
+
* 3. intercept-resolution - Resolve intercept segments
|
|
153
|
+
* 4. cache-store - Store segments in cache
|
|
154
|
+
* 5. background-revalidation - Trigger SWR if cache was stale
|
|
155
|
+
*
|
|
156
|
+
* Data flow:
|
|
157
|
+
* - empty() produces no segments
|
|
158
|
+
* - cache-lookup either yields cached segments OR passes through to segment-resolution
|
|
159
|
+
* - segment-resolution resolves fresh segments on cache miss
|
|
160
|
+
* - intercept-resolution adds intercept segments
|
|
161
|
+
* - cache-store observes and caches segments
|
|
162
|
+
* - background-revalidation triggers SWR revalidation if needed
|
|
163
|
+
*/
|
|
164
|
+
export function createMatchPartialPipeline<TEnv>(
|
|
165
|
+
ctx: MatchContext<TEnv>,
|
|
166
|
+
state: MatchPipelineState
|
|
167
|
+
): AsyncGenerator<ResolvedSegment> {
|
|
168
|
+
// Build the middleware chain
|
|
169
|
+
const pipeline = compose<ResolvedSegment>(
|
|
170
|
+
// Outermost - observes segments and triggers background revalidation
|
|
171
|
+
withBackgroundRevalidation(ctx, state),
|
|
172
|
+
// Observes and stores segments in cache
|
|
173
|
+
withCacheStore(ctx, state),
|
|
174
|
+
// Adds intercept segments after main segments
|
|
175
|
+
withInterceptResolution(ctx, state),
|
|
176
|
+
// Resolves segments on cache miss
|
|
177
|
+
withSegmentResolution(ctx, state),
|
|
178
|
+
// Innermost - checks cache first
|
|
179
|
+
withCacheLookup(ctx, state)
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// Start with empty source - cache lookup or segment resolution will produce segments
|
|
183
|
+
return pipeline(empty());
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Create the full match pipeline (simpler, no revalidation)
|
|
188
|
+
*
|
|
189
|
+
* Used for document requests (initial page load) where we don't need
|
|
190
|
+
* revalidation logic since there's no previous state to compare against.
|
|
191
|
+
*/
|
|
192
|
+
export function createMatchPipeline<TEnv>(
|
|
193
|
+
ctx: MatchContext<TEnv>,
|
|
194
|
+
state: MatchPipelineState
|
|
195
|
+
): AsyncGenerator<ResolvedSegment> {
|
|
196
|
+
// For full match, we only need:
|
|
197
|
+
// 1. Cache lookup
|
|
198
|
+
// 2. Segment resolution (without revalidation)
|
|
199
|
+
// 3. Intercept resolution
|
|
200
|
+
// 4. Cache store
|
|
201
|
+
|
|
202
|
+
// Note: Full match uses different resolution logic (resolveAllSegments instead of
|
|
203
|
+
// resolveAllSegmentsWithRevalidation). This will be handled by the segment resolution
|
|
204
|
+
// middleware checking ctx.isFullMatch or similar flag.
|
|
205
|
+
|
|
206
|
+
const pipeline = compose<ResolvedSegment>(
|
|
207
|
+
withCacheStore(ctx, state),
|
|
208
|
+
withInterceptResolution(ctx, state),
|
|
209
|
+
withSegmentResolution(ctx, state),
|
|
210
|
+
withCacheLookup(ctx, state)
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
return pipeline(empty());
|
|
214
|
+
}
|