@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.
@@ -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.136",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.136",
3
+ "version": "0.0.0-experimental.137",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -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 hold the Flight stream (and the HTTP response) open forever.
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:** because `.defer()` reserves the slot for the WHOLE item, a
135
- client reading the handle (`useHandle(Breadcrumbs)`) sees that entry as a
136
- `Promise` until it resolves. Type such reads with the exported
137
- `DeferredHandleEntry<BreadcrumbItem>` (from `@rangojs/router/client`); a
138
- deferred-aware consumer should `use()` thenable entries inside `<Suspense>`, while
139
- a simple one can skip them (`typeof entry.then === "function"`). Use `.defer()`
140
- only when even `label`/`href` are unknown at handler time if you know them and
141
- only the `content` is async, push a concrete item with a `Promise` `content` field
142
- instead (no `.defer()` needed).
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
- // Default: flatten into array
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 function: default flattens into T[]
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 (a default-flatten array, or a custom accumulator's value). If the handle's module was never imported (collect unregistered), it warns and falls back to `segments.flat()`.
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
- const Breadcrumbs = createHandle<{ label: string; href: string }>(); // default flatten
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
- HEAD_RESOLVE_HANDLE_NAMES,
40
- hasDeferredHandleValue,
36
+ deferredHandleNames,
41
37
  resolveDeferredHandleValues,
42
- } from "./deferred-handle-resolution.js";
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 ONLY Meta in the store before applying. Meta is the sole
148
- // head-placed handle whose consumer use()s a deferred value above the route
149
- // <Suspense>; an uncontained suspension there would revert the just-committed
150
- // route and hide its loading fallback. Every other handle (Breadcrumbs,
151
- // custom handles) keeps the DeferredHandleEntry contract: its deferred values
152
- // reach the consumer AS A PROMISE and are narrowed via isThenable(). So sync
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
- // Apply now. The non-deferred-Meta case applies the whole snapshot in one
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(historyKey, baseSnapshot, false);
118
+ store.updateCacheHandleData(
119
+ historyKey,
120
+ eventController.getHandleState().data,
121
+ false,
122
+ );
214
123
  }
215
124
  continue;
216
125
  }
217
126
 
218
- // Meta is deferred-pending. The applied snapshot carries the PREVIOUS page's
219
- // Meta (or none), not this route's final title, so the cache entry must NOT
220
- // be served as fresh on a popstate return. Mark it STALE and handlesPending
221
- // (token-guarded). This is the P1 fix: the deferred Meta is a SERVER-side
222
- // promise streamed via Flight, so a navigate-away ABORTS the stream and the
223
- // client's deferred-Meta promise never resolves — the .then below never
224
- // fires. stale makes a popstate return revalidate; handlesPending makes that
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. A diff-only revalidation would omit the unchanged
227
- // segments' handles and the deferred Meta would never land see the
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, baseSnapshot, true, true);
150
+ store.updateCacheHandleData(historyKey, previousSnapshot, true, true);
231
151
  }
232
152
 
233
- // Resolve Meta late, then swap it in. The swap is a PARTIAL merge with
234
- // resolvedIds=undefined so the stale-clear loop (which scans all handle
235
- // names under resolvedIds) cannot wipe the non-Meta buckets we already
236
- // applied. When the deferred Meta DOES resolve while this nav still owns the
237
- // entry (no navigate-away abort), write the resolved handle data and clear
238
- // stale + handlesPending the entry is now complete, so a popstate return
239
- // serves it without revalidating.
240
- //
241
- // Order-safety: each stream yield is a full cumulative snapshot and a
242
- // segment's handle array is atomic, so concurrent Meta resolutions of
243
- // different yields write identical per-segment arrays or touch disjoint
244
- // segments neither can clobber the other.
245
- void resolveDeferredHandleValues(
246
- handleData,
247
- HEAD_RESOLVE_HANDLE_NAMES,
248
- ).then((resolved) => {
249
- const cacheValue = { ...baseSnapshot, [META]: resolved[META] };
250
- if (stillLive()) {
251
- // Still on the live page: swap Meta in and refresh the cache as fresh.
252
- eventController.setHandleData(
253
- { [META]: resolved[META] },
254
- matched,
255
- true,
256
- undefined,
257
- );
258
- store.updateCacheHandleData(
259
- historyKey,
260
- eventController.getHandleState().data,
261
- false,
262
- false,
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
- // Update store state
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
- startTransition(() => {
783
- onUpdate({ root: newTree, metadata: metadata! });
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().
@@ -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 the renderer `use()`s; RSC Flight
18
- * streams it as a late row, so a deferred-aware consumer reading the handle
19
- * (`useHandle`) sees that entry as a `Promise` until it resolves (see
20
- * {@link DeferredHandleEntry}). The hazard that guards against bugs: a deferred
21
- * slot whose resolver is never called would keep the Flight stream and the HTTP
22
- * response — open forever. So a deferred auto-resolves to `else` after `timeoutMs`
23
- * (default {@link DEFAULT_DEFER_TIMEOUT_MS}) if the resolver is never called,
24
- * degrading gracefully (and warning in dev) instead of hanging the request.
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 appears in the accumulated handle data as a pending
78
- * `Promise` until it resolves (see {@link DeferredHandleEntry}); a
79
- * deferred-aware consumer narrows thenable entries (`use()`/`await` + null
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`.