@timber-js/app 0.2.0-alpha.123 → 0.2.0-alpha.125

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.
Files changed (88) hide show
  1. package/dist/_chunks/{actions-ZlcKx0H9.js → actions-418ygYKC.js} +2 -2
  2. package/dist/_chunks/{actions-ZlcKx0H9.js.map → actions-418ygYKC.js.map} +1 -1
  3. package/dist/_chunks/{build-output-helper-DYOWsPjZ.js → build-output-helper-K7oUCRqU.js} +1 -1
  4. package/dist/_chunks/{build-output-helper-DYOWsPjZ.js.map → build-output-helper-K7oUCRqU.js.map} +1 -1
  5. package/dist/_chunks/{cli-schema-sync-VcCf1TFY.js → cli-schema-sync-DFHBj6yZ.js} +59 -12
  6. package/dist/_chunks/{cli-schema-sync-VcCf1TFY.js.map → cli-schema-sync-DFHBj6yZ.js.map} +1 -1
  7. package/dist/_chunks/cli-schema-sync-vdobDWwX.js +2 -0
  8. package/dist/_chunks/{define-C0V_eKiW.js → define-BJMwm0c4.js} +2 -2
  9. package/dist/_chunks/{define-C0V_eKiW.js.map → define-BJMwm0c4.js.map} +1 -1
  10. package/dist/_chunks/{navigation-root-Ds_hDtRW.js → navigation-root-CeNTCxzS.js} +6 -6
  11. package/dist/_chunks/navigation-root-CeNTCxzS.js.map +1 -0
  12. package/dist/_chunks/{stale-reload-BN2kcE06.js → stale-reload-CCqb62Z3.js} +22 -2
  13. package/dist/_chunks/{stale-reload-BN2kcE06.js.map → stale-reload-CCqb62Z3.js.map} +1 -1
  14. package/dist/_chunks/{use-segment-params-Du7u25cK.js → use-segment-params-ByGqwUuO.js} +3 -2
  15. package/dist/_chunks/use-segment-params-ByGqwUuO.js.map +1 -0
  16. package/dist/_chunks/{walkers-tkw7sDlj.js → walkers-DMSGZeCk.js} +2 -2
  17. package/dist/_chunks/{walkers-tkw7sDlj.js.map → walkers-DMSGZeCk.js.map} +1 -1
  18. package/dist/adapters/cloudflare.js +1 -1
  19. package/dist/adapters/nitro.js +1 -1
  20. package/dist/cli-schema-sync.d.ts +6 -4
  21. package/dist/cli-schema-sync.d.ts.map +1 -1
  22. package/dist/cli.d.ts +1 -1
  23. package/dist/cli.d.ts.map +1 -1
  24. package/dist/cli.js +12 -6
  25. package/dist/cli.js.map +1 -1
  26. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  27. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  28. package/dist/client/browser-entry/router-init.d.ts +4 -1
  29. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  30. package/dist/client/index.js +4 -4
  31. package/dist/client/index.js.map +1 -1
  32. package/dist/client/internal.js +147 -301
  33. package/dist/client/internal.js.map +1 -1
  34. package/dist/client/link-pending-store.d.ts +19 -17
  35. package/dist/client/link-pending-store.d.ts.map +1 -1
  36. package/dist/client/link.d.ts.map +1 -1
  37. package/dist/client/navigation-context.d.ts +1 -0
  38. package/dist/client/navigation-context.d.ts.map +1 -1
  39. package/dist/client/navigation-root.d.ts +4 -1
  40. package/dist/client/navigation-root.d.ts.map +1 -1
  41. package/dist/client/router.d.ts +10 -7
  42. package/dist/client/router.d.ts.map +1 -1
  43. package/dist/client/rsc-fetch.d.ts.map +1 -1
  44. package/dist/client/segment-cache.d.ts +2 -0
  45. package/dist/client/segment-cache.d.ts.map +1 -1
  46. package/dist/client/segment-outlet.d.ts +23 -37
  47. package/dist/client/segment-outlet.d.ts.map +1 -1
  48. package/dist/client/segment-update-context.d.ts +21 -0
  49. package/dist/client/segment-update-context.d.ts.map +1 -0
  50. package/dist/client/use-search-params.d.ts +7 -5
  51. package/dist/client/use-search-params.d.ts.map +1 -1
  52. package/dist/index.js +3 -3
  53. package/dist/routing/index.js +2 -2
  54. package/dist/segment-params/index.js +1 -1
  55. package/dist/server/index.js +1 -1
  56. package/dist/server/internal.js +58 -2
  57. package/dist/server/internal.js.map +1 -1
  58. package/dist/server/route-element-builder.d.ts.map +1 -1
  59. package/dist/server/rsc-entry/helpers.d.ts +1 -0
  60. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  61. package/dist/server/state-tree-diff.d.ts +12 -5
  62. package/dist/server/state-tree-diff.d.ts.map +1 -1
  63. package/package.json +1 -1
  64. package/src/cli-schema-sync.ts +80 -13
  65. package/src/cli.ts +18 -7
  66. package/src/client/browser-entry/hydrate.ts +9 -1
  67. package/src/client/browser-entry/index.ts +3 -1
  68. package/src/client/browser-entry/post-hydration.ts +1 -13
  69. package/src/client/browser-entry/router-init.ts +50 -24
  70. package/src/client/link-pending-store.ts +22 -17
  71. package/src/client/link.tsx +12 -6
  72. package/src/client/navigation-context.ts +2 -1
  73. package/src/client/navigation-root.tsx +40 -18
  74. package/src/client/router.ts +113 -99
  75. package/src/client/rsc-fetch.ts +85 -6
  76. package/src/client/segment-cache.ts +5 -2
  77. package/src/client/segment-outlet.tsx +40 -49
  78. package/src/client/segment-update-context.ts +42 -0
  79. package/src/client/use-search-params.ts +25 -44
  80. package/src/server/route-element-builder.ts +69 -2
  81. package/src/server/rsc-entry/helpers.ts +18 -14
  82. package/src/server/state-tree-diff.ts +26 -22
  83. package/dist/_chunks/cli-schema-sync-4HcQIziG.js +0 -2
  84. package/dist/_chunks/navigation-root-Ds_hDtRW.js.map +0 -1
  85. package/dist/_chunks/use-segment-params-Du7u25cK.js.map +0 -1
  86. package/dist/client/segment-merger.d.ts +0 -119
  87. package/dist/client/segment-merger.d.ts.map +0 -1
  88. package/src/client/segment-merger.ts +0 -339
