@rangojs/router 0.0.0-experimental.151 → 0.0.0-experimental.152
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 +19 -1
- package/src/server/context.ts +35 -2
- package/src/server/request-context.ts +6 -0
package/dist/vite/index.js
CHANGED
|
@@ -2520,7 +2520,7 @@ import { resolve } from "node:path";
|
|
|
2520
2520
|
// package.json
|
|
2521
2521
|
var package_default = {
|
|
2522
2522
|
name: "@rangojs/router",
|
|
2523
|
-
version: "0.0.0-experimental.
|
|
2523
|
+
version: "0.0.0-experimental.152",
|
|
2524
2524
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
2525
2525
|
keywords: [
|
|
2526
2526
|
"react",
|
package/package.json
CHANGED
package/skills/ppr/SKILL.md
CHANGED
|
@@ -184,6 +184,22 @@ opt-out. Use that for routes where the shell depends on runtime-only auth,
|
|
|
184
184
|
cookies, or side-effectful SDK calls. A skipped build shell can still be owned
|
|
185
185
|
later by runtime capture when runtime middleware does not call `ctx.dynamic()`.
|
|
186
186
|
|
|
187
|
+
`ctx.dynamic()` also **re-permits handler header/cookie writes** (issue #735).
|
|
188
|
+
The header doctrine below forbids handler writes on a ppr route because they
|
|
189
|
+
ride MISSes and vanish on HITs — but a `dynamic()` render never HITs, so its
|
|
190
|
+
write is deterministic. Calling `ctx.dynamic()` clears the header latch for the
|
|
191
|
+
rest of the render, so the SAME handler can write its control-flow header
|
|
192
|
+
directly (no middleware relay). Ordering is a contract: call `dynamic()` BEFORE
|
|
193
|
+
the write — a write before it still throws.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
function catalogPage(ctx) {
|
|
197
|
+
ctx.dynamic(); // declare live -> refuses capture AND clears the header latch
|
|
198
|
+
ctx.headers.set("x-rango-sfra-proxy", "catalog");
|
|
199
|
+
return <Catalog />;
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
187
203
|
## Verifying it works
|
|
188
204
|
|
|
189
205
|
The header exists on DOCUMENT responses only. A bare `curl` gets the HTML
|
|
@@ -345,7 +361,9 @@ Four hard edges (each e2e/unit-pinned):
|
|
|
345
361
|
HIT); loaders are live but settle AFTER the response headers flushed with
|
|
346
362
|
the shell (dead letters). Move the write into route middleware — it runs
|
|
347
363
|
on every request, including HITs, and its headers/cookies merge into every
|
|
348
|
-
response.
|
|
364
|
+
response. The one exception: a handler that calls `ctx.dynamic()` FIRST
|
|
365
|
+
re-permits its own header/cookie write (#735) — a dynamic() render never
|
|
366
|
+
HITs, so the write is deterministic (see "Opting out per request").
|
|
349
367
|
- **Identity refuses.** `cookies()`/`headers()` inside a bake-lane loader
|
|
350
368
|
throws during capture and the capture REFUSES (deterministic, once-per-key
|
|
351
369
|
warned) — identity can never bake into the shared shell. Give that loader's
|
package/src/server/context.ts
CHANGED
|
@@ -903,14 +903,47 @@ export function latchPprHeaderScopeForEntries(
|
|
|
903
903
|
}
|
|
904
904
|
}
|
|
905
905
|
|
|
906
|
+
/**
|
|
907
|
+
* Clear the ppr header-write latch for the remainder of this render (issue
|
|
908
|
+
* #735). Called by ctx.dynamic(): a dynamic() render opts off the SHELL axis
|
|
909
|
+
* (rsc-rendering.ts skips both the HIT commit and the MISS capture on
|
|
910
|
+
* `_dynamic`), so it is ALWAYS live — every request re-runs the handler and its
|
|
911
|
+
* header write lands identically each time. The guard's reason to forbid it
|
|
912
|
+
* (MISS/HIT divergence) evaporates, so the write is re-permitted.
|
|
913
|
+
*
|
|
914
|
+
* ONLY the ppr (shell) axis is dropped — dynamic() does NOT opt off the CACHE
|
|
915
|
+
* axis. Two cases:
|
|
916
|
+
* - Pure ppr funnel (no cache() boundary): clear the latch → writes re-permit.
|
|
917
|
+
* - ppr route nested under a cache() boundary: fresh.ts latches "ppr" at the
|
|
918
|
+
* funnel top (first-wins), which MASKS the positional cache() latch, but the
|
|
919
|
+
* handler still runs inside the cache scope (`insideCacheScope`). A cache()
|
|
920
|
+
* HIT skips that handler, so the write is still non-deterministic — UNMASK to
|
|
921
|
+
* "cache" instead of clearing, so the guard keeps throwing (accurate cache()
|
|
922
|
+
* wording). This is why the check keys off `insideCacheScope`, not just kind.
|
|
923
|
+
*
|
|
924
|
+
* A subsequent cache() entered AFTER dynamic() on a pure-ppr funnel re-latches
|
|
925
|
+
* "cache" via latchCachedHeaderScope's `!store.cachedHeaderScope` guard (the
|
|
926
|
+
* field is undefined again once cleared). No-op when there is no funnel store or
|
|
927
|
+
* no ppr latch (dynamic() from middleware runs outside the funnel Store.run
|
|
928
|
+
* scope, so nothing is latched — the middleware exemption is unchanged).
|
|
929
|
+
*/
|
|
930
|
+
export function clearPprHeaderScope(): void {
|
|
931
|
+
const store = RangoContext.getStore();
|
|
932
|
+
if (store?.cachedHeaderScope?.kind !== "ppr") return;
|
|
933
|
+
store.cachedHeaderScope = store.insideCacheScope
|
|
934
|
+
? { kind: "cache", routeKey: store.cachedHeaderScope.routeKey }
|
|
935
|
+
: undefined;
|
|
936
|
+
}
|
|
937
|
+
|
|
906
938
|
/**
|
|
907
939
|
* RULE (issue #713): in any cached scenario ONLY MIDDLEWARE writes response
|
|
908
940
|
* headers — handler and loader writes throw while a scope is latched; the one
|
|
909
941
|
* exemption is DSL (registered) loaders under plain cache(). A handler-invoked
|
|
910
942
|
* loader body (ctx.use from a handler, never registered with loader()) is
|
|
911
943
|
* skipped with its handler on a HIT and throws like a handler write (#725).
|
|
912
|
-
*
|
|
913
|
-
*
|
|
944
|
+
* A ctx.dynamic() render clears the ppr latch (clearPprHeaderScope, #735) so
|
|
945
|
+
* its always-live handler header writes are re-permitted. Full layer rules and
|
|
946
|
+
* rationale: docs/design/ppr-shell-resume.md "The header doctrine".
|
|
914
947
|
*/
|
|
915
948
|
export function assertCachedHeaderWriteAllowed(
|
|
916
949
|
surface: string,
|
|
@@ -59,6 +59,7 @@ import type { LocationStateEntry } from "../browser/react/location-state-shared.
|
|
|
59
59
|
import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
|
|
60
60
|
import {
|
|
61
61
|
assertCachedHeaderWriteAllowed,
|
|
62
|
+
clearPprHeaderScope,
|
|
62
63
|
isInsideCacheScope,
|
|
63
64
|
} from "./context.js";
|
|
64
65
|
import {
|
|
@@ -979,6 +980,11 @@ export function createRequestContext<TEnv>(
|
|
|
979
980
|
build,
|
|
980
981
|
dynamic(): void {
|
|
981
982
|
ctx._dynamic = true;
|
|
983
|
+
// A dynamic() render is always live (never a shell HIT), so its handler
|
|
984
|
+
// header writes are deterministic — clear the ppr latch to re-permit them
|
|
985
|
+
// (#735). No-op from middleware (outside the funnel scope) or on non-ppr
|
|
986
|
+
// routes; cache() latches are left alone.
|
|
987
|
+
clearPprHeaderScope();
|
|
982
988
|
},
|
|
983
989
|
_dynamic: false,
|
|
984
990
|
get: ((keyOrVar: any) => {
|