@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,565 @@
1
+ /**
2
+ * CacheScope - Runtime cache scope for iterator-based caching
3
+ *
4
+ * Each cache() boundary in the route tree creates a new CacheScope.
5
+ * The scope owns: config, serialization, and storage operations.
6
+ */
7
+
8
+ /// <reference types="@vitejs/plugin-rsc/types" />
9
+
10
+ import type { PartialCacheOptions } from "../types.js";
11
+ import type { ResolvedSegment } from "../types.js";
12
+ import type {
13
+ SegmentCacheStore,
14
+ SegmentHandleData,
15
+ CachedEntryData,
16
+ SerializedSegmentData,
17
+ } from "./types.js";
18
+ import { getRequestContext } from "../server/request-context.js";
19
+ import {
20
+ renderToReadableStream,
21
+ createTemporaryReferenceSet,
22
+ } from "@vitejs/plugin-rsc/rsc";
23
+ import { createFromReadableStream } from "@vitejs/plugin-rsc/rsc";
24
+
25
+ // ============================================================================
26
+ // Constants
27
+ // ============================================================================
28
+
29
+ /** Default TTL when no explicit value or store defaults are configured */
30
+ const DEFAULT_TTL_SECONDS = 60;
31
+
32
+ // ============================================================================
33
+ // Serialization Utilities (internal)
34
+ // ============================================================================
35
+
36
+ /**
37
+ * Generate cache key base from pathname and params.
38
+ * Params are sorted alphabetically for consistent key generation.
39
+ * @internal
40
+ */
41
+ function getCacheKeyBase(
42
+ pathname: string,
43
+ params?: Record<string, string>
44
+ ): string {
45
+ const paramStr = params
46
+ ? Object.entries(params)
47
+ .sort(([a], [b]) => a.localeCompare(b))
48
+ .map(([k, v]) => `${k}=${v}`)
49
+ .join("&")
50
+ : "";
51
+
52
+ return paramStr ? `${pathname}:${paramStr}` : pathname;
53
+ }
54
+
55
+ /**
56
+ * Generate default cache key for a route request.
57
+ * Single cache entry per route - uses pathname as the key.
58
+ * Includes request type prefix since they produce different segment sets:
59
+ * - doc: document requests (full page load)
60
+ * - partial: navigation requests (client-side navigation)
61
+ * - intercept: intercept navigation (modal/overlay routes)
62
+ * @internal
63
+ */
64
+ function getDefaultRouteCacheKey(
65
+ pathname: string,
66
+ params?: Record<string, string>,
67
+ isIntercept?: boolean
68
+ ): string {
69
+ const ctx = getRequestContext();
70
+ const isPartial = ctx?.url.searchParams.has("_rsc_partial") ?? false;
71
+
72
+ // Intercept navigations get their own cache namespace
73
+ const prefix = isIntercept ? "intercept" : isPartial ? "partial" : "doc";
74
+
75
+ return `${prefix}:${getCacheKeyBase(pathname, params)}`;
76
+ }
77
+
78
+ /**
79
+ * Convert a ReadableStream to a string.
80
+ * @internal
81
+ */
82
+ async function streamToString(
83
+ stream: ReadableStream<Uint8Array>
84
+ ): Promise<string> {
85
+ const reader = stream.getReader();
86
+ const decoder = new TextDecoder();
87
+ let result = "";
88
+
89
+ while (true) {
90
+ const { done, value } = await reader.read();
91
+ if (done) break;
92
+ result += decoder.decode(value, { stream: true });
93
+ }
94
+
95
+ result += decoder.decode(); // flush
96
+ return result;
97
+ }
98
+
99
+ /**
100
+ * Convert a string to a ReadableStream.
101
+ * @internal
102
+ */
103
+ function stringToStream(str: string): ReadableStream<Uint8Array> {
104
+ const encoder = new TextEncoder();
105
+ const uint8 = encoder.encode(str);
106
+
107
+ return new ReadableStream({
108
+ start(controller) {
109
+ controller.enqueue(uint8);
110
+ controller.close();
111
+ },
112
+ });
113
+ }
114
+
115
+ /**
116
+ * RSC-serialize a value using React Server Components stream.
117
+ * Used for serializing loaderData, layout, loading components etc.
118
+ * @internal
119
+ */
120
+ async function rscSerialize(value: unknown): Promise<string | undefined> {
121
+ if (value === undefined || value === null) return undefined;
122
+
123
+ const temporaryReferences = createTemporaryReferenceSet();
124
+ const stream = renderToReadableStream(value, { temporaryReferences });
125
+ return streamToString(stream);
126
+ }
127
+
128
+ /**
129
+ * RSC-deserialize a value from a stored string.
130
+ * @internal
131
+ */
132
+ async function rscDeserialize<T>(
133
+ encoded: string | undefined
134
+ ): Promise<T | undefined> {
135
+ if (!encoded) return undefined;
136
+
137
+ const temporaryReferences = createTemporaryReferenceSet();
138
+ const stream = stringToStream(encoded);
139
+ return createFromReadableStream<T>(stream, { temporaryReferences });
140
+ }
141
+
142
+ /**
143
+ * Serialize segments for storage.
144
+ * Each segment's component, layout, loading, and loaderData are RSC-serialized.
145
+ * Metadata is preserved as-is.
146
+ * @internal
147
+ */
148
+ async function serializeSegments(
149
+ segments: ResolvedSegment[]
150
+ ): Promise<SerializedSegmentData[]> {
151
+ const serialized: SerializedSegmentData[] = [];
152
+
153
+ for (const segment of segments) {
154
+ const temporaryReferences = createTemporaryReferenceSet();
155
+
156
+ // Await component if it's a Promise (intercepts with loading keep component as Promise)
157
+ const componentResolved =
158
+ segment.component instanceof Promise
159
+ ? await segment.component
160
+ : segment.component;
161
+
162
+ // Serialize the component to RSC stream
163
+ const stream = renderToReadableStream(componentResolved, {
164
+ temporaryReferences,
165
+ });
166
+
167
+ // Convert stream to string
168
+ const encoded = await streamToString(stream);
169
+
170
+ // RSC-serialize layout if present (ReactNode)
171
+ const encodedLayout = segment.layout
172
+ ? await rscSerialize(segment.layout)
173
+ : undefined;
174
+
175
+ // RSC-serialize loading if present (ReactNode) - preserves tree structure
176
+ // Use "null" string to distinguish explicit null from undefined
177
+ const encodedLoading =
178
+ segment.loading !== undefined
179
+ ? segment.loading === null
180
+ ? "null"
181
+ : await rscSerialize(segment.loading)
182
+ : undefined;
183
+
184
+ // Await and RSC-serialize loaderData if present
185
+ const loaderDataResolved =
186
+ segment.loaderData instanceof Promise
187
+ ? await segment.loaderData
188
+ : segment.loaderData;
189
+ const encodedLoaderData = await rscSerialize(loaderDataResolved);
190
+
191
+ // Await and RSC-serialize loaderDataPromise if present
192
+ const loaderDataPromiseResolved =
193
+ segment.loaderDataPromise instanceof Promise
194
+ ? await segment.loaderDataPromise
195
+ : segment.loaderDataPromise;
196
+ const encodedLoaderDataPromise = await rscSerialize(
197
+ loaderDataPromiseResolved
198
+ );
199
+
200
+ serialized.push({
201
+ encoded,
202
+ encodedLayout,
203
+ encodedLoading,
204
+ encodedLoaderData,
205
+ encodedLoaderDataPromise,
206
+ metadata: {
207
+ id: segment.id,
208
+ type: segment.type,
209
+ namespace: segment.namespace,
210
+ index: segment.index,
211
+ params: segment.params,
212
+ slot: segment.slot,
213
+ belongsToRoute: segment.belongsToRoute,
214
+ layoutName: segment.layoutName,
215
+ parallelName: segment.parallelName,
216
+ loaderId: segment.loaderId,
217
+ loaderIds: segment.loaderIds,
218
+ },
219
+ });
220
+ }
221
+
222
+ return serialized;
223
+ }
224
+
225
+ /**
226
+ * Deserialize segments from storage.
227
+ * Reconstructs ResolvedSegment objects from RSC-serialized data.
228
+ * @internal
229
+ */
230
+ async function deserializeSegments(
231
+ data: SerializedSegmentData[]
232
+ ): Promise<ResolvedSegment[]> {
233
+ const segments: ResolvedSegment[] = [];
234
+
235
+ for (const item of data) {
236
+ const temporaryReferences = createTemporaryReferenceSet();
237
+
238
+ // Revive the component from cached string
239
+ const stream = stringToStream(item.encoded);
240
+ const component = await createFromReadableStream(stream, {
241
+ temporaryReferences,
242
+ });
243
+
244
+ // RSC-deserialize layout, loaderData, loaderDataPromise in parallel
245
+ const [layout, loaderData, loaderDataPromise, loadingData] =
246
+ await Promise.all([
247
+ rscDeserialize(item.encodedLayout),
248
+ rscDeserialize(item.encodedLoaderData),
249
+ rscDeserialize(item.encodedLoaderDataPromise),
250
+ rscDeserialize(item.encodedLoading),
251
+ ]);
252
+
253
+ segments.push({
254
+ ...item.metadata,
255
+ component: await component,
256
+ layout,
257
+ loading: loadingData,
258
+ loaderData,
259
+ loaderDataPromise,
260
+ } as ResolvedSegment);
261
+ }
262
+
263
+ return segments;
264
+ }
265
+
266
+ // ============================================================================
267
+ // CacheScope
268
+ // ============================================================================
269
+
270
+ /**
271
+ * CacheScope represents a cache boundary in the route tree.
272
+ *
273
+ * When withCache encounters an entry with cache config, it creates
274
+ * a new CacheScope. The scope owns serialization, storage, and TTL.
275
+ *
276
+ * Store resolution priority:
277
+ * 1. Explicit store in cache() options
278
+ * 2. App-level store from handler config
279
+ *
280
+ * TTL resolution priority:
281
+ * 1. Explicit value in cache() options
282
+ * 2. Explicit store's defaults (if store specified)
283
+ * 3. App-level store's defaults
284
+ * 4. Hardcoded fallback (60 seconds)
285
+ */
286
+ export class CacheScope {
287
+ readonly config: PartialCacheOptions | false;
288
+ readonly parent: CacheScope | null;
289
+ /** Explicit store from cache() options, if specified */
290
+ private readonly explicitStore: SegmentCacheStore | undefined;
291
+
292
+ constructor(
293
+ config: PartialCacheOptions | false,
294
+ parent: CacheScope | null = null
295
+ ) {
296
+ this.config = config;
297
+ this.parent = parent;
298
+ // Extract and store explicit store reference
299
+ this.explicitStore = config !== false ? config.store : undefined;
300
+ }
301
+
302
+ /**
303
+ * Whether caching is enabled for this scope
304
+ */
305
+ get enabled(): boolean {
306
+ return this.config !== false;
307
+ }
308
+
309
+ /**
310
+ * Get effective TTL from config or store defaults
311
+ */
312
+ get ttl(): number {
313
+ if (this.config === false) return 0;
314
+
315
+ // Explicit TTL in cache() options
316
+ if (this.config.ttl !== undefined) {
317
+ return this.config.ttl;
318
+ }
319
+
320
+ // Fall back to store defaults (explicit store first, then app-level)
321
+ const store = this.getStore();
322
+ if (store?.defaults?.ttl !== undefined) {
323
+ return store.defaults.ttl;
324
+ }
325
+
326
+ // Hardcoded fallback
327
+ return DEFAULT_TTL_SECONDS;
328
+ }
329
+
330
+ /**
331
+ * Get SWR window from config or store defaults
332
+ */
333
+ get swr(): number | undefined {
334
+ if (this.config === false) return undefined;
335
+
336
+ // Explicit SWR in cache() options
337
+ if (this.config.swr !== undefined) {
338
+ return this.config.swr;
339
+ }
340
+
341
+ // Fall back to store defaults
342
+ const store = this.getStore();
343
+ return store?.defaults?.swr;
344
+ }
345
+
346
+ /**
347
+ * Get the cache store - resolution priority:
348
+ * 1. Explicit store from cache() options
349
+ * 2. App-level store from request context
350
+ */
351
+ private getStore(): SegmentCacheStore | null {
352
+ // Explicit store from cache() options takes precedence
353
+ if (this.explicitStore) {
354
+ return this.explicitStore;
355
+ }
356
+ // Fall back to app-level store from request context
357
+ const ctx = getRequestContext();
358
+ return ctx?._cacheStore ?? null;
359
+ }
360
+
361
+ /**
362
+ * Resolve the cache key using custom key functions or default generation.
363
+ *
364
+ * Resolution priority:
365
+ * 1. Route-level `key` function (full override)
366
+ * 2. Store-level `keyGenerator` (modifies default key)
367
+ * 3. Default key generation (prefix:pathname:params)
368
+ *
369
+ * @internal
370
+ */
371
+ private async resolveKey(
372
+ pathname: string,
373
+ params: Record<string, string>,
374
+ isIntercept?: boolean
375
+ ): Promise<string> {
376
+ const requestCtx = getRequestContext();
377
+ if (!requestCtx) {
378
+ // Fallback to default key if no request context
379
+ return getDefaultRouteCacheKey(pathname, params, isIntercept);
380
+ }
381
+
382
+ // Priority 1: Route-level key function (full override)
383
+ if (this.config !== false && this.config.key) {
384
+ try {
385
+ const customKey = await this.config.key(requestCtx);
386
+ return customKey;
387
+ } catch (error) {
388
+ console.error(`[CacheScope] Custom key function failed, using default:`, error);
389
+ return getDefaultRouteCacheKey(pathname, params, isIntercept);
390
+ }
391
+ }
392
+
393
+ // Generate default key
394
+ const defaultKey = getDefaultRouteCacheKey(pathname, params, isIntercept);
395
+
396
+ // Priority 2: Store-level keyGenerator (modifies default key)
397
+ const store = this.getStore();
398
+ if (store?.keyGenerator) {
399
+ try {
400
+ const modifiedKey = await store.keyGenerator(requestCtx, defaultKey);
401
+ return modifiedKey;
402
+ } catch (error) {
403
+ console.error(`[CacheScope] Store keyGenerator failed, using default:`, error);
404
+ return defaultKey;
405
+ }
406
+ }
407
+
408
+ // Priority 3: Default key
409
+ return defaultKey;
410
+ }
411
+
412
+ /**
413
+ * Lookup cached segments for a route (single cache entry per request).
414
+ * Returns { segments, shouldRevalidate } or null if cache miss.
415
+ *
416
+ * @param pathname - URL pathname for cache key generation
417
+ * @param params - Route params for cache key generation
418
+ * @param isIntercept - Whether this is an intercept navigation (uses different cache key)
419
+ */
420
+ async lookupRoute(
421
+ pathname: string,
422
+ params: Record<string, string>,
423
+ isIntercept?: boolean
424
+ ): Promise<{
425
+ segments: ResolvedSegment[];
426
+ shouldRevalidate: boolean;
427
+ } | null> {
428
+ if (!this.enabled) return null;
429
+
430
+ const store = this.getStore();
431
+ if (!store) return null;
432
+
433
+ // Resolve cache key (may use custom key functions)
434
+ const key = await this.resolveKey(pathname, params, isIntercept);
435
+
436
+ try {
437
+ const result = await store.get(key);
438
+
439
+ if (!result) {
440
+ console.log(`[CacheScope] MISS: ${key}`);
441
+ return null;
442
+ }
443
+
444
+ const { data: cached, shouldRevalidate } = result;
445
+
446
+ // Deserialize segments
447
+ const segments = await deserializeSegments(cached.segments);
448
+
449
+ // Replay handle data
450
+ const handleStore = getRequestContext()?._handleStore;
451
+ if (handleStore) {
452
+ for (const [segId, segHandles] of Object.entries(cached.handles)) {
453
+ if (Object.keys(segHandles).length > 0) {
454
+ handleStore.replaySegmentData(segId, segHandles);
455
+ }
456
+ }
457
+ }
458
+
459
+ const segmentTypes = segments.map((s) =>
460
+ s.type === "parallel" ? s.slot : s.type
461
+ );
462
+ console.log(
463
+ `[CacheScope] ${shouldRevalidate ? "STALE" : "HIT"}: ${key} (${segmentTypes.join(", ")})`
464
+ );
465
+
466
+ return { segments, shouldRevalidate };
467
+ } catch (error) {
468
+ console.error(`[CacheScope] Failed to lookup ${key}:`, error);
469
+ return null;
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Cache all segments for a route (non-blocking via waitUntil)
475
+ * Single cache entry per route request.
476
+ * Loaders are excluded - they're always fresh unless they have their own cache() config.
477
+ *
478
+ * @param pathname - URL pathname for cache key generation
479
+ * @param params - Route params for cache key generation
480
+ * @param segments - All resolved segments to cache
481
+ * @param isIntercept - Whether this is an intercept navigation (uses different cache key)
482
+ */
483
+ async cacheRoute(
484
+ pathname: string,
485
+ params: Record<string, string>,
486
+ segments: ResolvedSegment[],
487
+ isIntercept?: boolean
488
+ ): Promise<void> {
489
+ if (!this.enabled || segments.length === 0) return;
490
+
491
+ const store = this.getStore();
492
+ if (!store) return;
493
+
494
+ const requestCtx = getRequestContext();
495
+ const handleStore = requestCtx?._handleStore;
496
+
497
+ if (!handleStore || !requestCtx) return;
498
+
499
+ // Exclude loader segments - loaders are always fresh by default
500
+ // Loaders can opt-in to caching with their own cache() config
501
+ const nonLoaderSegments = segments.filter((s) => s.type !== "loader");
502
+ if (nonLoaderSegments.length === 0) return;
503
+
504
+ const ttl = this.ttl;
505
+ const swr = this.swr;
506
+
507
+ // Resolve cache key early (while request context is available)
508
+ const key = await this.resolveKey(pathname, params, isIntercept);
509
+
510
+ // Check if this is a partial request (navigation) vs document request
511
+ const isPartial = requestCtx.url.searchParams.has("_rsc_partial");
512
+
513
+ requestCtx.waitUntil(async () => {
514
+ await handleStore.settled;
515
+
516
+ // For document requests: only cache if ALL segments have components (complete render)
517
+ // For partial requests: null components are expected (client already has them)
518
+ if (!isPartial) {
519
+ const hasAllComponents = nonLoaderSegments.every(
520
+ (s) => s.component !== null
521
+ );
522
+ if (!hasAllComponents) return;
523
+ }
524
+
525
+ // Collect handle data for non-loader segments only
526
+ const handles: Record<string, SegmentHandleData> = {};
527
+ for (const seg of nonLoaderSegments) {
528
+ handles[seg.id] = handleStore.getDataForSegment(seg.id);
529
+ }
530
+
531
+ try {
532
+ // Serialize non-loader segments only
533
+ const serializedSegments = await serializeSegments(nonLoaderSegments);
534
+
535
+ const data: CachedEntryData = {
536
+ segments: serializedSegments,
537
+ handles,
538
+ expiresAt: Date.now() + ttl * 1000,
539
+ };
540
+
541
+ await store.set(key, data, ttl, swr);
542
+
543
+ const segmentTypes = nonLoaderSegments.map((s) =>
544
+ s.type === "parallel" ? s.slot : s.type
545
+ );
546
+ console.log(
547
+ `[CacheScope] Cached: ${key} (${segmentTypes.join(", ")}) ttl=${ttl}s [loaders excluded]`
548
+ );
549
+ } catch (error) {
550
+ console.error(`[CacheScope] Failed to cache ${key}:`, error);
551
+ }
552
+ });
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Create a cache scope from entry's cache config
558
+ */
559
+ export function createCacheScope(
560
+ config: { options: PartialCacheOptions | false } | undefined,
561
+ parent: CacheScope | null = null
562
+ ): CacheScope | null {
563
+ if (!config) return parent; // No config, inherit parent
564
+ return new CacheScope(config.options, parent);
565
+ }