@rangojs/router 0.0.0-experimental.136 → 0.0.0-experimental.137
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/breadcrumbs/SKILL.md +29 -12
- package/skills/testing/handles.md +4 -2
- package/src/browser/event-controller.ts +21 -0
- package/src/browser/react/NavigationProvider.tsx +68 -157
- package/src/browser/server-action-bridge.ts +22 -10
- package/src/client.rsc.tsx +0 -3
- package/src/client.tsx +0 -3
- package/src/defer.ts +11 -22
- package/src/handle.ts +37 -15
- package/src/handles/MetaTags.tsx +16 -88
- package/src/handles/breadcrumbs.ts +12 -14
- package/src/handles/deferred-resolution.ts +127 -0
- package/src/handles/is-thenable.ts +7 -8
- package/src/handles/meta.ts +7 -44
- package/src/index.rsc.ts +0 -1
- package/src/index.ts +0 -1
- package/src/prerender.ts +17 -4
- package/src/router/prerender-match.ts +7 -3
- package/src/rsc/handler.ts +3 -1
- package/src/rsc/progressive-enhancement.ts +5 -2
- package/src/rsc/rsc-rendering.ts +5 -1
- package/src/ssr/index.tsx +1 -0
- package/src/testing/collect-handle.ts +14 -8
- package/src/testing/render-route.tsx +6 -1
- package/src/browser/react/deferred-handle-resolution.ts +0 -75
package/dist/vite/index.js
CHANGED
|
@@ -2297,7 +2297,7 @@ import { resolve } from "node:path";
|
|
|
2297
2297
|
// package.json
|
|
2298
2298
|
var package_default = {
|
|
2299
2299
|
name: "@rangojs/router",
|
|
2300
|
-
version: "0.0.0-experimental.
|
|
2300
|
+
version: "0.0.0-experimental.137",
|
|
2301
2301
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
2302
2302
|
keywords: [
|
|
2303
2303
|
"react",
|
package/package.json
CHANGED
|
@@ -90,7 +90,9 @@ a deep async component, not the handler — call `.defer()` on the push function
|
|
|
90
90
|
slot synchronously and returns a **resolver that is push-equal** — you call it
|
|
91
91
|
later, anywhere in the render, with the same argument you'd have passed to the
|
|
92
92
|
push (a value, a `Promise`, or a thunk). The only added behavior is a timeout, so a
|
|
93
|
-
forgotten resolve can't
|
|
93
|
+
forgotten resolve can't hang the render (and the HTTP response): resolve-by-default
|
|
94
|
+
awaits the reserved slot before any consumer reads it, and the timeout guarantees it
|
|
95
|
+
settles to `else` instead of blocking forever.
|
|
94
96
|
|
|
95
97
|
Reserve the slot in the handler, then resolve it from a nested async component
|
|
96
98
|
that closes over the resolver — no extra wiring (the resolver is a plain closure,
|
|
@@ -131,15 +133,17 @@ hung request. `timeoutMs: 0` or `Infinity` disable the timeout intentionally; an
|
|
|
131
133
|
other non-finite or negative value falls back to the default rather than silently
|
|
132
134
|
disabling the safety net.
|
|
133
135
|
|
|
134
|
-
**Consumer note
|
|
135
|
-
|
|
136
|
-
`Promise
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
136
|
+
**Consumer note (resolve-by-default):** a deferred crumb is RESOLVED before any
|
|
137
|
+
consumer sees it — `useHandle(Breadcrumbs)` returns the resolved item, never a
|
|
138
|
+
`Promise`, so you read it like any sync crumb (no `use()`, no thenable narrowing).
|
|
139
|
+
On a full/SSR load the value is resolved server-side; on a soft navigation the
|
|
140
|
+
breadcrumbs HOLD the previous resolved value until the deferred value lands, then
|
|
141
|
+
swap in — no blank, no pending entry. If the slot times out to `else: null`/
|
|
142
|
+
undefined, the entry is simply dropped. Use `.defer()` only when even
|
|
143
|
+
`label`/`href` are unknown at handler time — if you know them and only the
|
|
144
|
+
`content` is async, push a concrete item with a `Promise` `content` field instead
|
|
145
|
+
(the `content` field is a nested promise you resolve with `use()` in your
|
|
146
|
+
component; no `.defer()` needed).
|
|
143
147
|
|
|
144
148
|
## Consuming Breadcrumbs (Client)
|
|
145
149
|
|
|
@@ -274,15 +278,28 @@ Create your own handle with `createHandle()`:
|
|
|
274
278
|
```typescript
|
|
275
279
|
import { createHandle } from "@rangojs/router";
|
|
276
280
|
|
|
277
|
-
//
|
|
281
|
+
// Custom collect: last value wins.
|
|
278
282
|
export const PageTitle = createHandle<string, string>(
|
|
279
283
|
(segments) => segments.flat().at(-1) ?? "Default Title",
|
|
280
284
|
);
|
|
281
285
|
|
|
282
|
-
// No collect
|
|
286
|
+
// No collect: the DEFAULT is the identity (lossless) — `collect` receives the
|
|
287
|
+
// per-segment data (TData[][], one array per segment that pushed, in segment
|
|
288
|
+
// order) and passes it through as-is. `useHandle(Warnings)` is `string[][]`, so a
|
|
289
|
+
// consumer can tell which/how-many segments contributed.
|
|
283
290
|
export const Warnings = createHandle<string>();
|
|
291
|
+
|
|
292
|
+
// Want a single flat list instead? Opt in:
|
|
293
|
+
export const FlatWarnings = createHandle<string, string[]>((segments) =>
|
|
294
|
+
segments.flat(),
|
|
295
|
+
);
|
|
284
296
|
```
|
|
285
297
|
|
|
298
|
+
A handle whose module is never imported (so `createHandle()` never ran to register
|
|
299
|
+
its collect) falls back to this same identity default and **warns in dev** — a
|
|
300
|
+
handle with a custom collect that failed to register would otherwise return the
|
|
301
|
+
wrong shape silently, and the runtime can't tell it from one that wanted the default.
|
|
302
|
+
|
|
286
303
|
The Vite `exposeInternalIds` plugin auto-injects a stable `$$id` based on
|
|
287
304
|
file path and export name. No manual naming required for project-local code.
|
|
288
305
|
|
|
@@ -13,7 +13,7 @@ A handle's `collect`/accumulator (the `createHandle(collect)` argument that maps
|
|
|
13
13
|
| `handle` | `Handle<TData, TAccumulated>` | The handle whose registered collect to run. |
|
|
14
14
|
| `segments` | `ReadonlyArray<ReadonlyArray<TData>>` | Per-segment pushed values, one inner array per route segment, in **parent -> child** order. Empty inner arrays are filtered before the collect runs (matching production `collectHandleData` — a segment that pushed nothing is not passed through). |
|
|
15
15
|
|
|
16
|
-
**Returns** `TAccumulated` — exactly what the handle's collect produces (
|
|
16
|
+
**Returns** `TAccumulated` — exactly what the handle's collect produces (the default identity collect's per-segment `TData[][]`, or a custom accumulator's value). If the handle's module was never imported (collect unregistered), it falls back to that same identity default and **warns** — a handle with a custom collect that failed to register would otherwise return the wrong shape silently.
|
|
17
17
|
|
|
18
18
|
### runLoader option — `handles` — `src/testing/run-loader.ts`
|
|
19
19
|
|
|
@@ -40,7 +40,9 @@ import { describe, it, expect } from "vitest";
|
|
|
40
40
|
import { collectHandle } from "@rangojs/router/testing";
|
|
41
41
|
import { createHandle } from "@rangojs/router";
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
// Opt into a flat list; the default collect groups per segment (TData[][]).
|
|
44
|
+
type Crumb = { label: string; href: string };
|
|
45
|
+
const Breadcrumbs = createHandle<Crumb, Crumb[]>((segments) => segments.flat());
|
|
44
46
|
|
|
45
47
|
it("flattens per-segment crumbs in parent->child order", () => {
|
|
46
48
|
const home = { label: "Home", href: "/" };
|
|
@@ -249,6 +249,14 @@ export interface EventController {
|
|
|
249
249
|
resolvedIds?: string[],
|
|
250
250
|
): void;
|
|
251
251
|
getHandleState(): HandleState;
|
|
252
|
+
/**
|
|
253
|
+
* Update ONLY `routeSegmentIds` (what `useSegments` reads) from `matched`,
|
|
254
|
+
* leaving `data` and `segmentOrder` (what `useHandle` collects over) untouched.
|
|
255
|
+
* Used while a deferred handle is resolving: the route has changed (so
|
|
256
|
+
* `useSegments` must reflect the new segment ids) but `useHandle` still holds
|
|
257
|
+
* its previous value until the deferred snapshot is applied.
|
|
258
|
+
*/
|
|
259
|
+
setRouteSegmentIds(matched: string[]): void;
|
|
252
260
|
|
|
253
261
|
// Params operations
|
|
254
262
|
setParams(params: Record<string, string>): void;
|
|
@@ -860,6 +868,18 @@ export function createEventController(
|
|
|
860
868
|
};
|
|
861
869
|
}
|
|
862
870
|
|
|
871
|
+
function setRouteSegmentIds(matched: string[]): void {
|
|
872
|
+
const next = filterRouteSegmentIds(matched);
|
|
873
|
+
if (
|
|
874
|
+
next.length === routeSegmentIds.length &&
|
|
875
|
+
next.every((id, i) => id === routeSegmentIds[i])
|
|
876
|
+
) {
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
routeSegmentIds = next;
|
|
880
|
+
notifyHandles();
|
|
881
|
+
}
|
|
882
|
+
|
|
863
883
|
// ========================================================================
|
|
864
884
|
// Subscriptions
|
|
865
885
|
// ========================================================================
|
|
@@ -928,6 +948,7 @@ export function createEventController(
|
|
|
928
948
|
// Handles
|
|
929
949
|
setHandleData,
|
|
930
950
|
getHandleState,
|
|
951
|
+
setRouteSegmentIds,
|
|
931
952
|
|
|
932
953
|
// Params
|
|
933
954
|
setParams,
|
|
@@ -32,56 +32,10 @@ import { createAppShellRef, type AppShellRef } from "../app-shell.js";
|
|
|
32
32
|
import { startConnectionWarmup } from "../connection-warmup.js";
|
|
33
33
|
import { debugLog } from "../logging.js";
|
|
34
34
|
import { cloneHandleData } from "../navigation-store.js";
|
|
35
|
-
import { collectHandleData } from "../../handle.js";
|
|
36
|
-
import { Meta } from "../../handles/meta.js";
|
|
37
|
-
import type { MetaDescriptor } from "../../router/types.js";
|
|
38
35
|
import {
|
|
39
|
-
|
|
40
|
-
hasDeferredHandleValue,
|
|
36
|
+
deferredHandleNames,
|
|
41
37
|
resolveDeferredHandleValues,
|
|
42
|
-
} from "
|
|
43
|
-
|
|
44
|
-
/** Meta handle-name key. Meta is the only head-placed handle whose consumer
|
|
45
|
-
* use()s a deferred value above the route <Suspense>, so it must be resolved in
|
|
46
|
-
* the store before apply; every other handle keeps the promise contract. */
|
|
47
|
-
const META = "__rsc_router_meta__";
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Carry the previous page's COLLECTED Meta forward so the title is kept (no
|
|
51
|
-
* blank) while a deferred Meta resolves on a soft navigation.
|
|
52
|
-
*
|
|
53
|
-
* Why a carry-forward and not just preserving the previous Meta data: handle
|
|
54
|
-
* collection (useHandle/MetaTags) is driven by the event controller's
|
|
55
|
-
* `segmentOrder`, which becomes the NEW route's order so the synchronous
|
|
56
|
-
* breadcrumbs render immediately. The previous route's title lives under a
|
|
57
|
-
* segment that is NOT in the new order, so it would stop being collected — the
|
|
58
|
-
* title would fall back to the layout default. Re-keying the previous COLLECTED
|
|
59
|
-
* descriptors under a segment that IS in the new order keeps them visible.
|
|
60
|
-
*
|
|
61
|
-
* Title descriptors are wrapped as `{ title: { absolute } }` so re-collection
|
|
62
|
-
* under a (possibly template-bearing) new layout does not re-apply a title
|
|
63
|
-
* template to an already-final title. Promise and default (charSet/viewport)
|
|
64
|
-
* descriptors are dropped: Promise ones would suspend MetaTags, and the defaults
|
|
65
|
-
* are re-added by collectMeta.
|
|
66
|
-
*/
|
|
67
|
-
function carriedPreviousMeta(prev: MetaDescriptor[]): MetaDescriptor[] {
|
|
68
|
-
const out: MetaDescriptor[] = [];
|
|
69
|
-
for (const d of prev) {
|
|
70
|
-
if (d && typeof (d as { then?: unknown }).then === "function") continue;
|
|
71
|
-
const base = d as Exclude<MetaDescriptor, Promise<unknown>>;
|
|
72
|
-
if ("charSet" in base) continue;
|
|
73
|
-
if ("name" in base && (base as { name?: unknown }).name === "viewport") {
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
if ("title" in base) {
|
|
77
|
-
const t = (base as { title: unknown }).title;
|
|
78
|
-
out.push({ title: { absolute: typeof t === "string" ? t : String(t) } });
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
out.push(base);
|
|
82
|
-
}
|
|
83
|
-
return out;
|
|
84
|
-
}
|
|
38
|
+
} from "../../handles/deferred-resolution.js";
|
|
85
39
|
|
|
86
40
|
/**
|
|
87
41
|
* Process handles from an async generator, updating the event controller
|
|
@@ -144,131 +98,88 @@ async function processHandles(
|
|
|
144
98
|
|
|
145
99
|
yieldCount++;
|
|
146
100
|
|
|
147
|
-
// Resolve
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
// handles AND non-Meta deferred promises apply/stream through immediately —
|
|
154
|
-
// only Meta is held back and swapped in once resolved.
|
|
155
|
-
const metaDeferred = hasDeferredHandleValue(
|
|
156
|
-
handleData,
|
|
157
|
-
HEAD_RESOLVE_HANDLE_NAMES,
|
|
158
|
-
);
|
|
101
|
+
// Resolve-by-default: hold the previous resolved value until this yield's
|
|
102
|
+
// deferred (Promise) handle values settle, then apply the fully-resolved
|
|
103
|
+
// snapshot. The hold needs NO extra state — we simply do not touch the store
|
|
104
|
+
// until the values resolve, so useHandle keeps reading (and showing) the
|
|
105
|
+
// previous data. A yield with no deferred value applies synchronously.
|
|
106
|
+
const hasDeferred = deferredHandleNames(handleData).size > 0;
|
|
159
107
|
|
|
160
|
-
|
|
161
|
-
// call (Meta included). When Meta IS deferred, replace the deferred Meta with
|
|
162
|
-
// the previous page's COLLECTED Meta (stale-while-revalidate — never a blank
|
|
163
|
-
// title) keyed under one of the NEW route's Meta segments, so it stays
|
|
164
|
-
// collected under the new segment order while the synchronous and non-Meta
|
|
165
|
-
// deferred handles update with normal cleanup. The resolved Meta is swapped
|
|
166
|
-
// in by the partial merge below.
|
|
167
|
-
if (metaDeferred) {
|
|
168
|
-
const immediate: HandleData = { ...handleData };
|
|
169
|
-
const metaSegments = handleData[META] ?? {};
|
|
170
|
-
// Anchor: the last new Meta segment in matched order (collected after the
|
|
171
|
-
// shared layout, so its carried title wins). Falls back to any new Meta
|
|
172
|
-
// segment if matched ordering does not surface one.
|
|
173
|
-
const metaSegmentIds = Object.keys(metaSegments);
|
|
174
|
-
const ordered = (matched ?? []).filter((id) =>
|
|
175
|
-
metaSegmentIds.includes(id),
|
|
176
|
-
);
|
|
177
|
-
const anchor = ordered.at(-1) ?? metaSegmentIds.at(-1);
|
|
178
|
-
|
|
179
|
-
const prevState = eventController.getHandleState();
|
|
180
|
-
const prevCollected = collectHandleData(
|
|
181
|
-
Meta,
|
|
182
|
-
prevState.data,
|
|
183
|
-
prevState.segmentOrder,
|
|
184
|
-
) as MetaDescriptor[];
|
|
185
|
-
const carried = carriedPreviousMeta(prevCollected);
|
|
186
|
-
|
|
187
|
-
if (anchor && carried.length > 0) {
|
|
188
|
-
immediate[META] = { [anchor]: carried };
|
|
189
|
-
} else {
|
|
190
|
-
// No previous Meta to carry and/or no anchor: leave Meta unset until it
|
|
191
|
-
// resolves (the documented no-previous-Meta behavior).
|
|
192
|
-
delete immediate[META];
|
|
193
|
-
}
|
|
194
|
-
eventController.setHandleData(immediate, matched, isPartial, resolvedIds);
|
|
195
|
-
} else {
|
|
108
|
+
if (!hasDeferred) {
|
|
196
109
|
eventController.setHandleData(
|
|
197
110
|
handleData,
|
|
198
111
|
matched,
|
|
199
112
|
isPartial,
|
|
200
113
|
resolvedIds,
|
|
201
114
|
);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
// Snapshot of the nav's full applied handle state (sync handles, non-Meta
|
|
205
|
-
// deferred promises, and — when Meta is deferred — the carried previous Meta).
|
|
206
|
-
// Captured AFTER applying so it reflects what is actually on screen now.
|
|
207
|
-
const baseSnapshot = cloneHandleData(eventController.getHandleState().data);
|
|
208
|
-
|
|
209
|
-
if (!metaDeferred) {
|
|
210
|
-
// Non-deferred: the applied snapshot is final. Keep the cache in sync and
|
|
211
|
-
// fresh. The token guard stops a stale same-URL nav writing a newer entry.
|
|
115
|
+
// Keep the cache fresh. The token guard stops a stale same-URL nav writing
|
|
116
|
+
// a newer entry.
|
|
212
117
|
if (store.getCacheEntryInstance(historyKey) === myInstance) {
|
|
213
|
-
store.updateCacheHandleData(
|
|
118
|
+
store.updateCacheHandleData(
|
|
119
|
+
historyKey,
|
|
120
|
+
eventController.getHandleState().data,
|
|
121
|
+
false,
|
|
122
|
+
);
|
|
214
123
|
}
|
|
215
124
|
continue;
|
|
216
125
|
}
|
|
217
126
|
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
//
|
|
127
|
+
// The PREVIOUS (held) snapshot — captured before the await so the cache and
|
|
128
|
+
// the navigate-away merge below reflect what useHandle is still showing.
|
|
129
|
+
const previousSnapshot = cloneHandleData(
|
|
130
|
+
eventController.getHandleState().data,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// The route HAS changed even though the handle data is held, so update
|
|
134
|
+
// `routeSegmentIds` (what useSegments reads) now. This leaves `data` /
|
|
135
|
+
// `segmentOrder` (what useHandle collects over) untouched, so useHandle keeps
|
|
136
|
+
// holding its previous value while useSegments reflects the new route.
|
|
137
|
+
eventController.setRouteSegmentIds(matched ?? []);
|
|
138
|
+
|
|
139
|
+
// Deferred-pending: the new values are not applied yet (the previous value is
|
|
140
|
+
// held), so the cache entry must NOT be served as fresh on a popstate return.
|
|
141
|
+
// Mark it STALE + handlesPending (token-guarded), storing the PREVIOUS (held)
|
|
142
|
+
// snapshot. P1 fix: a deferred value is a SERVER-side promise streamed via
|
|
143
|
+
// Flight, so a navigate-away ABORTS the stream and the resolve below never
|
|
144
|
+
// settles. stale makes a popstate return revalidate; handlesPending makes that
|
|
225
145
|
// revalidation a FULL re-render (no client segment IDs) so the server
|
|
226
|
-
// re-streams the handles
|
|
227
|
-
// segments' handles and the deferred
|
|
228
|
-
// segmentIds branch in navigation-bridge.ts.
|
|
146
|
+
// re-streams the handles — a diff-only revalidation would omit the unchanged
|
|
147
|
+
// segments' handles and the deferred value would never land (see the
|
|
148
|
+
// segmentIds branch in navigation-bridge.ts).
|
|
229
149
|
if (store.getCacheEntryInstance(historyKey) === myInstance) {
|
|
230
|
-
store.updateCacheHandleData(historyKey,
|
|
150
|
+
store.updateCacheHandleData(historyKey, previousSnapshot, true, true);
|
|
231
151
|
}
|
|
232
152
|
|
|
233
|
-
// Resolve
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
);
|
|
264
|
-
} else if (store.getCacheEntryInstance(historyKey) === myInstance) {
|
|
265
|
-
// Navigated away, but THIS nav still owns the target cache entry: write
|
|
266
|
-
// the resolved data and clear stale + handlesPending so a popstate return
|
|
267
|
-
// is fresh.
|
|
268
|
-
store.updateCacheHandleData(historyKey, cacheValue, false, false);
|
|
269
|
-
}
|
|
270
|
-
// else: a newer nav to the same URL superseded us — do nothing.
|
|
271
|
-
});
|
|
153
|
+
// Resolve every deferred value (allSettled; rejected + nullish dropped, sync
|
|
154
|
+
// values pass through). Each stream yield is a full cumulative snapshot.
|
|
155
|
+
const resolved = await resolveDeferredHandleValues(handleData);
|
|
156
|
+
|
|
157
|
+
if (!stillLive()) {
|
|
158
|
+
// Navigated away (or a same-URL nav superseded us) while resolving. We do
|
|
159
|
+
// NOT write `resolved` into the entry. It is THIS yield's snapshot only (on a
|
|
160
|
+
// partial nav, just the re-resolved segments' buckets), and a correct write
|
|
161
|
+
// needs setHandleData's nested per-segment merge + matched/resolvedIds
|
|
162
|
+
// cleanup: HandleData is handleName -> segmentId -> entries[], so a
|
|
163
|
+
// handle-name-level spread would drop a shared layout bucket (e.g. a
|
|
164
|
+
// Breadcrumbs layout crumb under L0 when the route pushed under R0) and would
|
|
165
|
+
// mark stale previous-route buckets fresh. We cannot run that merge here
|
|
166
|
+
// without touching the now-different live page. Instead leave the entry as it
|
|
167
|
+
// was marked before the await — stale + handlesPending — so a popstate return
|
|
168
|
+
// revalidates with a full re-render and re-streams the handles. A newer nav
|
|
169
|
+
// owning the entry has already overwritten it; nothing to do either way.
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Still live: apply the fully-resolved snapshot and refresh the cache fresh.
|
|
174
|
+
eventController.setHandleData(resolved, matched, isPartial, resolvedIds);
|
|
175
|
+
if (store.getCacheEntryInstance(historyKey) === myInstance) {
|
|
176
|
+
store.updateCacheHandleData(
|
|
177
|
+
historyKey,
|
|
178
|
+
eventController.getHandleState().data,
|
|
179
|
+
false,
|
|
180
|
+
false,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
272
183
|
}
|
|
273
184
|
|
|
274
185
|
// Check again before final updates
|
|
@@ -558,18 +558,15 @@ export function createServerActionBridge(
|
|
|
558
558
|
return undefined;
|
|
559
559
|
}
|
|
560
560
|
|
|
561
|
-
// Update UI with error boundary
|
|
562
|
-
startTransition(() => {
|
|
563
|
-
onUpdate({ root: errorTree, metadata: metadata! });
|
|
564
|
-
});
|
|
565
|
-
|
|
566
561
|
// Update segment tracking to exclude error segment IDs
|
|
567
562
|
const errorSegmentIds = new Set(diff);
|
|
568
563
|
const segmentIdsAfterError = segmentState.currentSegmentIds.filter(
|
|
569
564
|
(id) => !errorSegmentIds.has(id),
|
|
570
565
|
);
|
|
571
566
|
|
|
572
|
-
//
|
|
567
|
+
// Cache (and bump the nav instance) BEFORE the UI update so a deferred
|
|
568
|
+
// handle pushed by the error-boundary render still applies — see the
|
|
569
|
+
// "normal" case below for why caching after onUpdate dropped it.
|
|
573
570
|
store.setSegmentIds(segmentIdsAfterError);
|
|
574
571
|
const currentHandleData = eventController.getHandleState().data;
|
|
575
572
|
store.cacheSegmentsForHistory(
|
|
@@ -578,6 +575,11 @@ export function createServerActionBridge(
|
|
|
578
575
|
currentHandleData,
|
|
579
576
|
);
|
|
580
577
|
|
|
578
|
+
// Update UI with error boundary
|
|
579
|
+
startTransition(() => {
|
|
580
|
+
onUpdate({ root: errorTree, metadata: metadata! });
|
|
581
|
+
});
|
|
582
|
+
|
|
581
583
|
// Throw the error so the action promise rejects
|
|
582
584
|
if (returnValue && !returnValue.ok) {
|
|
583
585
|
throw returnValue.data;
|
|
@@ -779,10 +781,16 @@ export function createServerActionBridge(
|
|
|
779
781
|
break;
|
|
780
782
|
}
|
|
781
783
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
784
|
+
// Cache (and bump the nav instance) BEFORE the UI update, matching the
|
|
785
|
+
// navigation commit order (navigation-transaction.ts:157). processHandles
|
|
786
|
+
// is spawned by onUpdate and captures the current nav instance up front;
|
|
787
|
+
// a deferred handle value resolves asynchronously and is only applied
|
|
788
|
+
// while stillLive() (its captured instance still owns the page). Caching
|
|
789
|
+
// AFTER onUpdate bumped the instance out from under that in-flight
|
|
790
|
+
// resolve, so stillLive() turned false and the resolved handle snapshot
|
|
791
|
+
// was dropped on the action-revalidation path (sync siblings sharing the
|
|
792
|
+
// yield are held atomically, so they were dropped too).
|
|
793
|
+
//
|
|
786
794
|
// Location state already applied above (pre-switch). Update store.
|
|
787
795
|
store.setSegmentIds(matched);
|
|
788
796
|
const currentHandleData = eventController.getHandleState().data;
|
|
@@ -791,6 +799,10 @@ export function createServerActionBridge(
|
|
|
791
799
|
fullSegments,
|
|
792
800
|
currentHandleData,
|
|
793
801
|
);
|
|
802
|
+
|
|
803
|
+
startTransition(() => {
|
|
804
|
+
onUpdate({ root: newTree, metadata: metadata! });
|
|
805
|
+
});
|
|
794
806
|
// Invalidation deferred to finalizeAction() (runs after this caches
|
|
795
807
|
// the fresh segments), suppressed when the action called
|
|
796
808
|
// keepClientCache().
|
package/src/client.rsc.tsx
CHANGED
|
@@ -101,8 +101,5 @@ export { useHref } from "./browser/react/use-href.js";
|
|
|
101
101
|
export { useReverse } from "./browser/react/use-reverse.js";
|
|
102
102
|
|
|
103
103
|
export { useHandle } from "./browser/react/use-handle.js";
|
|
104
|
-
// Type a deferred-aware consumer narrows: an accumulated entry may be a Promise
|
|
105
|
-
// (a `ctx.use(Handle).defer()` slot) until it resolves.
|
|
106
|
-
export type { DeferredHandleEntry } from "./defer.js";
|
|
107
104
|
|
|
108
105
|
export { useLocationState } from "./browser/react/location-state.js";
|
package/src/client.tsx
CHANGED
|
@@ -389,9 +389,6 @@ export {
|
|
|
389
389
|
|
|
390
390
|
export { type Handle } from "./handle.js";
|
|
391
391
|
export { useHandle } from "./browser/react/use-handle.js";
|
|
392
|
-
// Type a deferred-aware consumer narrows: an accumulated entry may be a Promise
|
|
393
|
-
// (a `ctx.use(Handle).defer()` slot) until it resolves.
|
|
394
|
-
export type { DeferredHandleEntry } from "./defer.js";
|
|
395
392
|
|
|
396
393
|
export { Meta } from "./handles/meta.js";
|
|
397
394
|
export { MetaTags } from "./handles/MetaTags.js";
|
package/src/defer.ts
CHANGED
|
@@ -14,14 +14,14 @@
|
|
|
14
14
|
* // deep async component, far from ctx:
|
|
15
15
|
* resolve({ label, href, content }); // identical call, just deferred
|
|
16
16
|
*
|
|
17
|
-
* Under the hood the reserved slot is a Promise
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* slot whose resolver is never called would keep the
|
|
22
|
-
* response —
|
|
23
|
-
* (default {@link DEFAULT_DEFER_TIMEOUT_MS}) if the resolver is
|
|
24
|
-
* degrading gracefully (and warning in dev) instead of hanging
|
|
17
|
+
* Under the hood the reserved slot is a Promise. Handle values are resolved
|
|
18
|
+
* before any consumer sees them (resolve-by-default: the full render resolves
|
|
19
|
+
* server-side, navigation resolves client-side before apply), so `useHandle`
|
|
20
|
+
* receives the resolved value, never the Promise. The hazard that guards against
|
|
21
|
+
* bugs: a deferred slot whose resolver is never called would keep the render —
|
|
22
|
+
* and the HTTP response — waiting forever. So a deferred auto-resolves to `else`
|
|
23
|
+
* after `timeoutMs` (default {@link DEFAULT_DEFER_TIMEOUT_MS}) if the resolver is
|
|
24
|
+
* never called, degrading gracefully (and warning in dev) instead of hanging.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
/** Default auto-resolve window. Long enough for genuine deep async work, short
|
|
@@ -74,24 +74,13 @@ export type HandlePush<TData> = HandlePushFn<TData> & {
|
|
|
74
74
|
* re-enter the deadlock-guard push-callback scope a direct push thunk gets,
|
|
75
75
|
* because a deferred resolver fires after the handler phase has closed.
|
|
76
76
|
*
|
|
77
|
-
* The reserved slot
|
|
78
|
-
* `
|
|
79
|
-
*
|
|
80
|
-
* check) before dereferencing.
|
|
77
|
+
* The reserved slot is resolved before any consumer reads it
|
|
78
|
+
* (resolve-by-default), so `useHandle` receives the resolved value (or the
|
|
79
|
+
* `else` fallback on timeout), never a Promise.
|
|
81
80
|
*/
|
|
82
81
|
defer(options?: DeferOptions<TData>): HandlePushFn<TData>;
|
|
83
82
|
};
|
|
84
83
|
|
|
85
|
-
/**
|
|
86
|
-
* A handle entry a deferred-aware consumer may read from `useHandle`: either a
|
|
87
|
-
* resolved value, or a pending `Promise` that resolves to the value, to `else`,
|
|
88
|
-
* or (when no `else` was given) `undefined` on timeout. Reading code should treat
|
|
89
|
-
* thenable entries as such and narrow before dereferencing.
|
|
90
|
-
*/
|
|
91
|
-
export type DeferredHandleEntry<TData> =
|
|
92
|
-
| TData
|
|
93
|
-
| Promise<TData | null | undefined>;
|
|
94
|
-
|
|
95
84
|
// Internal: a timeout-bounded { promise, resolve }. Not part of the public API
|
|
96
85
|
// (the public surface is `ctx.use(Handle).defer()`); exported for `withDefer`
|
|
97
86
|
// and unit tests only. Resolves to `T`, the `else` fallback, or `undefined`.
|