@@ -1,11 +1,11 @@
1
1
  import { t as bindUseQueryStates } from "../_chunks/use-query-states-C9G6LP0F.js";
2
2
  import { c as cachedSearchParams, i as _setCachedSearch, n as getSsrData, r as setSsrData, s as cachedSearch, t as clearSsrData } from "../_chunks/ssr-data-DVX8S7vj.js";
3
- import { a as setNavigationState, i as getNavigationState, r as NavigationProvider, t as setCurrentParams } from "../_chunks/use-segment-params-Du7u25cK.js";
3
+ import { a as setNavigationState, i as getNavigationState, o as useNavigationContext, r as NavigationProvider, t as setCurrentParams } from "../_chunks/use-segment-params-ByGqwUuO.js";
4
4
  import { n as getRouterOrNull, r as setGlobalRouter, t as getRouter } from "../_chunks/router-ref-B2-2kIIC.js";
5
5
  import { TimberErrorBoundary } from "./error-boundary.js";
6
6
  import { n as useSegmentContext, t as SegmentProvider } from "../_chunks/segment-context-Bg0pFbLT.js";
7
- import { t as setHardNavigating } from "../_chunks/navigation-root-Ds_hDtRW.js";
8
- import { cloneElement, isValidElement, useSyncExternalStore } from "react";
7
+ import { t as setHardNavigating } from "../_chunks/navigation-root-CeNTCxzS.js";
8
+ import { r as triggerStaleReload, t as isStaleClientReference } from "../_chunks/stale-reload-CCqb62Z3.js";
9
9
  //#region src/client/segment-cache.ts
10
10
  /**
11
11
  * Maintains the client-side segment tree representing currently mounted
@@ -64,14 +64,15 @@ function buildSegmentTree(segments) {
64
64
  let root;
65
65
  let parent;
66
66
  for (const info of layouts) {
67
+ const id = info.segmentId ?? info.path;
67
68
  const node = {
68
- segment: info.path,
69
+ segment: id,
69
70
  payload: null,
70
71
  isAsync: info.isAsync,
71
72
  children: /* @__PURE__ */ new Map()
72
73
  };
73
74
  if (!root) root = node;
74
- if (parent) parent.children.set(info.path, node);
75
+ if (parent) parent.children.set(id, node);
75
76
  parent = node;
76
77
  }
77
78
  return root;
@@ -157,217 +158,6 @@ var HistoryStack = class {
157
158
  }
158
159
  };
159
160
  //#endregion
