@rangojs/router 0.26.5 → 0.27.0
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/bin/rango.js +2 -2
- package/dist/types/browser/action-coordinator.d.ts +0 -4
- package/dist/types/browser/action-fence.d.ts +11 -3
- package/dist/types/browser/event-controller.d.ts +18 -0
- package/dist/types/browser/history-state.d.ts +4 -0
- package/dist/types/browser/intercept-utils.d.ts +23 -0
- package/dist/types/browser/rsc-router.d.ts +1 -1
- package/dist/types/prefetch-markers.d.ts +12 -5
- package/dist/types/router/match-context.d.ts +8 -0
- package/dist/types/router/match-middleware/cache-lookup.d.ts +1 -1
- package/dist/types/router/match-middleware/cache-store.d.ts +2 -2
- package/dist/types/router/revalidation.d.ts +6 -1
- package/dist/types/router/router-context.d.ts +2 -17
- package/dist/types/rsc/shell-capture-constants.d.ts +1 -1
- package/dist/types/testing/serve-shell-request.d.ts +7 -0
- package/dist/types/types/handler-context.d.ts +9 -6
- package/dist/types/vite/plugins/server-ref-hashing.d.ts +8 -1
- package/dist/vite/index.js +10 -6
- package/package.json +2 -2
- package/skills/client-urls/SKILL.md +2 -1
- package/skills/document-cache/SKILL.md +5 -0
- package/skills/intercept/SKILL.md +36 -6
- package/skills/layout/SKILL.md +11 -7
- package/skills/loader/SKILL.md +30 -13
- package/skills/middleware/SKILL.md +1 -1
- package/skills/migrate-nextjs/SKILL.md +5 -4
- package/skills/parallel/SKILL.md +5 -4
- package/skills/ppr/SKILL.md +1 -0
- package/skills/rango/SKILL.md +18 -10
- package/skills/server-actions/SKILL.md +43 -5
- package/skills/testing/predicates.md +1 -1
- package/skills/testing/serve-shell-request.md +7 -7
- package/skills/ux-architecture/SKILL.md +3 -2
- package/src/browser/action-coordinator.ts +2 -13
- package/src/browser/action-fence.ts +11 -3
- package/src/browser/event-controller.ts +37 -0
- package/src/browser/history-state.ts +9 -1
- package/src/browser/intercept-utils.ts +51 -0
- package/src/browser/navigation-bridge.ts +20 -17
- package/src/browser/navigation-client.ts +5 -10
- package/src/browser/navigation-transaction.ts +3 -10
- package/src/browser/partial-update.ts +8 -0
- package/src/browser/rsc-router.tsx +16 -2
- package/src/browser/server-action-bridge.ts +223 -78
- package/src/cache/document-cache.ts +12 -6
- package/src/prefetch-markers.ts +13 -5
- package/src/router/match-context.ts +13 -0
- package/src/router/match-middleware/cache-lookup.ts +21 -10
- package/src/router/match-middleware/cache-store.ts +10 -4
- package/src/router/match-result.ts +1 -20
- package/src/router/navigation-snapshot.ts +2 -1
- package/src/router/revalidation.ts +33 -7
- package/src/router/router-context.ts +4 -22
- package/src/rsc/rsc-rendering.ts +12 -4
- package/src/rsc/shell-capture-constants.ts +2 -0
- package/src/ssr/ssr-root.tsx +1 -0
- package/src/testing/serve-shell-request.ts +20 -2
- package/src/types/handler-context.ts +9 -6
- package/src/vite/plugins/server-ref-hashing.ts +16 -5
|
@@ -90,7 +90,7 @@ import type { Revalidate } from "@rangojs/router";
|
|
|
90
90
|
import * as CartActions from "./actions/cart";
|
|
91
91
|
|
|
92
92
|
export const revalidateCartData: Revalidate = (ctx) =>
|
|
93
|
-
ctx.isAction(CartActions) || undefined;
|
|
93
|
+
ctx.stale || ctx.isAction(CartActions) || undefined;
|
|
94
94
|
|
|
95
95
|
layout(CartLayout, () => [
|
|
96
96
|
middleware(cartRenderMiddleware),
|
|
@@ -633,10 +633,11 @@ re-rendering:
|
|
|
633
633
|
```typescript
|
|
634
634
|
import { updateBlog } from "./actions/blog";
|
|
635
635
|
|
|
636
|
-
// Re-run this layout when a blog action fires
|
|
637
|
-
// skipped after actions by default; undefined
|
|
636
|
+
// Re-run this layout when a blog action fires, and on Back after an action
|
|
637
|
+
// (layouts above the route are skipped after actions by default; undefined
|
|
638
|
+
// defers otherwise)
|
|
638
639
|
layout(BlogLayout, () => [
|
|
639
|
-
revalidate((ctx) => ctx.isAction(updateBlog) || undefined),
|
|
640
|
+
revalidate((ctx) => ctx.isAction(updateBlog) || ctx.stale || undefined),
|
|
640
641
|
path("/blog/:slug", BlogPost, { name: "blogPost" }),
|
|
641
642
|
]);
|
|
642
643
|
|
|
@@ -860,7 +861,7 @@ Server actions work the same way — `"use server"` directive, `useActionState`,
|
|
|
860
861
|
|
|
861
862
|
Key difference: in Rango, route middleware does NOT wrap action execution. Actions only see global middleware context. Use `getRequestContext()` in actions to access `ctx.set()`/`ctx.get()`.
|
|
862
863
|
|
|
863
|
-
Next.js's `revalidateTag()` maps directly: tag entries via `cache({ tags })` / `cacheTag(...)`, then invalidate. **In a server action use `await updateTag(tag)`** — it is read-your-own-writes, so the action's own re-render sees fresh data, and it rejects when the store's durable write fails; `revalidateTag(tag)` is a background (non-blocking) hard-purge for route handlers / webhooks. The request that calls `revalidateTag()` also reads its own writes, but a failed durable write only reaches `onError`. `revalidatePath()` has no path-based equivalent — tag the route's entries instead. Separately, the route, the segments inside its `path()`, and all loaders already re-run after every action; layouts and parallels above the route are skipped. To re-render one of those skipped segments after an action, attach `revalidate((ctx) => ctx.isAction(updateBlog) || undefined)` to it; to narrow a loader or route segment to one action, use `revalidate((ctx) => (ctx.isAction() ? ctx.isAction(updateBlog) : undefined))` (match the imported action by reference, as in §3). See `/server-actions` for the full pattern (validation, error handling, file uploads), `/caching` for tag invalidation, and `/loader` for revalidation rule semantics.
|
|
864
|
+
Next.js's `revalidateTag()` maps directly: tag entries via `cache({ tags })` / `cacheTag(...)`, then invalidate. **In a server action use `await updateTag(tag)`** — it is read-your-own-writes, so the action's own re-render sees fresh data, and it rejects when the store's durable write fails; `revalidateTag(tag)` is a background (non-blocking) hard-purge for route handlers / webhooks. The request that calls `revalidateTag()` also reads its own writes, but a failed durable write only reaches `onError`. `revalidatePath()` has no path-based equivalent — tag the route's entries instead. Separately, the route, the segments inside its `path()`, and all loaders already re-run after every action; layouts and parallels above the route are skipped. To re-render one of those skipped segments after an action, and on Back after one, attach `revalidate((ctx) => ctx.isAction(updateBlog) || ctx.stale || undefined)` to it; to narrow a loader or route segment to one action, use `revalidate((ctx) => (ctx.isAction() ? ctx.isAction(updateBlog) : undefined))` (match the imported action by reference, as in §3). See `/server-actions` for the full pattern (validation, error handling, file uploads), `/caching` for tag invalidation, and `/loader` for revalidation rule semantics.
|
|
864
865
|
|
|
865
866
|
## 8. Metadata / Head
|
|
866
867
|
|
package/skills/parallel/SKILL.md
CHANGED
|
@@ -392,8 +392,9 @@ parallel(
|
|
|
392
392
|
() => [
|
|
393
393
|
loader(CartLoader),
|
|
394
394
|
// Layout-level slot (kept after actions by default): also re-render after
|
|
395
|
-
// cart actions. Under a path() the slot
|
|
396
|
-
|
|
395
|
+
// cart actions and on Back after an action. Under a path() the slot
|
|
396
|
+
// re-renders after every action anyway.
|
|
397
|
+
revalidate((ctx) => ctx.isAction(CartActions) || ctx.stale || undefined),
|
|
397
398
|
]
|
|
398
399
|
)
|
|
399
400
|
```
|
|
@@ -456,7 +457,7 @@ import type { Revalidate } from "@rangojs/router";
|
|
|
456
457
|
import * as CartActions from "./actions/cart";
|
|
457
458
|
|
|
458
459
|
export const revalidateCartData: Revalidate = (ctx) =>
|
|
459
|
-
ctx.isAction(CartActions) || undefined;
|
|
460
|
+
ctx.stale || ctx.isAction(CartActions) || undefined;
|
|
460
461
|
|
|
461
462
|
layout(CartLayout, () => [
|
|
462
463
|
revalidate(revalidateCartData), // producer reruns
|
|
@@ -575,7 +576,7 @@ export const shopPatterns = urls(({
|
|
|
575
576
|
() => [
|
|
576
577
|
loader(CartLoader),
|
|
577
578
|
loading(<CartSkeleton />),
|
|
578
|
-
revalidate((ctx) => ctx.isAction(CartActions) || undefined),
|
|
579
|
+
revalidate((ctx) => ctx.isAction(CartActions) || ctx.stale || undefined),
|
|
579
580
|
]
|
|
580
581
|
),
|
|
581
582
|
|
package/skills/ppr/SKILL.md
CHANGED
|
@@ -482,6 +482,7 @@ The bounded bypass tokens, grouped by when they are decided:
|
|
|
482
482
|
| Token | Decided | Meaning |
|
|
483
483
|
| ----------------------------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
484
484
|
| `method`, `dynamic`, `nonce`, `store-unavailable`, `passive-read-unsupported` | pre-read | request/route/store ineligible for replay |
|
|
485
|
+
| `stale` | pre-read | a stale request (`_rsc_stale`, Back/Forward onto a page an action made stale) renders live, as an action does: the record may predate the write |
|
|
485
486
|
| `no-navigation-context` | pre-read | no `X-RSC-Router-Client-Path` and no same-origin `Referer`; a partial match is impossible |
|
|
486
487
|
| `prerender-store` | pre-read or match | `Prerender()` route served by its baked artifact (pre-read probe of the normal variant; reclassified post-match when the store actually served, either variant) |
|
|
487
488
|
| `intercept` | match | the navigation resolved to an intercept — replay is never armed for intercepts (they keep their normal cache path); no heal capture |
|
package/skills/rango/SKILL.md
CHANGED
|
@@ -87,10 +87,12 @@ are immune to partial-revalidation staleness by construction.
|
|
|
87
87
|
the entry's children too — a hard `false` on one side of a
|
|
88
88
|
producer/consumer pair desyncs it.
|
|
89
89
|
4. **Cross-entry sharing** — an outer `layout()` entry feeding descendants.
|
|
90
|
-
Outer entries do NOT revalidate on actions
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
Outer entries do NOT revalidate on actions, or on Back/Forward onto a page
|
|
91
|
+
an action made stale, by default (the revalidation trace calls this
|
|
92
|
+
`action:parent-chain-skip` / `stale:parent-chain-skip`), so this rung always
|
|
93
|
+
requires a shared revalidation contract: the same named `revalidate()`
|
|
94
|
+
function on the producer and every consumer, returning `true` on `stale`
|
|
95
|
+
as well as on its actions. See `/layout` → "Revalidation Contracts".
|
|
94
96
|
Before writing one, check whether the producer can move down a rung.
|
|
95
97
|
|
|
96
98
|
The failure mode this ladder prevents: a consumer re-runs, its producer
|
|
@@ -249,7 +251,9 @@ _which_ action with `ctx.isAction(addToCart)` (rename-safe); branch on _what it
|
|
|
249
251
|
returned_ with `ctx.actionResult` — the value your `"use server"` function
|
|
250
252
|
returned, for outcome-conditional revalidation. The arg also exposes `actionId`
|
|
251
253
|
(raw `path#export`), `actionUrl`, `formData` (form-based actions only), `method`,
|
|
252
|
-
and `stale` (the `_rsc_stale` signal that an action ran in this or another tab
|
|
254
|
+
and `stale` (the `_rsc_stale` signal that an action ran in this or another tab;
|
|
255
|
+
that Back/Forward revalidation takes an action's defaults, but `isAction()` is
|
|
256
|
+
false on it).
|
|
253
257
|
`actionId`, `actionUrl`, `actionResult`, and `formData` are `undefined` on plain
|
|
254
258
|
navigation; `method` is `"GET"` there and `"POST"` for an action. Call
|
|
255
259
|
`ctx.isAction()` with no arguments to ask "was this any action?".
|
|
@@ -258,9 +262,10 @@ navigation; `method` is `"GET"` there and `"POST"` for an action. Call
|
|
|
258
262
|
default already does: after an action, loaders, the route, and segments inside
|
|
259
263
|
the `path()` re-run; layouts and parallels above the route are skipped.
|
|
260
264
|
|
|
261
|
-
- **Add a signal** where the default skips:
|
|
262
|
-
|
|
263
|
-
|
|
265
|
+
- **Add a signal** where the default skips:
|
|
266
|
+
`ctx.isAction(CartActions) || ctx.stale || undefined` on a parent layout
|
|
267
|
+
re-renders it after cart actions and on Back after an action, and defers
|
|
268
|
+
otherwise. On a loader or route segment it changes nothing.
|
|
264
269
|
- **Narrow after actions** where the default re-runs:
|
|
265
270
|
`ctx.isAction() ? ctx.isAction(CartActions) : undefined` re-runs only for cart
|
|
266
271
|
actions and keeps the navigation default (params or search changed).
|
|
@@ -270,8 +275,11 @@ the `path()` re-run; layouts and parallels above the route are skipped.
|
|
|
270
275
|
|
|
271
276
|
```ts
|
|
272
277
|
// on a parent layout (skipped after actions by default): also re-render when
|
|
273
|
-
// checkout succeeded; defer to the default otherwise
|
|
274
|
-
revalidate(
|
|
278
|
+
// checkout succeeded, and on Back after an action; defer to the default otherwise
|
|
279
|
+
revalidate(
|
|
280
|
+
(ctx) =>
|
|
281
|
+
(ctx.isAction(checkout) && ctx.actionResult?.ok) || ctx.stale || undefined,
|
|
282
|
+
),
|
|
275
283
|
```
|
|
276
284
|
|
|
277
285
|
The same `actionResult` idiom works in a `clientUrls()` group's loader
|
|
@@ -428,8 +428,9 @@ several actions, or a namespace import (`import * as CartActions`). Wrap it by
|
|
|
428
428
|
what the segment's default already does:
|
|
429
429
|
|
|
430
430
|
- **Add a signal** where the default skips (a parent layout or layout-level
|
|
431
|
-
parallel): `ctx.isAction(CartActions) || undefined` re-renders it
|
|
432
|
-
actions and
|
|
431
|
+
parallel): `ctx.isAction(CartActions) || ctx.stale || undefined` re-renders it
|
|
432
|
+
after cart actions and on Back after an action, and defers otherwise. On a
|
|
433
|
+
loader or route segment it changes nothing.
|
|
433
434
|
- **Narrow after actions** where the default re-runs (loaders, the route, and
|
|
434
435
|
segments inside the `path()`):
|
|
435
436
|
`ctx.isAction() ? ctx.isAction(CartActions) : undefined` re-runs only for cart
|
|
@@ -508,8 +509,10 @@ consumer:
|
|
|
508
509
|
import type { Revalidate } from "@rangojs/router";
|
|
509
510
|
import * as CartActions from "./actions/cart";
|
|
510
511
|
|
|
512
|
+
// True on a stale Back/Forward too: it re-runs CartPage, and a producer that
|
|
513
|
+
// sits it out leaves the page reading undefined.
|
|
511
514
|
const revalidateCart: Revalidate = (ctx) =>
|
|
512
|
-
ctx.isAction(CartActions) || undefined;
|
|
515
|
+
ctx.stale || ctx.isAction(CartActions) || undefined;
|
|
513
516
|
|
|
514
517
|
urls(({ path, layout, loader, revalidate }) => [
|
|
515
518
|
layout(CartLayout, () => [
|
|
@@ -534,6 +537,41 @@ revalidates), flushes the prefetch cache, rotates the router's state cookie,
|
|
|
534
537
|
and tells other open tabs to do the same. This happens for every action that
|
|
535
538
|
reached the server, including ones that threw. No setup is needed.
|
|
536
539
|
|
|
540
|
+
### When the user navigates while the action runs
|
|
541
|
+
|
|
542
|
+
Users rarely wait for an action. They click "Add to cart" and then a link, or
|
|
543
|
+
go Back. That navigation is an ordinary one: a prefetched link shows its page at
|
|
544
|
+
once, as it would with no action in flight. That page may show data from before
|
|
545
|
+
the write, such as a layout's cart count the navigation kept. So once the last
|
|
546
|
+
action in flight settles, the router revalidates the page on screen once, in
|
|
547
|
+
the background: every loader on it re-runs, and the result commits with no
|
|
548
|
+
`loading()` fallback and no remount. `useNavigation().state` stays `"idle"`. This covers an action that failed too.
|
|
549
|
+
|
|
550
|
+
```text
|
|
551
|
+
/product/1: click "Add to cart", then a link to /checkout
|
|
552
|
+
/checkout shows at once (layout: cart:0)
|
|
553
|
+
the action settles
|
|
554
|
+
one background revalidation (layout: cart:1)
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
A few details:
|
|
558
|
+
|
|
559
|
+
- If the user stays on the page, nothing extra is sent. The action's own
|
|
560
|
+
response already re-ran that page's loaders.
|
|
561
|
+
- The revalidation waits for a navigation the user started and never cancels
|
|
562
|
+
it. Several actions in flight share one revalidation.
|
|
563
|
+
- A `redirect()` from the action replaces it: the redirect target renders
|
|
564
|
+
fresh.
|
|
565
|
+
- If an `intercept()` is on screen, nothing runs. Leaving the intercept with
|
|
566
|
+
Back restores the page underneath and revalidates it as stale (the
|
|
567
|
+
action marked the history cache stale). Only loaders that opt in with
|
|
568
|
+
`revalidate(({ stale }) => stale)` re-run there; the others keep their data
|
|
569
|
+
until a request re-runs them.
|
|
570
|
+
- It holds no segments, so every loader on the page re-runs whatever its
|
|
571
|
+
`revalidate()` says, and the document cache never answers it.
|
|
572
|
+
- Going Back or Forward out of an intercept does not abort an action sent from
|
|
573
|
+
the intercept. `useAction()` and your `await` get its real result.
|
|
574
|
+
|
|
537
575
|
### `keepClientCache()` — skip it for no-op actions
|
|
538
576
|
|
|
539
577
|
Some hot actions change nothing a route renders: a debounced draft autosave, an
|
|
@@ -555,8 +593,8 @@ export async function saveDraft(formData: FormData) {
|
|
|
555
593
|
|
|
556
594
|
What it suppresses, for this action only: the stale-marking of the history
|
|
557
595
|
cache, the prefetch flush, the state-cookie rotation, the cross-tab broadcast,
|
|
558
|
-
and the background
|
|
559
|
-
the action was in flight. What it does not change:
|
|
596
|
+
and the background revalidation the client runs when the user navigated while
|
|
597
|
+
the action was in flight (above). What it does not change:
|
|
560
598
|
|
|
561
599
|
- The action's own response still carries the revalidation render your
|
|
562
600
|
`revalidate()` rules select, and the client applies it.
|
|
@@ -80,7 +80,7 @@ Defaults model a same-URL navigation with no action, for a route-owned loader: a
|
|
|
80
80
|
| `method` | `string` | Overrides the request method (`POST` with an `action`, otherwise `GET`). |
|
|
81
81
|
| `segmentType` | `"layout" \| "route" \| "parallel" \| "loader"` | The segment type the predicate is attached to. Defaults to `"loader"`. |
|
|
82
82
|
| `belongsToRoute` | `boolean` | Defaults to `true`; `false` models a layout's loader, which a navigation keeps by default. |
|
|
83
|
-
| `stale` | `boolean` | The `stale` arg (a history-entry background revalidation). Defaults to `false`.
|
|
83
|
+
| `stale` | `boolean` | The `stale` arg (a history-entry background revalidation), which takes an action's defaults. Defaults to `false`. |
|
|
84
84
|
| `routeName` | `string` | `toRouteName` (and `routeName`). |
|
|
85
85
|
| `fromRouteName` | `string` | `fromRouteName`. |
|
|
86
86
|
| `env` | `TEnv` | Environment bindings of the seeded context. |
|
|
@@ -20,13 +20,13 @@ A `Prerender` route is served as in production: from the artifact `router.matchF
|
|
|
20
20
|
|
|
21
21
|
### Options — `ServeShellRequestOptions`
|
|
22
22
|
|
|
23
|
-
| Field | Type
|
|
24
|
-
| ------------ |
|
|
25
|
-
| `cacheStore` | `SegmentCacheStore`
|
|
26
|
-
| `env` | `TEnv`
|
|
27
|
-
| `headers` | `HeadersInit`
|
|
28
|
-
| `partial` | `true \| { from?: string; rewriteTarget?: string; segments?: string[]; prefetch?: boolean; fill?: boolean; interceptSource?: string }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). `prefetch: true` sends it as a `<Link>` prefetch (`X-Rango-Prefetch`), so `prefetch: false` work is deferred. `fill: true` sends the follow-up request (`_rsc_fill=1`) the browser makes after adopting a prefetch; pass the prefetch's delivered ids, without the deferred ones, as `segments`, and the same URL as `from`. `interceptSource` sends the intercept source (`X-RSC-Router-Intercept-Source`) the browser sends with the fill of an intercept, whose `from` is that source too. `rewriteTarget` sends what the page at `from` carried as its payload's `metadata.rewriteTarget` when middleware rewrote it (`_rsc_rt`), as the browser does, so the server diffs against the route that rendered that page. |
|
|
29
|
-
| `action` | `{ ref: Function; id: string; args?: unknown[]; segments?: string[]; rewriteTarget?: string }`
|
|
23
|
+
| Field | Type | Meaning |
|
|
24
|
+
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
25
|
+
| `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store. |
|
|
26
|
+
| `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them. |
|
|
27
|
+
| `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`. |
|
|
28
|
+
| `partial` | `true \| { from?: string; rewriteTarget?: string; segments?: string[]; prefetch?: boolean; fill?: boolean; interceptSource?: string; stale?: boolean }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). `prefetch: true` sends it as a `<Link>` prefetch (`X-Rango-Prefetch`), so `prefetch: false` work is deferred. `fill: true` sends the follow-up request (`_rsc_fill=1`) the browser makes after adopting a prefetch; pass the prefetch's delivered ids, without the deferred ones, as `segments`, and the same URL as `from`. `interceptSource` sends the intercept source (`X-RSC-Router-Intercept-Source`) the browser sends with the fill of an intercept, whose `from` is that source too. `rewriteTarget` sends what the page at `from` carried as its payload's `metadata.rewriteTarget` when middleware rewrote it (`_rsc_rt`), as the browser does, so the server diffs against the route that rendered that page. `stale: true` sends a stale request (`_rsc_stale`), as the browser sends it: every `revalidate()` sees `stale: true` (see `revalidate()`'s `stale`), segments take an action's revalidation defaults, and it renders live, as an action does: neither the route cache (`cache()`) nor the document cache serves or stores it, and a `ppr` route's `replayStatus` is `{ outcome: "BYPASS", reason: "stale" }`. |
|
|
29
|
+
| `action` | `{ ref: Function; id: string; args?: unknown[]; segments?: string[]; rewriteTarget?: string }` | Send the request a JS server action call makes from the page at `url`: a POST of `args` (encoded as the browser encodes them) with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments` (the page's mounted segment ids, default none), plus the page's `rewriteTarget` as for `partial`. Registers `ref` under `id` (`<module id>#<export name>`). Not with `partial` or `form`. |
|
|
30
30
|
|
|
31
31
|
### Returns — `ServeShellRequestResult`
|
|
32
32
|
|
|
@@ -228,8 +228,9 @@ them right:
|
|
|
228
228
|
|
|
229
229
|
- A boolean is a hard answer, `undefined` defers to the default. Pick by the
|
|
230
230
|
default you are changing: the ternary narrows a loader that re-runs after
|
|
231
|
-
every action; `isAction(X) || undefined` adds a re-run
|
|
232
|
-
skips (a server layout's own
|
|
231
|
+
every action; `isAction(X) || stale || undefined` adds a re-run, after `X`
|
|
232
|
+
and on Back after an action, where the default skips (a server layout's own
|
|
233
|
+
handler).
|
|
233
234
|
- `revalidate(true)` and `revalidate(false)` are shorthands for `() => true`
|
|
234
235
|
and `() => false`.
|
|
235
236
|
- Never `revalidate(false)` on data keyed by a param: product to product then
|
|
@@ -6,11 +6,7 @@ import type { ActionEntry } from "./event-controller.js";
|
|
|
6
6
|
* reconciliation; this only covers successfully-reconciled partial responses.
|
|
7
7
|
*/
|
|
8
8
|
type ActionScenario =
|
|
9
|
-
| {
|
|
10
|
-
type: "navigated-away";
|
|
11
|
-
historyKeyChanged: boolean;
|
|
12
|
-
onInterceptRoute: boolean;
|
|
13
|
-
}
|
|
9
|
+
| { type: "navigated-away" }
|
|
14
10
|
| { type: "hmr-missing" }
|
|
15
11
|
| { type: "consolidation-needed"; segmentIds: string[] }
|
|
16
12
|
| { type: "concurrent-skip"; otherFetchingCount: number }
|
|
@@ -41,8 +37,6 @@ export interface ActionOutcomeInput {
|
|
|
41
37
|
reconciledSegmentCount: number;
|
|
42
38
|
/** Number of matched segment IDs from server */
|
|
43
39
|
matchedCount: number;
|
|
44
|
-
/** Current intercept source URL (null when not on intercept route) */
|
|
45
|
-
currentInterceptSource: string | null;
|
|
46
40
|
}
|
|
47
41
|
|
|
48
42
|
// Segment IDs to re-fetch when concurrent actions each revalidated different
|
|
@@ -85,12 +79,7 @@ export function classifyActionOutcome(
|
|
|
85
79
|
input.currentPathname !== input.actionStartPathname ||
|
|
86
80
|
input.currentLocationKey !== input.actionStartLocationKey
|
|
87
81
|
) {
|
|
88
|
-
return {
|
|
89
|
-
type: "navigated-away",
|
|
90
|
-
historyKeyChanged:
|
|
91
|
-
input.currentLocationKey !== input.actionStartLocationKey,
|
|
92
|
-
onInterceptRoute: input.currentInterceptSource !== null,
|
|
93
|
-
};
|
|
82
|
+
return { type: "navigated-away" };
|
|
94
83
|
}
|
|
95
84
|
|
|
96
85
|
if (input.reconciledSegmentCount < input.matchedCount) {
|
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
* start. While the fence is up, the decision of whether the action invalidated
|
|
7
7
|
* anything is deferred to the response:
|
|
8
8
|
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* - a navigation during the flight is an ordinary one: a prefetch is served,
|
|
10
|
+
* and the page it commits may hold pre-write data until the follow-up below,
|
|
11
|
+
* - a navigation without a prefetch, and a prefetch, fetch with
|
|
12
|
+
* `cache: "no-store"` so they cannot be served stale bytes from the
|
|
13
|
+
* browser's Vary-keyed HTTP cache,
|
|
12
14
|
* - popstate reads are treated as stale-while-revalidate.
|
|
13
15
|
*
|
|
14
16
|
* Nothing is wiped, rotated, or broadcast while the fence is up — that is what
|
|
@@ -16,6 +18,12 @@
|
|
|
16
18
|
* actions compose: each action raises and lowers its own reference, and the
|
|
17
19
|
* fence is down only when the count reaches zero.
|
|
18
20
|
*
|
|
21
|
+
* The drop runs the batch's follow-up: when an action of the batch did not
|
|
22
|
+
* land on the page on screen, the action bridge refetches that page once,
|
|
23
|
+
* whole, after the navigation the user started, unless an intercept is on
|
|
24
|
+
* screen (server-action-bridge.ts followUp; the rules are R1-R7 in
|
|
25
|
+
* docs/internal/execution-model.md).
|
|
26
|
+
*
|
|
19
27
|
* The refcount is a single module-level counter, not keyed by routerId.
|
|
20
28
|
* Consumers (navigation-client.ts, prefetch/fetch.ts, navigation-bridge.ts)
|
|
21
29
|
* read it unscoped, so an action in one router would suppress another router's
|
|
@@ -52,6 +52,18 @@ export interface NavigationEntry {
|
|
|
52
52
|
phase: NavigationPhase;
|
|
53
53
|
startedAt: number;
|
|
54
54
|
options?: NavigateOptionsInternal & { skipLoadingState?: boolean };
|
|
55
|
+
/**
|
|
56
|
+
* Resolves once the navigation has ended: its transaction ended and every
|
|
57
|
+
* stream it opened closed. True when it committed, false when it ended
|
|
58
|
+
* without a commit (a failure) or was left (aborted, which settles it at
|
|
59
|
+
* once). A `prefetch: false` fill holds the transaction open
|
|
60
|
+
* (partial-update.ts awaits fillHoles before it resolves) and its stream
|
|
61
|
+
* until the fill's answer streamed, so a waiter never cuts a fill or a
|
|
62
|
+
* stream short. Only the action bridge's follow-up awaits it
|
|
63
|
+
* (server-action-bridge.ts followUp); no hook reads it, and settling it
|
|
64
|
+
* notifies nothing.
|
|
65
|
+
*/
|
|
66
|
+
settled: Promise<boolean>;
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
/**
|
|
@@ -351,6 +363,12 @@ export interface EventController {
|
|
|
351
363
|
|
|
352
364
|
// Direct state access for advanced use
|
|
353
365
|
getCurrentNavigation(): NavigationEntry | null;
|
|
366
|
+
/**
|
|
367
|
+
* The newest navigation until it settles (NavigationEntry.settled): before
|
|
368
|
+
* its commit, and after it while its transaction or a stream is still
|
|
369
|
+
* open. Null once it settled or was left.
|
|
370
|
+
*/
|
|
371
|
+
getUnsettledNavigation(): NavigationEntry | null;
|
|
354
372
|
getInflightActions(): Map<string, ActionEntry>;
|
|
355
373
|
/** Whether any concurrent actions have occurred (shared across all handles) */
|
|
356
374
|
hadAnyConcurrentActions(): boolean;
|
|
@@ -513,6 +531,9 @@ export function createEventController(
|
|
|
513
531
|
// links a request to its signal only until then): the navigation stops
|
|
514
532
|
// reading it, and it streams to its end.
|
|
515
533
|
let streamingNavigation: AbortController | null = null;
|
|
534
|
+
// The newest navigation until it settles (getUnsettledNavigation). Not
|
|
535
|
+
// derived state: no hook reads it, and moving it notifies nothing.
|
|
536
|
+
let unsettledNavigation: NavigationEntry | null = null;
|
|
516
537
|
|
|
517
538
|
const inflightActions = new Map<string, ActionEntry>();
|
|
518
539
|
|
|
@@ -743,19 +764,31 @@ export function createEventController(
|
|
|
743
764
|
}
|
|
744
765
|
|
|
745
766
|
const abort = new AbortController();
|
|
767
|
+
let resolveSettled!: (committed: boolean) => void;
|
|
746
768
|
const entry: NavigationEntry = {
|
|
747
769
|
url,
|
|
748
770
|
abort,
|
|
749
771
|
phase: "fetching",
|
|
750
772
|
startedAt: Date.now(),
|
|
751
773
|
options,
|
|
774
|
+
settled: new Promise((resolve) => (resolveSettled = resolve)),
|
|
775
|
+
};
|
|
776
|
+
const settle = (committed: boolean): void => {
|
|
777
|
+
if (unsettledNavigation === entry) unsettledNavigation = null;
|
|
778
|
+
resolveSettled(committed);
|
|
752
779
|
};
|
|
780
|
+
abort.signal.addEventListener("abort", () => settle(false));
|
|
753
781
|
|
|
754
782
|
currentNavigation = entry;
|
|
783
|
+
unsettledNavigation = entry;
|
|
755
784
|
notify();
|
|
756
785
|
|
|
757
786
|
let completed = false;
|
|
787
|
+
let disposed = false;
|
|
758
788
|
let openStreams = 0;
|
|
789
|
+
const settleIfEnded = (): void => {
|
|
790
|
+
if (disposed && openStreams === 0) settle(completed);
|
|
791
|
+
};
|
|
759
792
|
|
|
760
793
|
return {
|
|
761
794
|
abort,
|
|
@@ -780,6 +813,7 @@ export function createEventController(
|
|
|
780
813
|
if (--openStreams === 0 && streamingNavigation === abort) {
|
|
781
814
|
streamingNavigation = null;
|
|
782
815
|
}
|
|
816
|
+
settleIfEnded();
|
|
783
817
|
notify();
|
|
784
818
|
},
|
|
785
819
|
};
|
|
@@ -797,6 +831,8 @@ export function createEventController(
|
|
|
797
831
|
|
|
798
832
|
// Disposable: cleanup if not completed (e.g., error thrown)
|
|
799
833
|
[Symbol.dispose]() {
|
|
834
|
+
disposed = true;
|
|
835
|
+
settleIfEnded();
|
|
800
836
|
// If aborted by another navigation, don't touch state
|
|
801
837
|
if (abort.signal.aborted) return;
|
|
802
838
|
|
|
@@ -1287,6 +1323,7 @@ export function createEventController(
|
|
|
1287
1323
|
|
|
1288
1324
|
// Direct access
|
|
1289
1325
|
getCurrentNavigation: () => currentNavigation,
|
|
1326
|
+
getUnsettledNavigation: () => unsettledNavigation,
|
|
1290
1327
|
getInflightActions: () => inflightActions,
|
|
1291
1328
|
hadAnyConcurrentActions: () => hadAnyConcurrentActions,
|
|
1292
1329
|
};
|
|
@@ -169,13 +169,21 @@ export function stripShellMissMarker(): void {
|
|
|
169
169
|
*
|
|
170
170
|
* A slot is recognised by its key alone (the `~r` suffix): at start-up the
|
|
171
171
|
* module that defines it may not be loaded.
|
|
172
|
+
*
|
|
173
|
+
* The same pass drops the entry's intercept marks (`intercept`,
|
|
174
|
+
* `sourceUrl`): a document request never renders an intercept, so the entry
|
|
175
|
+
* is a full page for later Back/Forward.
|
|
172
176
|
*/
|
|
173
177
|
export function clearLocationStateOnDocumentLoad(): void {
|
|
174
178
|
const state: unknown = window.history.state;
|
|
175
179
|
if (state === null || typeof state !== "object") return;
|
|
176
180
|
let next: Record<string, unknown> | undefined;
|
|
177
181
|
for (const key in state) {
|
|
178
|
-
if (
|
|
182
|
+
if (
|
|
183
|
+
(isLocationStateKey(key) && key.endsWith(CLEAR_ON_RELOAD_KEY_SUFFIX)) ||
|
|
184
|
+
key === "intercept" ||
|
|
185
|
+
key === "sourceUrl"
|
|
186
|
+
) {
|
|
179
187
|
next ??= { ...state };
|
|
180
188
|
delete next[key];
|
|
181
189
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ResolvedSegment } from "./types.js";
|
|
2
2
|
import type { SlotState } from "../types.js";
|
|
3
|
+
import { isPlaceholder } from "../types/segments.js";
|
|
4
|
+
import { loaderParentId } from "./react/filter-segment-order.js";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Check if a segment is an intercept segment.
|
|
@@ -51,3 +53,52 @@ export function hasActiveIntercept(slots?: Record<string, SlotState>): boolean {
|
|
|
51
53
|
export function isInterceptOnlyCache(segments: ResolvedSegment[]): boolean {
|
|
52
54
|
return segments.some(isInterceptSegment);
|
|
53
55
|
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* An intercept's `matched`: the client's ids without any of the slot's (its
|
|
59
|
+
* id and every loader segment on it, another intercept's into the same slot
|
|
60
|
+
* included), then the slot's (interceptSegmentIds). One order for the
|
|
61
|
+
* prefetch, its fill, an action and a revisit: a fill whose `matched` differs
|
|
62
|
+
* from its tree's is rendered as a new tree (browser/partial-update.ts
|
|
63
|
+
* fillHoles). Shared by router/match-result.ts and wholeInterceptMatched.
|
|
64
|
+
*/
|
|
65
|
+
export function interceptMatched(
|
|
66
|
+
clientIds: readonly string[],
|
|
67
|
+
slotIds: readonly string[],
|
|
68
|
+
): string[] {
|
|
69
|
+
// loaderParentId leaves a non-loader id as it is: the slot's own id goes too.
|
|
70
|
+
return [
|
|
71
|
+
...clientIds.filter((id) => loaderParentId(id) !== slotIds[0]),
|
|
72
|
+
...slotIds,
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The `matched` the browser keeps for an intercept answer to a request that
|
|
78
|
+
* held no segments (a followed redirect, a Back/Forward revalidation with
|
|
79
|
+
* handles pending, the dev HMR refetch), or undefined to keep the answer's.
|
|
80
|
+
* The server never renders the page under an intercept and lists it only
|
|
81
|
+
* from the ids a request held, so such an answer has an empty outlet under
|
|
82
|
+
* the slot. The page it lands over (`held`) stays, as the server lists it for
|
|
83
|
+
* a request that holds it.
|
|
84
|
+
*/
|
|
85
|
+
export function wholeInterceptMatched(
|
|
86
|
+
held: readonly ResolvedSegment[],
|
|
87
|
+
answer: {
|
|
88
|
+
matched?: string[];
|
|
89
|
+
segments?: ResolvedSegment[];
|
|
90
|
+
slots?: Record<string, SlotState>;
|
|
91
|
+
},
|
|
92
|
+
): string[] | undefined {
|
|
93
|
+
if (!hasActiveIntercept(answer.slots)) return undefined;
|
|
94
|
+
const heldIds = held.filter((s) => !isPlaceholder(s)).map((s) => s.id);
|
|
95
|
+
if (heldIds.length === 0) return undefined;
|
|
96
|
+
const slot = answer.segments?.find(
|
|
97
|
+
(s) => isInterceptSegment(s) && s.type !== "loader",
|
|
98
|
+
);
|
|
99
|
+
if (!slot) return undefined;
|
|
100
|
+
const slotLoaderIds = (answer.matched ?? []).filter(
|
|
101
|
+
(id) => id !== slot.id && loaderParentId(id) === slot.id,
|
|
102
|
+
);
|
|
103
|
+
return interceptMatched(heldIds, [slot.id, ...slotLoaderIds]);
|
|
104
|
+
}
|
|
@@ -643,16 +643,17 @@ export function createNavigationBridge(
|
|
|
643
643
|
const isIntercept = historyState?.intercept === true;
|
|
644
644
|
const interceptSourceUrl = historyState?.sourceUrl;
|
|
645
645
|
|
|
646
|
-
//
|
|
647
|
-
//
|
|
646
|
+
// Actions in flight keep running across an intercept change, as across
|
|
647
|
+
// any navigation (server-action-bridge.ts followUp).
|
|
648
648
|
const currentInterceptSource = store.getInterceptSourceUrl();
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
649
|
+
// An intercept is fetched only from its source page (the page under
|
|
650
|
+
// an open intercept counts as on screen). From any other page the
|
|
651
|
+
// entry loads as a document, which renders its URL's full page.
|
|
652
|
+
const loadsDocument =
|
|
653
|
+
isIntercept &&
|
|
654
|
+
generateHistoryKey(
|
|
655
|
+
currentInterceptSource ?? store.getSegmentState().currentUrl,
|
|
656
|
+
) !== generateHistoryKey(interceptSourceUrl);
|
|
656
657
|
|
|
657
658
|
// Popstate that exits an intercept to a non-intercept destination. The
|
|
658
659
|
// fallback fetch path below needs `leave-intercept` mode so it filters
|
|
@@ -673,13 +674,6 @@ export function createNavigationBridge(
|
|
|
673
674
|
historyKey,
|
|
674
675
|
);
|
|
675
676
|
|
|
676
|
-
// If this is an intercept, restore the intercept context
|
|
677
|
-
if (isIntercept && interceptSourceUrl) {
|
|
678
|
-
store.setInterceptSourceUrl(interceptSourceUrl);
|
|
679
|
-
} else {
|
|
680
|
-
store.setInterceptSourceUrl(null);
|
|
681
|
-
}
|
|
682
|
-
|
|
683
677
|
// Helper to check if streaming is in progress
|
|
684
678
|
const isStreaming = () => eventController.getState().isStreaming;
|
|
685
679
|
|
|
@@ -701,6 +695,7 @@ export function createNavigationBridge(
|
|
|
701
695
|
const isStale = (cached?.stale ?? false) || isActionFenceActive();
|
|
702
696
|
|
|
703
697
|
if (isRestorable(cachedSegments)) {
|
|
698
|
+
store.setInterceptSourceUrl(interceptSourceUrl ?? null);
|
|
704
699
|
// transition({ when }) decides the restore (kind "pop") against the
|
|
705
700
|
// entry being left, so before the store moves to the restored entry.
|
|
706
701
|
// The decision the entry was committed with is not reused: it was
|
|
@@ -872,7 +867,15 @@ export function createNavigationBridge(
|
|
|
872
867
|
debugLog("[Browser] History cache miss for key:", historyKey);
|
|
873
868
|
}
|
|
874
869
|
|
|
875
|
-
// Fetch if not cached
|
|
870
|
+
// Fetch if not cached. The document load starts at the top: pagehide
|
|
871
|
+
// saves the scroll on screen under this entry, and the browser
|
|
872
|
+
// restores it after the reload. Instant, so `scroll-behavior: smooth`
|
|
873
|
+
// cannot leave it part-way when pagehide saves it.
|
|
874
|
+
if (loadsDocument) {
|
|
875
|
+
window.scrollTo({ top: 0, behavior: "instant" });
|
|
876
|
+
return window.location.reload();
|
|
877
|
+
}
|
|
878
|
+
store.setInterceptSourceUrl(interceptSourceUrl ?? null);
|
|
876
879
|
const tx = createNavigationTransaction(store, eventController, url, {
|
|
877
880
|
replace: true,
|
|
878
881
|
});
|