@rangojs/router 0.0.0-experimental.140 → 0.0.0-experimental.141
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/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/ppr/SKILL.md +229 -362
- package/skills/rango/SKILL.md +2 -2
- package/src/cache/cf/cf-cache-store.ts +8 -0
- package/src/cache/index.ts +0 -5
- package/src/cache/shell-snapshot.ts +368 -0
- package/src/cache/types.ts +66 -0
- package/src/cache/vercel/vercel-cache-store.ts +12 -1
- package/src/index.rsc.ts +1 -5
- package/src/index.ts +1 -17
- package/src/rsc/rsc-rendering.ts +279 -89
- package/src/rsc/shell-capture.ts +523 -65
- package/src/rsc/shell-serve.ts +124 -0
- package/src/server/context.ts +7 -0
- package/src/server/request-context.ts +52 -46
- package/src/ssr/index.tsx +38 -6
- package/src/theme/ThemeProvider.tsx +36 -26
- package/src/urls/index.ts +1 -0
- package/src/urls/path-helper.ts +5 -0
- package/src/urls/pattern-types.ts +36 -0
- package/src/cache/shell-cache.ts +0 -386
- package/src/server/live.ts +0 -130
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integrated PPR shell serving (Axis 2, see docs/design/ppr-shell-resume.md).
|
|
3
|
+
*
|
|
4
|
+
* PPR is opt-in per PAGE ROUTE via the `ppr` path option
|
|
5
|
+
* (`path(pattern, Handler, { name, ppr: true | PartialPrerenderProps })`) and the
|
|
6
|
+
* serving logic is INTEGRAL to the render pipeline — there is no middleware to
|
|
7
|
+
* mount. This module owns the config/key/store plumbing the render layer
|
|
8
|
+
* (rsc-rendering.ts) uses at its COMMIT POINT, which sits after the WHOLE
|
|
9
|
+
* middleware chain (global `router.use()` chain AND route DSL `middleware()`,
|
|
10
|
+
* both of which wrap the render pass): any middleware rejection/redirect wins
|
|
11
|
+
* before a single shell byte is written.
|
|
12
|
+
*
|
|
13
|
+
* The shell store is the app-level `createRouter({ cache })` store
|
|
14
|
+
* (`requestCtx._cacheStore`). A store without the `getShell`/`putShell` family
|
|
15
|
+
* degrades a ppr route to axis 1 with a once-per-key warning (the declared
|
|
16
|
+
* intent cannot be honored — unlike an undeclared route, which is silent).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import React from "react";
|
|
20
|
+
import type { EntryData } from "../server/context.js";
|
|
21
|
+
import { sortedSearchString } from "../cache/cache-key-utils.js";
|
|
22
|
+
import type { ShellCacheEntry, SegmentCacheStore } from "../cache/types.js";
|
|
23
|
+
|
|
24
|
+
/** Debug/status header the browser (and e2e assertions) can read: HIT | MISS. */
|
|
25
|
+
export const SHELL_STATUS_HEADER = "x-rango-shell";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default shell ttl (seconds) for `ppr: true` and for a PartialPrerenderProps
|
|
29
|
+
* that omits `ttl`.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_PPR_TTL_SECONDS = 300;
|
|
32
|
+
|
|
33
|
+
/** The route's ppr option normalized to a concrete policy. */
|
|
34
|
+
export interface ResolvedPprConfig {
|
|
35
|
+
ttl: number;
|
|
36
|
+
swr?: number;
|
|
37
|
+
tags?: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Normalize the matched page route's `ppr` path option. Returns null when the
|
|
42
|
+
* route does not declare `ppr` (or declares `ppr: false`) — the caller then does
|
|
43
|
+
* NOTHING: no store read, no capture, no logs. Pure axis 1, zero cost.
|
|
44
|
+
*
|
|
45
|
+
* PPR is a DOCUMENT-level property of the page route; there is no subtree
|
|
46
|
+
* inheritance (declaring it on a layout is not supported — a follow-up).
|
|
47
|
+
*/
|
|
48
|
+
export function resolvePprConfig(
|
|
49
|
+
entry: EntryData | undefined | null,
|
|
50
|
+
): ResolvedPprConfig | null {
|
|
51
|
+
if (!entry || entry.type !== "route") return null;
|
|
52
|
+
const ppr = entry.ppr;
|
|
53
|
+
if (ppr === undefined || ppr === false) return null;
|
|
54
|
+
if (ppr === true) return { ttl: DEFAULT_PPR_TTL_SECONDS };
|
|
55
|
+
return {
|
|
56
|
+
ttl: ppr.ttl ?? DEFAULT_PPR_TTL_SECONDS,
|
|
57
|
+
swr: ppr.swr,
|
|
58
|
+
tags: ppr.tags,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Shell cache key: host + pathname + sorted search + a `:shell` namespace suffix
|
|
64
|
+
* (so it can never collide with a document-cache key; the store further isolates
|
|
65
|
+
* the shell family internally).
|
|
66
|
+
*
|
|
67
|
+
* The key includes the request HOST: in a multi-tenant host-router deployment
|
|
68
|
+
* (one worker, one shared KV/runtime-cache store) a host-less key would serve
|
|
69
|
+
* tenant A's captured shell to tenant B's users.
|
|
70
|
+
*/
|
|
71
|
+
export function buildShellKey(url: URL): string {
|
|
72
|
+
const sorted = sortedSearchString(url.searchParams);
|
|
73
|
+
const searchSuffix = sorted ? `?${sorted}` : "";
|
|
74
|
+
return `${url.host}${url.pathname}${searchSuffix}:shell`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* React version captured at prerender time is the invalidation gate: a stored
|
|
79
|
+
* shell whose reactVersion differs from the running React cannot be resumed (the
|
|
80
|
+
* postponed blob is build-coupled), so it is treated as a miss — the recapture
|
|
81
|
+
* overwrites the same key and the entry otherwise ages out via TTL.
|
|
82
|
+
*/
|
|
83
|
+
export function isValidShellHit(entry: ShellCacheEntry): boolean {
|
|
84
|
+
return entry.reactVersion === React.version;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Decode a base64 prelude back into bytes for stream composition. */
|
|
88
|
+
export function base64ToBytes(b64: string): Uint8Array {
|
|
89
|
+
const binary = atob(b64);
|
|
90
|
+
const bytes = new Uint8Array(binary.length);
|
|
91
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
92
|
+
return bytes;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** True when the store implements the shell entry family. */
|
|
96
|
+
export function hasShellFamily(
|
|
97
|
+
store: SegmentCacheStore | undefined,
|
|
98
|
+
): store is SegmentCacheStore & {
|
|
99
|
+
getShell: NonNullable<SegmentCacheStore["getShell"]>;
|
|
100
|
+
putShell: NonNullable<SegmentCacheStore["putShell"]>;
|
|
101
|
+
} {
|
|
102
|
+
return !!store?.getShell && !!store?.putShell;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Keys already warned about a missing shell store family (once per key). */
|
|
106
|
+
const warnedMissingStore = new Set<string>();
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Warn once per key that a route declared `ppr` but the app-level cache store
|
|
110
|
+
* does not implement the shell family (getShell/putShell), so the route stays on
|
|
111
|
+
* axis 1. Unlike an undeclared route (silent), a declared route that cannot be
|
|
112
|
+
* honored deserves a diagnostic.
|
|
113
|
+
*/
|
|
114
|
+
export function warnShellStoreMissingOnce(key: string): void {
|
|
115
|
+
if (warnedMissingStore.has(key)) return;
|
|
116
|
+
warnedMissingStore.add(key);
|
|
117
|
+
console.warn(
|
|
118
|
+
`[rango] Route for "${key}" declares the ppr path option, but the app-level ` +
|
|
119
|
+
"cache store does not implement the shell family (getShell/putShell), so " +
|
|
120
|
+
"the route is served on axis 1 without a shell. Use MemorySegmentCacheStore, " +
|
|
121
|
+
"CFCacheStore, or VercelCacheStore (or add the family to your custom store) " +
|
|
122
|
+
"via createRouter({ cache }).",
|
|
123
|
+
);
|
|
124
|
+
}
|
package/src/server/context.ts
CHANGED
|
@@ -233,6 +233,13 @@ export type EntryData =
|
|
|
233
233
|
staticHandlerId?: string;
|
|
234
234
|
/** Response type for non-RSC routes (json, text, image, any) */
|
|
235
235
|
responseType?: string;
|
|
236
|
+
/**
|
|
237
|
+
* PPR (partial pre-rendering) opt-in from the path() `ppr` option. A
|
|
238
|
+
* document-level property of the page route: `true` uses the default
|
|
239
|
+
* shell policy, an object carries ttl/swr/tags. Read by the integrated
|
|
240
|
+
* PPR serve path (rsc/shell-serve.ts resolvePprConfig).
|
|
241
|
+
*/
|
|
242
|
+
ppr?: boolean | import("../urls/pattern-types.js").PartialPrerenderProps;
|
|
236
243
|
} & EntryPropCommon &
|
|
237
244
|
EntryPropDatas &
|
|
238
245
|
EntryPropSegments &
|
|
@@ -50,7 +50,6 @@ import type { Theme, ResolvedThemeConfig } from "../theme/types.js";
|
|
|
50
50
|
import type { ExecutionContext, RequestScope } from "../types/request-scope.js";
|
|
51
51
|
import type { TransitionWhenFn } from "../types/segments.js";
|
|
52
52
|
import type { ResolvedTracing } from "../router/tracing.js";
|
|
53
|
-
import { fireAndForgetWaitUntil } from "../types/request-scope.js";
|
|
54
53
|
import {
|
|
55
54
|
THEME_COOKIE,
|
|
56
55
|
isValidTheme,
|
|
@@ -174,49 +173,17 @@ export interface RequestContext<
|
|
|
174
173
|
/** @internal Cache store for segment caching (optional, used by CacheScope) */
|
|
175
174
|
_cacheStore?: SegmentCacheStore;
|
|
176
175
|
|
|
177
|
-
/**
|
|
178
|
-
* @internal PPR shell-resume signal. Set by the shell-cache middleware on a
|
|
179
|
-
* validated shell HIT before it awaits next(); read in the render orchestration
|
|
180
|
-
* (rsc-rendering) to call the resume strategy instead of a full fizz render. The
|
|
181
|
-
* middleware sets it optimistically — the render layer is the final authority
|
|
182
|
-
* (nonce/formState/allReady bypass) and marks the response only when it actually
|
|
183
|
-
* resumed. Single-request; the middleware clears it in a finally.
|
|
184
|
-
*/
|
|
185
|
-
_shellResume?: { postponed: string | null };
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* @internal PPR shell-capture DESCRIPTOR ("a capture of this document is
|
|
189
|
-
* wanted"). Set by the shell-cache middleware BEFORE its single foreground
|
|
190
|
-
* next() on a MISS or SWR stale hit, and read by the render orchestration
|
|
191
|
-
* (rsc-rendering) AFTER the response is built to schedule a background capture
|
|
192
|
-
* task. Its mere presence must NOT change the foreground render — loader
|
|
193
|
-
* masking and the cookie/header capture guard key off `_shellCaptureRun`, not
|
|
194
|
-
* this descriptor. `store` carries the SAME store the middleware resolved for
|
|
195
|
-
* its getShell read (options.store ?? _cacheStore), so a store-attached
|
|
196
|
-
* middleware writes captures where it reads them; without it an explicit
|
|
197
|
-
* options.store distinct from the app-level _cacheStore would read one store
|
|
198
|
-
* and write another and the shell would never HIT. `tags` is left unset here —
|
|
199
|
-
* the background capture collects the shell's own (non-loader) request tags
|
|
200
|
-
* from its derived render. Single-request; the middleware clears it in a
|
|
201
|
-
* finally after next() settles.
|
|
202
|
-
*/
|
|
203
|
-
_shellCapture?: {
|
|
204
|
-
key: string;
|
|
205
|
-
ttl?: number;
|
|
206
|
-
swr?: number;
|
|
207
|
-
tags?: string[];
|
|
208
|
-
store?: SegmentCacheStore<any>;
|
|
209
|
-
};
|
|
210
|
-
|
|
211
176
|
/**
|
|
212
177
|
* @internal PPR shell-capture ACTIVE marker. True ONLY inside the background
|
|
213
178
|
* capture task's derived request context (built by shell-capture.ts). This is
|
|
214
179
|
* the switch every capture-specific behavior reads: loader masking
|
|
215
180
|
* (loader-mask.ts isShellCaptureActive / fresh.ts emitStreaming) and the
|
|
216
181
|
* cookies()/headers() capture guard (cookie-store.ts
|
|
217
|
-
* assertNotInsideShellCapture). The foreground render never sets it
|
|
218
|
-
*
|
|
219
|
-
*
|
|
182
|
+
* assertNotInsideShellCapture). The foreground render never sets it, so the
|
|
183
|
+
* served response is byte-identical to axis 1. The capture descriptor itself
|
|
184
|
+
* (key/ttl/swr/tags/store) is NOT threaded through the request context — the
|
|
185
|
+
* integrated PPR serve path (rsc/shell-serve.ts + rsc-rendering.ts) builds it
|
|
186
|
+
* locally and passes it to scheduleShellCapture directly.
|
|
220
187
|
*/
|
|
221
188
|
_shellCaptureRun?: boolean;
|
|
222
189
|
|
|
@@ -267,6 +234,19 @@ export interface RequestContext<
|
|
|
267
234
|
/** @internal Registered onResponse callbacks */
|
|
268
235
|
_onResponseCallbacks: Array<(response: Response) => Response>;
|
|
269
236
|
|
|
237
|
+
/**
|
|
238
|
+
* @internal Promises of the background tasks scheduled via this context's
|
|
239
|
+
* waitUntil (deferred cache writes, revalidations, consumer tasks). The PPR
|
|
240
|
+
* shell capture drains this list BEFORE its match/render as an ORDERING EDGE:
|
|
241
|
+
* every foreground deferred cache write is scheduled here before the capture
|
|
242
|
+
* task is, so settling the list first guarantees the capture's cache reads
|
|
243
|
+
* observe the foreground's generation instead of racing it (see
|
|
244
|
+
* shell-capture.ts). Tasks whose scheduling fn carries
|
|
245
|
+
* UNTRACKED_BACKGROUND_TASK are not tracked (the capture task itself —
|
|
246
|
+
* tracking it would make that drain await its own promise).
|
|
247
|
+
*/
|
|
248
|
+
_pendingBackgroundTasks?: Array<Promise<unknown>>;
|
|
249
|
+
|
|
270
250
|
/**
|
|
271
251
|
* Current theme setting (only available when theme is enabled in router config)
|
|
272
252
|
*
|
|
@@ -495,8 +475,6 @@ export type PublicRequestContext<
|
|
|
495
475
|
| "_handleStore"
|
|
496
476
|
| "_transitionWhen"
|
|
497
477
|
| "_cacheStore"
|
|
498
|
-
| "_shellResume"
|
|
499
|
-
| "_shellCapture"
|
|
500
478
|
| "_shellCaptureRun"
|
|
501
479
|
| "_explicitTaggedStores"
|
|
502
480
|
| "_requestTags"
|
|
@@ -535,6 +513,17 @@ export type PublicRequestContext<
|
|
|
535
513
|
| "res"
|
|
536
514
|
>;
|
|
537
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Marker for a waitUntil-scheduled fn whose task promise must NOT enter
|
|
518
|
+
* _pendingBackgroundTasks. Used by the PPR shell capture for its own task:
|
|
519
|
+
* the capture's pre-render write barrier settles that list, so tracking the
|
|
520
|
+
* capture itself would make the drain wait on its own (still-running) promise.
|
|
521
|
+
* @internal
|
|
522
|
+
*/
|
|
523
|
+
export const UNTRACKED_BACKGROUND_TASK: unique symbol = Symbol.for(
|
|
524
|
+
"rango.untrackedBackgroundTask",
|
|
525
|
+
);
|
|
526
|
+
|
|
538
527
|
// AsyncLocalStorage instance for request context
|
|
539
528
|
const requestContextStorage = new AsyncLocalStorage<RequestContext<any>>();
|
|
540
529
|
|
|
@@ -946,20 +935,37 @@ export function createRequestContext<TEnv>(
|
|
|
946
935
|
_cacheProfiles: cacheProfiles,
|
|
947
936
|
|
|
948
937
|
waitUntil(fn: () => Promise<void>): void {
|
|
938
|
+
// Wrap in Promise.resolve().then(fn) so a SYNCHRONOUS throw in a
|
|
939
|
+
// non-async callback becomes a rejected promise handed to the host's
|
|
940
|
+
// waitUntil (logged as a background failure), instead of escaping into
|
|
941
|
+
// the request flow. Mirrors fireAndForgetWaitUntil's deferral.
|
|
942
|
+
const task = Promise.resolve().then(fn);
|
|
943
|
+
// Track the task promise so the PPR shell capture can settle the
|
|
944
|
+
// foreground's deferred cache writes before its own match/render (the
|
|
945
|
+
// ordering edge; see _pendingBackgroundTasks). The capture task itself
|
|
946
|
+
// opts out via the marker — the drain must never await its own promise.
|
|
947
|
+
if (
|
|
948
|
+
!(fn as { [UNTRACKED_BACKGROUND_TASK]?: boolean })[
|
|
949
|
+
UNTRACKED_BACKGROUND_TASK
|
|
950
|
+
]
|
|
951
|
+
) {
|
|
952
|
+
ctx._pendingBackgroundTasks?.push(task);
|
|
953
|
+
}
|
|
949
954
|
if (executionContext?.waitUntil) {
|
|
950
|
-
|
|
951
|
-
// non-async callback becomes a rejected promise handed to the host's
|
|
952
|
-
// waitUntil (logged as a background failure), instead of escaping into
|
|
953
|
-
// the request flow. Mirrors fireAndForgetWaitUntil's deferral.
|
|
954
|
-
executionContext.waitUntil(Promise.resolve().then(fn));
|
|
955
|
+
executionContext.waitUntil(task);
|
|
955
956
|
} else {
|
|
956
|
-
|
|
957
|
+
// Node/dev fallback: fire-and-forget with error logging (the same
|
|
958
|
+
// policy fireAndForgetWaitUntil applies).
|
|
959
|
+
task.catch((err) =>
|
|
960
|
+
console.error("[waitUntil] Background task failed:", err),
|
|
961
|
+
);
|
|
957
962
|
}
|
|
958
963
|
},
|
|
959
964
|
|
|
960
965
|
executionContext,
|
|
961
966
|
|
|
962
967
|
_onResponseCallbacks: [],
|
|
968
|
+
_pendingBackgroundTasks: [],
|
|
963
969
|
|
|
964
970
|
onResponse(callback: (response: Response) => Response): void {
|
|
965
971
|
assertNotInsideCacheExec(ctx, "onResponse");
|
package/src/ssr/index.tsx
CHANGED
|
@@ -173,13 +173,25 @@ export interface SSRDependencies<TEnv = unknown> {
|
|
|
173
173
|
const DEFAULT_SHELL_CAPTURE_MAX_WAIT_MS = 5000;
|
|
174
174
|
|
|
175
175
|
/**
|
|
176
|
-
* Fixed number of macrotask hops between `quiesce` resolving and the abort.
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
176
|
+
* Fixed number of macrotask hops between `quiesce` resolving and the abort. These
|
|
177
|
+
* give React's fizz worker turns to flush the settled shell into the prelude and
|
|
178
|
+
* mark still-pending boundaries as POSTPONED (rather than errored) before
|
|
179
|
+
* controller.abort() lands. Not a wall-clock wait.
|
|
180
|
+
*
|
|
181
|
+
* Why 16 and not the original 2: under the REPLAY-ONLY capture model
|
|
182
|
+
* (docs/design/ppr-shell-resume.md), the capture Flight render serializes ring-3
|
|
183
|
+
* cached segments that are ALREADY serialized, so it emits the whole shell payload
|
|
184
|
+
* in the first tick and the gate declares quiesce almost immediately (~a few ms).
|
|
185
|
+
* On the old fresh-execution path the Flight dribbled out as handlers ran, so
|
|
186
|
+
* Flight-quiet effectively meant "the shell has rendered" and 2 hops sufficed. Under
|
|
187
|
+
* replay, Flight-quiet fires BEFORE the fizz side has consumed the instant payload
|
|
188
|
+
* and rendered the shell to `<body>`, so the fizz needs a real buffer of turns after
|
|
189
|
+
* quiesce — otherwise the abort lands on an unrendered tree (empty prelude, root
|
|
190
|
+
* postpone) and the sanity gate refuses. Still task-based (masked loaders never
|
|
191
|
+
* emit, so more hops never lets a hole settle); a cold worker whose first
|
|
192
|
+
* attempt still under-renders heals on the in-place retry. Bounded by maxWaitMs.
|
|
181
193
|
*/
|
|
182
|
-
const POST_QUIESCE_TASK_HOPS =
|
|
194
|
+
const POST_QUIESCE_TASK_HOPS = 16;
|
|
183
195
|
|
|
184
196
|
/**
|
|
185
197
|
* Route an SSR error through the deps.onError notification callback with the
|
|
@@ -398,6 +410,7 @@ export function createShellCaptureHandler<TEnv = unknown>(
|
|
|
398
410
|
) {
|
|
399
411
|
const { createFromReadableStream, loadBootstrapScriptContent, prerender } =
|
|
400
412
|
deps;
|
|
413
|
+
const onError = deps.onError;
|
|
401
414
|
|
|
402
415
|
if (!prerender) {
|
|
403
416
|
throw new Error(
|
|
@@ -437,6 +450,25 @@ export function createShellCaptureHandler<TEnv = unknown>(
|
|
|
437
450
|
const prerenderPromise = prerender(<SsrRoot />, {
|
|
438
451
|
signal: controller.signal,
|
|
439
452
|
bootstrapScriptContent,
|
|
453
|
+
// Abort is how capture WORKS: once the shell is quiet we abort() to freeze
|
|
454
|
+
// the prelude and let the still-pending holes postpone. React reports the
|
|
455
|
+
// abort reason for each pending boundary through onError. Without an onError
|
|
456
|
+
// here React falls back to console.error, so every capture that still has a
|
|
457
|
+
// live hole at abort time (the normal case, and every cold-module capture
|
|
458
|
+
// where the shell is not yet done) dumps a DOMException [AbortError] stack —
|
|
459
|
+
// once per pending boundary. That is EXPECTED degradation, not a failure, so
|
|
460
|
+
// swallow the abort here. Genuine shell render errors (a component throwing)
|
|
461
|
+
// are NOT the abort and still surface through the deps.onError channel, the
|
|
462
|
+
// same one renderHTML uses. See docs/design/ppr-shell-resume.md.
|
|
463
|
+
onError: (error: unknown) => {
|
|
464
|
+
if (
|
|
465
|
+
controller.signal.aborted &&
|
|
466
|
+
(error as { name?: string } | null)?.name === "AbortError"
|
|
467
|
+
) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
reportRenderError(onError, error);
|
|
471
|
+
},
|
|
440
472
|
});
|
|
441
473
|
|
|
442
474
|
// Wait for the caller's quiesce signal. By the time it resolves the Flight
|
|
@@ -109,27 +109,6 @@ function applyThemeToDocument(theme: Theme, config: ResolvedThemeConfig): void {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
function getStoredTheme(config: ResolvedThemeConfig): Theme {
|
|
113
|
-
const { storageKey, themes, defaultTheme, enableSystem } = config;
|
|
114
|
-
|
|
115
|
-
let stored = readThemeFromCookie(storageKey);
|
|
116
|
-
|
|
117
|
-
if (!stored) {
|
|
118
|
-
stored = readThemeFromStorage(storageKey);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
if (stored) {
|
|
122
|
-
if (stored === "system" && enableSystem) {
|
|
123
|
-
return "system";
|
|
124
|
-
}
|
|
125
|
-
if (themes.includes(stored)) {
|
|
126
|
-
return stored as Theme;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return defaultTheme;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
112
|
export function ThemeProvider({
|
|
134
113
|
config,
|
|
135
114
|
initialTheme,
|
|
@@ -137,17 +116,48 @@ export function ThemeProvider({
|
|
|
137
116
|
}: ThemeProviderProps): React.ReactNode {
|
|
138
117
|
const [mounted, setMounted] = useState(false);
|
|
139
118
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
119
|
+
// HYDRATION PARITY: this initializer is the server (SSR/resume) render AND
|
|
120
|
+
// the client's hydration render — both must produce the same value. It must
|
|
121
|
+
// NEVER read cookie/localStorage: whenever the payload's initialTheme
|
|
122
|
+
// differs from the visitor's stored theme (a PPR shell HIT deliberately
|
|
123
|
+
// replays the CAPTURE's initialTheme), a storage-reading initializer makes
|
|
124
|
+
// the client's first render diverge from the server tree. Any raw-theme text
|
|
125
|
+
// (a toggle label) then fails hydration, React regenerates the tree, and the
|
|
126
|
+
// FOUC-applied class is wiped from <html>. The visitor's stored theme is
|
|
127
|
+
// applied by the post-mount re-sync effect below instead.
|
|
128
|
+
const [theme, setThemeState] = useState<Theme>(
|
|
129
|
+
() => initialTheme ?? config.defaultTheme,
|
|
130
|
+
);
|
|
145
131
|
|
|
146
132
|
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>("light");
|
|
147
133
|
|
|
148
134
|
useEffect(() => {
|
|
149
135
|
setMounted(true);
|
|
150
136
|
setSystemTheme(getSystemTheme());
|
|
137
|
+
// Re-sync state from an EXPLICITLY stored theme after mount. initialTheme
|
|
138
|
+
// comes from the payload and can legitimately differ from the visitor's
|
|
139
|
+
// stored theme — on a PPR shell HIT it is deliberately the CAPTURE's theme
|
|
140
|
+
// (the resume tree must match the frozen prelude; see
|
|
141
|
+
// ShellCacheEntry.initialTheme). The FOUC script already applied the stored
|
|
142
|
+
// theme to the document pre-paint; this brings the provider state (toggles,
|
|
143
|
+
// useTheme readers) in line with it, and is the ONLY place the provider
|
|
144
|
+
// reads storage (the initializer must not — see the parity note above).
|
|
145
|
+
// Only an explicit cookie/localStorage value re-syncs — a defaultTheme
|
|
146
|
+
// fallback must not override a server-provided initialTheme when the
|
|
147
|
+
// visitor never chose a theme.
|
|
148
|
+
// First VALID candidate wins — an empty/garbage cookie value (e.g. a
|
|
149
|
+
// deleted cookie leaving "theme=") must not shadow a valid localStorage
|
|
150
|
+
// value behind a bare null-coalesce.
|
|
151
|
+
const explicit =
|
|
152
|
+
[
|
|
153
|
+
readThemeFromCookie(config.storageKey),
|
|
154
|
+
readThemeFromStorage(config.storageKey),
|
|
155
|
+
].find((v) => v !== null && isValidTheme(v, config)) ?? null;
|
|
156
|
+
if (explicit !== null && explicit !== theme) {
|
|
157
|
+
setThemeState(explicit as Theme);
|
|
158
|
+
applyThemeToDocument(explicit as Theme, config);
|
|
159
|
+
}
|
|
160
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
151
161
|
}, []);
|
|
152
162
|
|
|
153
163
|
const setTheme = useCallback(
|
package/src/urls/index.ts
CHANGED
package/src/urls/path-helper.ts
CHANGED
|
@@ -175,6 +175,11 @@ export function createPathHelper<TEnv>(): PathFn<TEnv> {
|
|
|
175
175
|
...(resolveResponseType(options)
|
|
176
176
|
? { responseType: resolveResponseType(options) }
|
|
177
177
|
: {}),
|
|
178
|
+
// PPR shell-caching opt-in (document-level). Stored raw; the integrated
|
|
179
|
+
// serve path normalizes it via resolvePprConfig (rsc/shell-serve.ts).
|
|
180
|
+
...(options?.ppr !== undefined && options.ppr !== false
|
|
181
|
+
? { ppr: options.ppr }
|
|
182
|
+
: {}),
|
|
178
183
|
};
|
|
179
184
|
|
|
180
185
|
if (isStaticHandler(handler) && handler.$$id && ctx.namePrefix) {
|
|
@@ -35,12 +35,48 @@ export type LocalOnlyInclude = string & { [LOCAL_ONLY_BRAND]: void };
|
|
|
35
35
|
/**
|
|
36
36
|
* Options for path() function
|
|
37
37
|
*/
|
|
38
|
+
/**
|
|
39
|
+
* Options for the `ppr` path option (PPR shell caching — Axis 2, see
|
|
40
|
+
* docs/design/ppr-shell-resume.md and the /ppr skill). Declaring
|
|
41
|
+
* `ppr: true | PartialPrerenderProps` on a page route opts that DOCUMENT into
|
|
42
|
+
* shell capture: the rendered HTML shell (everything that is not a live hole) is
|
|
43
|
+
* cached and, on a later GET, flushed immediately while fizz resumes only the
|
|
44
|
+
* holes. Serving is integral to the router — there is no middleware to mount;
|
|
45
|
+
* the shell store is the app-level `createRouter({ cache })` store (which must
|
|
46
|
+
* implement the `getShell`/`putShell` family).
|
|
47
|
+
*/
|
|
48
|
+
export interface PartialPrerenderProps {
|
|
49
|
+
/**
|
|
50
|
+
* Shell time-to-live in seconds. Defaults to 300 (`ppr: true` uses the same
|
|
51
|
+
* default).
|
|
52
|
+
*/
|
|
53
|
+
ttl?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Stale-while-revalidate window in seconds: a stale shell is still served
|
|
56
|
+
* while a background recapture refreshes it.
|
|
57
|
+
*/
|
|
58
|
+
swr?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Operational tags attached to the captured shell entry for
|
|
61
|
+
* `updateTag()`/`revalidateTag()`-driven eviction. UNIONED with the tags the
|
|
62
|
+
* capture render auto-collects (the shell's own non-loader request tags).
|
|
63
|
+
*/
|
|
64
|
+
tags?: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
38
67
|
export interface PathOptions<
|
|
39
68
|
TName extends string = string,
|
|
40
69
|
TSearch extends SearchSchema = {},
|
|
41
70
|
> {
|
|
42
71
|
/** Route name for href() lookups */
|
|
43
72
|
name?: TName;
|
|
73
|
+
/**
|
|
74
|
+
* PPR shell caching opt-in for this page route (document-level). `true` uses
|
|
75
|
+
* the default policy (ttl 300); an object sets ttl/swr/tags. See
|
|
76
|
+
* {@link PartialPrerenderProps}. Routes without this option are pure axis 1 —
|
|
77
|
+
* no capture, no store reads, no logs.
|
|
78
|
+
*/
|
|
79
|
+
ppr?: boolean | PartialPrerenderProps;
|
|
44
80
|
/** Search param schema for typed query parameters */
|
|
45
81
|
search?: TSearch;
|
|
46
82
|
/** Trailing slash behavior: "never" (redirect /path/ to /path), "always" (redirect /path to /path/), "ignore" (match both) */
|