160
- //#region src/client/segment-merger.ts
161
- /**
162
- * Segment Merger — client-side tree merging for partial RSC payloads.
163
- *
164
- * When the server skips rendering sync layouts (because the client already
165
- * has them cached), the RSC payload is missing outer segment wrappers.
166
- * This module reconstructs the full element tree by splicing the partial
167
- * payload into cached segment subtrees.
168
- *
169
- * The approach:
170
- * 1. After each full RSC payload render, walk the decoded element tree
171
- * and cache each segment's subtree (identified by SegmentProvider boundaries)
172
- * 2. When a partial payload arrives, wrap it with cached segment elements
173
- * using React.cloneElement to preserve component identity
174
- *
175
- * React.cloneElement preserves the element's `type` — React sees the same
176
- * component at the same tree position and reconciles (preserving state)
177
- * rather than remounting. This is how layout state survives navigations.
178
- *
179
- * Design docs: 19-client-navigation.md §"Navigation Reconciliation"
180
- * Security: access.ts runs on the server regardless of skipping — this
181
- * is a performance optimization only. See 13-security.md.
182
- */
183
- /**
184
- * Cache of React element subtrees per segment path.
185
- * Updated after each navigation with the full decoded RSC element tree.
186
- */
187
- var SegmentElementCache = class {
188
- entries = /* @__PURE__ */ new Map();
189
- get(segmentPath) {
190
- return this.entries.get(segmentPath);
191
- }
192
- set(segmentPath, entry) {
193
- this.entries.set(segmentPath, entry);
194
- }
195
- has(segmentPath) {
196
- return this.entries.has(segmentPath);
197
- }
198
- clear() {
199
- this.entries.clear();
200
- }
201
- get size() {
202
- return this.entries.size;
203
- }
204
- /**
205
- * Get the set of segment paths that are safe for the server to skip.
206
- * Only segments with an inner SegmentProvider (hasMergeableChild) are
207
- * included — the merger can only replace inner SegmentProviders, not
208
- * pages embedded in layout output. Used to filter the state tree.
209
- *
210
- * Returns an empty set if the element cache is empty (no elements
211
- * cached yet). This is the safe default — an empty set means no
212
- * segments pass the filter, so the state tree is empty and the server
213
- * does a full render. The element cache is populated lazily after the
214
- * first SPA navigation (RSC-decoded elements from hydration are
215
- * thenables that can't be walked until React resolves them).
216
- */
217
- getMergeablePaths() {
218
- const paths = /* @__PURE__ */ new Set();
219
- for (const [, entry] of this.entries) if (entry.hasMergeableChild) paths.add(entry.segmentPath);
220
- return paths;
221
- }
222
- };
223
- /**
224
- * Check if a React element is a SegmentProvider by looking for the
225
- * `segments` prop (an array of path segments). This is the only
226
- * component that receives this prop shape.
227
- */
228
- function isSegmentProvider(element) {
229
- if (!isValidElement(element)) return false;
230
- const props = element.props;
231
- return Array.isArray(props.segments);
232
- }
233
- /**
234
- * Extract the segment path from a SegmentProvider element.
235
- *
236
- * Uses the `segmentId` prop if available (set by the server for route groups
237
- * to distinguish siblings that share the same urlPath). Falls back to
238
- * reconstructing from the `segments` array prop.
239
- */
240
- function getSegmentPath(element) {
241
- const props = element.props;
242
- if (props.segmentId) return props.segmentId;
243
- const filtered = props.segments.filter(Boolean);
244
- return filtered.length === 0 ? "/" : "/" + filtered.join("/");
245
- }
246
- /**
247
- * Walk a React element tree and extract all SegmentProvider boundaries.
248
- * Returns an ordered list of segment entries from outermost to innermost.
249
- *
250
- * This only finds SegmentProviders along the main children path — it does
251
- * not descend into parallel routes/slots (those are separate subtrees).
252
- */
253
- function extractSegments(element) {
254
- const segments = [];
255
- walkForSegments(element, segments);
256
- for (let i = 0; i < segments.length; i++) segments[i].hasMergeableChild = i < segments.length - 1;
257
- return segments;
258
- }
259
- function walkForSegments(node, out) {
260
- if (!isValidElement(node)) return;
261
- const el = node;
262
- const props = el.props;
263
- if (isSegmentProvider(node)) {
264
- out.push({
265
- segmentPath: getSegmentPath(el),
266
- element: el,
267
- hasMergeableChild: false
268
- });
269
- walkChildren(props.children, out);
270
- return;
271
- }
272
- walkChildren(props.children, out);
273
- }
274
- function walkChildren(children, out) {
275
- if (children == null) return;
276
- if (Array.isArray(children)) for (const child of children) walkForSegments(child, out);
277
- else walkForSegments(children, out);
278
- }
279
- /**
280
- * Cache all segment subtrees from a fully-rendered RSC element tree.
281
- * Call this after every full RSC payload render (navigate, refresh, hydration).
282
- */
283
- function cacheSegmentElements(element, cache) {
284
- const segments = extractSegments(element);
285
- for (const entry of segments) cache.set(entry.segmentPath, entry);
286
- }
287
- function findSegmentProviderPath(node, targetPath) {
288
- const children = node.props.children;
289
- if (children == null) return null;
290
- if (Array.isArray(children)) for (let i = 0; i < children.length; i++) {
291
- const child = children[i];
292
- if (!isValidElement(child)) continue;
293
- if (isSegmentProvider(child)) {
294
- if (!targetPath || getSegmentPath(child) === targetPath) return [{
295
- element: node,
296
- childIndex: i
297
- }];
298
- }
299
- const deeper = findSegmentProviderPath(child, targetPath);
300
- if (deeper) return [{
301
- element: node,
302
- childIndex: i
303
- }, ...deeper];
304
- }
305
- else if (isValidElement(children)) {
306
- if (isSegmentProvider(children)) {
307
- if (!targetPath || getSegmentPath(children) === targetPath) return [{
308
- element: node,
309
- childIndex: -1
310
- }];
311
- }
312
- const deeper = findSegmentProviderPath(children, targetPath);
313
- if (deeper) return [{
314
- element: node,
315
- childIndex: -1
316
- }, ...deeper];
317
- }
318
- return null;
319
- }
320
- /**
321
- * Replace a nested SegmentProvider within a cached element tree with
322
- * new content. Uses cloneElement along the path to produce a new tree
323
- * with preserved component identity at every level except the replaced node.
324
- *
325
- * @param cachedElement The cached SegmentProvider element for this segment
326
- * @param newInnerContent The new React element to splice in at the inner segment position
327
- * @param innerSegmentPath The path of the inner segment to replace (optional — replaces first found)
328
- * @returns New element tree with the inner segment replaced
329
- */
330
- function replaceInnerSegment(cachedElement, newInnerContent, innerSegmentPath) {
331
- const path = findSegmentProviderPath(cachedElement, innerSegmentPath);
332
- if (!path || path.length === 0) return cachedElement;
333
- let replacement = newInnerContent;
334
- for (let i = path.length - 1; i >= 0; i--) {
335
- const { element, childIndex } = path[i];
336
- if (childIndex === -1) replacement = cloneElement(element, {}, replacement);
337
- else {
338
- const newChildren = [...element.props.children];
339
- newChildren[childIndex] = replacement;
340
- replacement = cloneElement(element, {}, ...newChildren);
341
- }
342
- }
343
- return replacement;
344
- }
345
- /**
346
- * Merge a partial RSC payload with cached segment elements.
347
- *
348
- * When the server skips segments, the partial payload starts from the
349
- * first non-skipped segment. This function wraps it with cached elements
350
- * for the skipped segments, producing a full tree that React can
351
- * reconcile with the mounted tree (preserving layout state).
352
- *
353
- * @param partialPayload The RSC payload element (may be partial)
354
- * @param skippedSegments Ordered list of segment paths that were skipped (outermost first)
355
- * @param cache The segment element cache
356
- * @returns The merged full element tree, or the partial payload if merging isn't possible
357
- */
358
- function mergeSegmentTree(partialPayload, skippedSegments, cache) {
359
- if (!isValidElement(partialPayload)) return partialPayload;
360
- if (skippedSegments.length === 0) return partialPayload;
361
- let result = partialPayload;
362
- for (let i = skippedSegments.length - 1; i >= 0; i--) {
363
- const segmentPath = skippedSegments[i];
364
- const cached = cache.get(segmentPath);
365
- if (!cached) return partialPayload;
366
- result = replaceInnerSegment(cached.element, result);
367
- }
368
- return result;
369
- }
370
- //#endregion
371
161
  //#region src/client/rsc-fetch.ts
