@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.
@@ -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.151",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.151",
3
+ "version": "0.0.0-experimental.152",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -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
@@ -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
- * Full layer rules and rationale: docs/design/ppr-shell-resume.md "The header
913
- * doctrine".
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) => {