372
162
  var RSC_CONTENT_TYPE = "text/x-component";
373
163
  /**
@@ -526,6 +316,46 @@ var ServerErrorResponse = class extends Error {
526
316
  }
527
317
  };
528
318
  /**
319
+ * Wrap a response body stream to track when it's fully consumed.
320
+ * Returns a new body that passes all chunks through unchanged, plus
321
+ * a `done` promise that resolves when the last chunk is read (or
322
+ * rejects if the stream errors).
323
+ *
324
+ * Used to keep React transitions open for the full RSC stream
325
+ * duration — createFromFetch's thenable resolves on shell arrival,
326
+ * but we need stream completion for useOptimistic pending state.
327
+ */
328
+ function trackStreamCompletion(body) {
329
+ let resolveDone;
330
+ let rejectDone;
331
+ const done = new Promise((res, rej) => {
332
+ resolveDone = res;
333
+ rejectDone = rej;
334
+ });
335
+ const reader = body.getReader();
336
+ return {
337
+ body: new ReadableStream({
338
+ async pull(controller) {
339
+ try {
340
+ const result = await reader.read();
341
+ if (result.done) {
342
+ controller.close();
343
+ resolveDone();
344
+ } else controller.enqueue(result.value);
345
+ } catch (error) {
346
+ controller.error(error);
347
+ rejectDone(error);
348
+ }
349
+ },
350
+ cancel(reason) {
351
+ reader.cancel(reason);
352
+ resolveDone();
353
+ }
354
+ }),
355
+ done
356
+ };
357
+ }
358
+ /**
529
359
  * Fetch an RSC payload from the server. If a decodeRsc function is provided,
530
360
  * the response is decoded into a React element tree via createFromFetch.
531
361
  * Otherwise, the raw response text is returned (test mode).
@@ -546,6 +376,7 @@ async function fetchRscPayload(url, deps, stateTree, currentUrl, signal) {
546
376
  let segmentInfo = null;
547
377
  let params = null;
548
378
  let skippedSegments = null;
379
+ let streamDone = Promise.resolve();
549
380
  const wrappedPromise = fetchPromise.then((response) => {
550
381
  if (checkReloadSignal(response)) throw new VersionSkewError();
551
382
  const redirectLocation = response.headers.get("X-Timber-Redirect") || (response.status >= 300 && response.status < 400 ? response.headers.get("Location") : null);
@@ -555,15 +386,26 @@ async function fetchRscPayload(url, deps, stateTree, currentUrl, signal) {
555
386
  segmentInfo = extractSegmentInfo(response);
556
387
  params = extractParams(response);
557
388
  skippedSegments = extractSkippedSegments(response);
389
+ if (response.body) {
390
+ const tracked = trackStreamCompletion(response.body);
391
+ streamDone = tracked.done;
392
+ streamDone.catch(() => {});
393
+ return new Response(tracked.body, {
394
+ headers: response.headers,
395
+ status: response.status
396
+ });
397
+ }
558
398
  return response;
559
399
  });
560
400
  await wrappedPromise;
561
401
  const payload = deps.decodeRsc(wrappedPromise);
562
- const decodePromise = Promise.resolve(payload).then(() => void 0);
563
- decodePromise.catch(() => {});
402
+ const payloadError = new Promise((_, reject) => {
403
+ Promise.resolve(payload).then(() => {}, reject);
404
+ });
405
+ payloadError.catch(() => {});
564
406
  return {
565
407
  payload,
566
- decodePromise,
408
+ decodePromise: Promise.race([streamDone, payloadError]),
567
409
  headElements,
568
410
  segmentInfo,
569
411
  params,
@@ -603,9 +445,7 @@ function createRouter(deps) {
603
445
  const segmentCache = new SegmentCache();
604
446
  const prefetchCache = new PrefetchCache();
605
447
  const historyStack = new HistoryStack();
606
- const segmentElementCache = new SegmentElementCache();
607
448
  let routerPhase = { phase: "idle" };
608
- let navigationId = 0;
609
449
  const pendingListeners = /* @__PURE__ */ new Set();
610
450
  let currentNavAbort = null;
611
451
  /**
@@ -640,31 +480,24 @@ function createRouter(deps) {
640
480
  function renderPayload(payload, navState) {
641
481
  if (deps.renderRoot) deps.renderRoot(payload, navState);
642
482
  }
643
- /**
644
- * Merge a partial RSC payload with cached segment elements if segments
645
- * were skipped. Returns the merged payload ready for rendering.
646
- *
647
- * Segment element caching is deferred to after paint by the caller
648
- * so it doesn't block the render path.
649
- */
650
- async function mergePayload(payload, skippedSegments) {
651
- if (skippedSegments && skippedSegments.length > 0) return mergeSegmentTree(payload != null && typeof payload === "object" && "then" in payload ? await payload : payload, skippedSegments, segmentElementCache);
652
- return payload;
483
+ function isPartialNavigation(skippedSegments) {
484
+ return skippedSegments != null && skippedSegments.length > 0;
653
485
  }
654
486
  /**
655
- * Schedule segment element caching after paint. Resolves thenable
656
- * payloads first since tree walking requires resolved React elements.
657
- * Guards against stale writes: if a newer navigation started before
658
- * this callback fires, the cache write is skipped.
487
+ * Build a segment updates map for partial navigation. Identifies the
488
+ * first non-skipped segment and maps it to the payload content.
659
489
  */
660
- function deferSegmentCaching(payload) {
661
- const capturedNavId = navigationId;
662
- afterPaint(async () => {
663
- if (capturedNavId !== navigationId) return;
664
- const resolved = payload != null && typeof payload === "object" && "then" in payload ? await payload : payload;
665
- if (capturedNavId !== navigationId) return;
666
- cacheSegmentElements(resolved, segmentElementCache);
667
- });
490
+ function buildSegmentUpdates(result) {
491
+ const skipped = new Set(result.skippedSegments);
492
+ const segmentInfo = result.segmentInfo;
493
+ const updates = /* @__PURE__ */ new Map();
494
+ if (segmentInfo) {
495
+ for (const info of segmentInfo) if (!skipped.has(info.segmentId ?? info.path)) {
496
+ updates.set(info.segmentId ?? info.path, result.payload);
497
+ break;
498
+ }
499
+ }
500
+ return updates;
668
501
  }
669
502
  /**
670
503
  * Update navigation state (params + pathname) for the next render.
@@ -677,9 +510,11 @@ function createRouter(deps) {
677
510
  function updateNavigationState(params, url) {
678
511
  const resolvedParams = params ?? {};
679
512
  setCurrentParams(resolvedParams);
513
+ const parsed = new URL(url, "http://localhost");
680
514
  const navState = {
681
515
  params: resolvedParams,
682
- pathname: url.startsWith("http") ? new URL(url).pathname : url.split("?")[0] || "/"
516
+ pathname: parsed.pathname || "/",
517
+ search: parsed.search
683
518
  };
684
519
  setNavigationState(navState);
685
520
  return navState;
@@ -687,34 +522,51 @@ function createRouter(deps) {
687
522
  async function renderViaTransition(url, perform) {
688
523
  if (deps.navigateTransition) {
689
524
  let headElements = null;
690
- let decodePromise = null;
691
525
  await deps.navigateTransition(url, async (wrapPayload) => {
692
526
  const result = await perform();
693
527
  headElements = result.headElements;
694
- decodePromise = result.decodePromise;
695
- const merged = await mergePayload(result.payload, result.skippedSegments);
528
+ if (isPartialNavigation(result.skippedSegments)) {
529
+ const segmentUpdates = buildSegmentUpdates(result);
530
+ historyStack.push(url, {
531
+ payload: null,
532
+ headElements: result.headElements,
533
+ params: result.params
534
+ });
535
+ return {
536
+ element: wrapPayload(deps._getCurrentPayload?.() ?? result.payload, result.navState, segmentUpdates),
537
+ decodePromise: result.decodePromise
538
+ };
539
+ }
696
540
  historyStack.push(url, {
697
- payload: merged,
541
+ payload: result.payload,
698
542
  headElements: result.headElements,
699
543
  params: result.params
700
544
  });
701
- deferSegmentCaching(merged);
702
- return wrapPayload(merged, result.navState);
545
+ return {
546
+ element: wrapPayload(result.payload, result.navState),
547
+ decodePromise: result.decodePromise
548
+ };
703
549
  });
704
550
  return {
705
551
  headElements,
706
- decodePromise
552
+ decodePromise: null
707
553
  };
708
554
  }
709
555
  const result = await perform();
710
- const merged = await mergePayload(result.payload, result.skippedSegments);
711
- historyStack.push(url, {
712
- payload: merged,
556
+ if (isPartialNavigation(result.skippedSegments)) historyStack.push(url, {
557
+ payload: null,
713
558
  headElements: result.headElements,
714
559
  params: result.params
715
560
  });
716
- deferSegmentCaching(merged);
717
- renderPayload(merged, result.navState);
561
+ else {
562
+ const payload = result.payload != null && typeof result.payload === "object" && "then" in result.payload ? await result.payload : result.payload;
563
+ historyStack.push(url, {
564
+ payload,
565
+ headElements: result.headElements,
566
+ params: result.params
567
+ });
568
+ renderPayload(payload, result.navState);
569
+ }
718
570
  return {
719
571
  headElements: result.headElements,
720
572
  decodePromise: result.decodePromise
@@ -754,7 +606,7 @@ function createRouter(deps) {
754
606
  skippedSegments: prefetched.skippedSegments ?? null
755
607
  } : void 0;
756
608
  if (result === void 0) {
757
- const stateTree = segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths());
609
+ const stateTree = segmentCache.serializeStateTree();
758
610
  const rawCurrentUrl = deps.getCurrentUrl();
759
611
  result = await fetchRscPayload(url, deps, stateTree, rawCurrentUrl.startsWith("http") ? new URL(rawCurrentUrl).pathname : new URL(rawCurrentUrl, "http://localhost").pathname, options.signal);
760
612
  }
@@ -782,7 +634,6 @@ function createRouter(deps) {
782
634
  const replace = options.replace === true;
783
635
  const externalSignal = options._signal;
784
636
  const skipHistory = options._skipHistory === true;
785
- navigationId++;
786
637
  const navAbort = createNavAbort(externalSignal);
787
638
  const currentScrollY = deps.getScrollY();
788
639
  if (deps.saveNavigationEntryScroll) deps.saveNavigationEntryScroll(currentScrollY);
@@ -799,19 +650,18 @@ function createRouter(deps) {
799
650
  }
800
651
  setPending(true, url);
801
652
  try {
802
- const { headElements, decodePromise } = await renderViaTransition(url, () => performNavigationFetch(url, {
653
+ const { headElements } = await renderViaTransition(url, () => performNavigationFetch(url, {
803
654
  replace,
804
655
  signal: navAbort.signal,
805
656
  skipHistory: effectiveSkipHistory
806
657
  }));
807
- if (decodePromise) await decodePromise;
808
658
  applyHead(headElements);
809
659
  window.dispatchEvent(new Event("timber:navigation-end"));
810
660
  restoreScrollAfterPaint(scroll ? 0 : currentScrollY);
811
661
  } catch (error) {
812
662
  if (error instanceof VersionSkewError) {
813
663
  setHardNavigating(true);
814
- const { triggerStaleReload } = await import("../_chunks/stale-reload-BN2kcE06.js");
664
+ const { triggerStaleReload } = await import("../_chunks/stale-reload-CCqb62Z3.js").then((n) => n.n);
815
665
  triggerStaleReload();
816
666
  return new Promise(() => {});
817
667
  }
@@ -826,6 +676,11 @@ function createRouter(deps) {
826
676
  window.location.href = error.url;
827
677
  return new Promise(() => {});
828
678
  }
679
+ if (isStaleClientReference(error)) {
680
+ setHardNavigating(true);
681
+ triggerStaleReload();
682
+ return new Promise(() => {});
683
+ }
829
684
  if (isAbortError(error)) return;
830
685
  throw error;
831
686
  } finally {
@@ -836,11 +691,10 @@ function createRouter(deps) {
836
691
  }
837
692
  async function refresh() {
838
693
  const currentUrl = deps.getCurrentUrl();
839
- navigationId++;
840
694
  const navAbort = createNavAbort();
841
695
  setPending(true, currentUrl);
842
696
  try {
843
- const { headElements, decodePromise } = await renderViaTransition(currentUrl, async () => {
697
+ const { headElements } = await renderViaTransition(currentUrl, async () => {
844
698
  const result = await fetchRscPayload(currentUrl, deps, void 0, void 0, navAbort.signal);
845
699
  updateSegmentCache(result.segmentInfo);
846
700
  const navState = updateNavigationState(result.params, currentUrl);
@@ -849,9 +703,13 @@ function createRouter(deps) {
849
703
  navState
850
704
  };
851
705
  });
852
- if (decodePromise) await decodePromise;
853
706
  applyHead(headElements);
854
707
  } catch (error) {
708
+ if (isStaleClientReference(error)) {
709
+ setHardNavigating(true);
710
+ triggerStaleReload();
711
+ return new Promise(() => {});
712
+ }
855
713
  if (isAbortError(error)) return;
856
714
  throw error;
857
715
  } finally {
@@ -861,7 +719,6 @@ function createRouter(deps) {
861
719
  }
862
720
  }
863
721
  async function handlePopState(url, scrollY = 0, externalSignal) {
864
- navigationId++;
865
722
  const entry = historyStack.get(url);
866
723
  if (entry && entry.payload !== null) {
867
724
  const navState = updateNavigationState(entry.params, url);
@@ -872,8 +729,8 @@ function createRouter(deps) {
872
729
  const navAbort = createNavAbort(externalSignal);
873
730
  setPending(true, url);
874
731
  try {
875
- const { headElements, decodePromise } = await renderViaTransition(url, async () => {
876
- const result = await fetchRscPayload(url, deps, segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths()), void 0, navAbort.signal);
732
+ const { headElements } = await renderViaTransition(url, async () => {
733
+ const result = await fetchRscPayload(url, deps, segmentCache.serializeStateTree(), void 0, navAbort.signal);
877
734
  updateSegmentCache(result.segmentInfo);
878
735
  const navState = updateNavigationState(result.params, url);
879
736
  return {
@@ -881,10 +738,14 @@ function createRouter(deps) {
881
738
  navState
882
739
  };
883
740
  });
884
- if (decodePromise) await decodePromise;
885
741
  applyHead(headElements);
886
742
  restoreScrollAfterPaint(scrollY);
887
743
  } catch (error) {
744
+ if (isStaleClientReference(error)) {
745
+ setHardNavigating(true);
746
+ triggerStaleReload();
747
+ return new Promise(() => {});
748
+ }
888
749
  if (isAbortError(error)) return;
889
750
  throw error;
890
751
  } finally {
@@ -900,7 +761,8 @@ function createRouter(deps) {
900
761
  function prefetch(url) {
901
762
  if (prefetchCache.get(url) !== void 0) return;
902
763
  if (historyStack.has(url)) return;
903
- fetchRscPayload(url, deps, segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths())).then((result) => {
764
+ fetchRscPayload(url, deps, segmentCache.serializeStateTree()).then((result) => {
765
+ result.decodePromise?.catch(() => {});
904
766
  prefetchCache.set(url, result);
905
767
  }, () => {});
906
768
  }
@@ -921,12 +783,10 @@ function createRouter(deps) {
921
783
  payload: element,
922
784
  headElements
923
785
  });
924
- deferSegmentCaching(element);
925
786
  renderPayload(element, getNavigationState());
926
787
  applyHead(headElements);
927
788
  },
928
789
  initSegmentCache: (segments) => updateSegmentCache(segments),
929
- cacheElementTree: (element) => cacheSegmentElements(element, segmentElementCache),
930
790
  segmentCache,
931
791
  prefetchCache,
932
792
  historyStack
@@ -940,9 +800,12 @@ function createRouter(deps) {
940
800
  * Returns a read-only URLSearchParams instance reflecting the current
941
801
  * URL's query string. Updates when client-side navigation changes the URL.
942
802
  *
943
- * This is a thin wrapper over window.location.search, provided for
944
- * Next.js API compatibility (libraries like nuqs import useSearchParams
945
- * from next/navigation).
803
+ * On the client, reads from NavigationContext which is updated atomically
804
+ * with the RSC tree render. This replaces the previous useSyncExternalStore
805
+ * approach which read window.location.search directly — causing React to
806
+ * detect external store tearing during transitions and fall back to
807
+ * synchronous rendering (renderRootSync instead of renderRootConcurrent),
808
+ * blocking the main thread and freezing animations.
946
809
  *
947
810
  * Unlike Next.js's ReadonlyURLSearchParams, this returns a standard
948
811
  * URLSearchParams. Mutation methods (set, delete, append) work on the
@@ -951,28 +814,9 @@ function createRouter(deps) {
951
814
  * During SSR, reads the request search params from the SSR ALS context
952
815
  * (populated by ssr-entry.ts) instead of window.location.
953
816
  *
954
- * All mutable state is delegated to client/state.ts for singleton guarantees.
955
- * See design/18-build-system.md §"Singleton State Registry"
817
+ * Compatible with Next.js's `useSearchParams()` from `next/navigation`.
956
818
  */
957
- function getSearch() {
958
- if (typeof window !== "undefined") return window.location.search;
959
- const data = getSsrData();
960
- if (!data) return "";
961
- const str = new URLSearchParams(data.searchParams).toString();
962
- return str ? `?${str}` : "";
963
- }
964
- function getServerSearch() {
965
- const data = getSsrData();
966
- if (!data) return "";
967
- const str = new URLSearchParams(data.searchParams).toString();
968
- return str ? `?${str}` : "";
969
- }
970
- function subscribe(callback) {
971
- window.addEventListener("popstate", callback);
972
- return () => window.removeEventListener("popstate", callback);
973
- }
974
- function getSearchParams() {
975
- const search = getSearch();
819
+ function getSearchParams(search) {
976
820
  if (search !== cachedSearch) {
977
821
  const params = new URLSearchParams(search);
978
822
  _setCachedSearch(search, params);
@@ -980,18 +824,20 @@ function getSearchParams() {
980
824
  }
981
825
  return cachedSearchParams;
982
826
  }
983
- function getServerSearchParams() {
984
- const data = getSsrData();
985
- return data ? new URLSearchParams(data.searchParams) : new URLSearchParams();
986
- }
987
827
  /**
988
828
  * Read the current URL search params.
989
829
  *
990
830
  * Compatible with Next.js's `useSearchParams()` from `next/navigation`.
991
831
  */
992
832
  function useSearchParams() {
993
- useSyncExternalStore(subscribe, getSearch, getServerSearch);
994
- return typeof window !== "undefined" ? getSearchParams() : getServerSearchParams();
833
+ try {
834
+ const navContext = useNavigationContext();
835
+ if (navContext !== null) return getSearchParams(navContext.search);
836
+ } catch {}
837
+ const ssrData = getSsrData();
838
+ if (ssrData) return new URLSearchParams(ssrData.searchParams);
839
+ if (typeof window !== "undefined") return getSearchParams(window.location.search);
840
+ return new URLSearchParams();
995
841
  }
996
842
  //#endregion
997
843
  export { HistoryStack, NavigationProvider, PrefetchCache, SegmentCache, SegmentProvider, TimberErrorBoundary, bindUseQueryStates, clearSsrData, createRouter, getNavigationState, getRouter, getRouterOrNull, getSsrData, setCurrentParams, setGlobalRouter, setNavigationState, setSsrData, useSearchParams, useSegmentContext };