@timber-js/app 0.1.30 → 0.1.32

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.
@@ -6,7 +6,7 @@ import type { SegmentInfo } from './segment-cache';
6
6
  import { HistoryStack } from './history';
7
7
  import type { HeadElement } from './head';
8
8
  import { setCurrentParams } from './use-params.js';
9
- import { getNavigationState, setNavigationState } from './navigation-context.js';
9
+ import { setNavigationState } from './navigation-context.js';
10
10
 
11
11
  // ─── Types ───────────────────────────────────────────────────────
12
12
 
@@ -54,6 +54,21 @@ export interface RouterDeps {
54
54
  afterPaint?: (callback: () => void) => void;
55
55
  /** Apply resolved head elements (title, meta tags) to the DOM after navigation. */
56
56
  applyHead?: (elements: HeadElement[]) => void;
57
+ /**
58
+ * Run a navigation inside a React transition with optimistic pending URL.
59
+ * The pending URL shows immediately (useOptimistic urgent update) and
60
+ * reverts when the transition commits (atomic with the new tree).
61
+ *
62
+ * The `perform` callback receives a `wrapPayload` function to wrap the
63
+ * decoded RSC payload with NavigationProvider + NuqsAdapter before
64
+ * TransitionRoot sets it as the new element.
65
+ *
66
+ * If not provided (tests), the router falls back to renderRoot.
67
+ */
68
+ navigateTransition?: (
69
+ pendingUrl: string,
70
+ perform: (wrapPayload: (payload: unknown) => unknown) => Promise<unknown>,
71
+ ) => Promise<void>;
57
72
  }
58
73
 
59
74
  /** Result of fetching an RSC payload — includes head elements and segment metadata. */
@@ -298,27 +313,18 @@ export function createRouter(deps: RouterDeps): RouterInstance {
298
313
  let pending = false;
299
314
  let pendingUrl: string | null = null;
300
315
  const pendingListeners = new Set<(pending: boolean) => void>();
301
- /** Last rendered payload — used to re-render at navigation start with pendingUrl set. */
302
- let lastRenderedPayload: unknown = null;
303
316
 
304
317
  function setPending(value: boolean, url?: string): void {
305
318
  const newPendingUrl = value && url ? url : null;
306
319
  if (pending === value && pendingUrl === newPendingUrl) return;
307
320
  pending = value;
308
321
  pendingUrl = newPendingUrl;
309
- // Notify external store listeners (useNavigationPending, etc.)
322
+ // Notify external store listeners (non-React consumers).
323
+ // React-facing pending state is handled by useOptimistic in
324
+ // TransitionRoot via navigateTransition — not this function.
310
325
  for (const listener of pendingListeners) {
311
326
  listener(value);
312
327
  }
313
- // When navigation starts, re-render the current tree with pendingUrl
314
- // set in NavigationContext. This makes the pending state visible to
315
- // LinkStatusProvider atomically via React context, avoiding the
316
- // two-commit gap between useSyncExternalStore and context updates.
317
- if (value && lastRenderedPayload !== null) {
318
- const currentState = getNavigationState();
319
- setNavigationState({ ...currentState, pendingUrl: newPendingUrl });
320
- renderPayload(lastRenderedPayload);
321
- }
322
328
  }
323
329
 
324
330
  /** Update the segment cache from server-provided segment metadata. */
@@ -332,29 +338,22 @@ export function createRouter(deps: RouterDeps): RouterInstance {
332
338
 
333
339
  /** Render a decoded RSC payload into the DOM if a renderer is available. */
334
340
  function renderPayload(payload: unknown): void {
335
- lastRenderedPayload = payload;
336
341
  if (deps.renderRoot) {
337
342
  deps.renderRoot(payload);
338
343
  }
339
344
  }
340
345
 
341
346
  /**
342
- * Update navigation state (params + pathname + pendingUrl) for the next render.
347
+ * Update navigation state (params + pathname) for the next render.
343
348
  *
344
349
  * Sets both the module-level fallback (for tests and SSR) and the
345
350
  * navigation context state (read by renderRoot to wrap the element
346
351
  * in NavigationProvider). The context update is atomic with the tree
347
352
  * render — both are passed to reactRoot.render() in the same call.
348
- *
349
- * pendingUrl is included so that LinkStatusProvider (which reads from
350
- * NavigationContext) sees the pending state change in the same React
351
- * commit as params/pathname — preventing the gap where the spinner
352
- * disappears before the active state updates.
353
353
  */
354
354
  function updateNavigationState(
355
355
  params: Record<string, string | string[]> | null | undefined,
356
- url: string,
357
- navPendingUrl: string | null = null
356
+ url: string
358
357
  ): void {
359
358
  const resolvedParams = params ?? {};
360
359
  // Module-level fallback for tests (no NavigationProvider) and SSR
@@ -363,7 +362,32 @@ export function createRouter(deps: RouterDeps): RouterInstance {
363
362
  const pathname = url.startsWith('http')
364
363
  ? new URL(url).pathname
365
364
  : url.split('?')[0] || '/';
366
- setNavigationState({ params: resolvedParams, pathname, pendingUrl: navPendingUrl });
365
+ setNavigationState({ params: resolvedParams, pathname });
366
+ }
367
+
368
+ /**
369
+ * Render a payload via navigateTransition (production) or renderRoot (tests).
370
+ * The perform callback should fetch data, update state, and return the payload.
371
+ * In production, the entire callback runs inside a React transition with
372
+ * useOptimistic for the pending URL. In tests, the payload is rendered directly.
373
+ */
374
+ async function renderViaTransition(
375
+ pendingUrl: string,
376
+ perform: () => Promise<FetchResult>,
377
+ ): Promise<HeadElement[] | null> {
378
+ if (deps.navigateTransition) {
379
+ let headElements: HeadElement[] | null = null;
380
+ await deps.navigateTransition(pendingUrl, async (wrapPayload) => {
381
+ const result = await perform();
382
+ headElements = result.headElements;
383
+ return wrapPayload(result.payload);
384
+ });
385
+ return headElements;
386
+ }
387
+ // Fallback: no transition (tests, no React tree)
388
+ const result = await perform();
389
+ renderPayload(result.payload);
390
+ return result.headElements;
367
391
  }
368
392
 
369
393
  /** Apply head elements (title, meta tags) to the DOM if available. */
@@ -382,6 +406,60 @@ export function createRouter(deps: RouterDeps): RouterInstance {
382
406
  }
383
407
  }
384
408
 
409
+ /**
410
+ * Core navigation logic shared between the transition and fallback paths.
411
+ * Fetches the RSC payload, updates all state, and returns the result.
412
+ */
413
+ async function performNavigationFetch(
414
+ url: string,
415
+ options: { replace: boolean },
416
+ ): Promise<FetchResult> {
417
+ // Check prefetch cache first. PrefetchResult has optional segmentInfo/params
418
+ // fields — normalize to null for FetchResult compatibility.
419
+ const prefetched = prefetchCache.consume(url);
420
+ let result: FetchResult | undefined = prefetched
421
+ ? {
422
+ payload: prefetched.payload,
423
+ headElements: prefetched.headElements,
424
+ segmentInfo: prefetched.segmentInfo ?? null,
425
+ params: prefetched.params ?? null,
426
+ }
427
+ : undefined;
428
+
429
+ if (result === undefined) {
430
+ // Fetch RSC payload with state tree for partial rendering.
431
+ // Send current URL for intercepting route resolution (modal pattern).
432
+ const stateTree = segmentCache.serializeStateTree();
433
+ const rawCurrentUrl = deps.getCurrentUrl();
434
+ const currentUrl = rawCurrentUrl.startsWith('http')
435
+ ? new URL(rawCurrentUrl).pathname
436
+ : new URL(rawCurrentUrl, 'http://localhost').pathname;
437
+ result = await fetchRscPayload(url, deps, stateTree, currentUrl);
438
+ }
439
+
440
+ // Update the browser history — replace mode overwrites the current entry
441
+ if (options.replace) {
442
+ deps.replaceState({ timber: true, scrollY: 0 }, '', url);
443
+ } else {
444
+ deps.pushState({ timber: true, scrollY: 0 }, '', url);
445
+ }
446
+
447
+ // Store the payload in the history stack
448
+ historyStack.push(url, {
449
+ payload: result.payload,
450
+ headElements: result.headElements,
451
+ params: result.params,
452
+ });
453
+
454
+ // Update the segment cache with the new route's segment tree.
455
+ updateSegmentCache(result.segmentInfo);
456
+
457
+ // Update navigation state (params + pathname) before rendering.
458
+ updateNavigationState(result.params, url);
459
+
460
+ return result;
461
+ }
462
+
385
463
  async function navigate(url: string, options: NavigationOptions = {}): Promise<void> {
386
464
  const scroll = options.scroll !== false;
387
465
  const replace = options.replace === true;
@@ -397,54 +475,14 @@ export function createRouter(deps: RouterDeps): RouterInstance {
397
475
  setPending(true, url);
398
476
 
399
477
  try {
400
- // Check prefetch cache first
401
- let result = prefetchCache.consume(url);
402
-
403
- if (result === undefined) {
404
- // Fetch RSC payload with state tree for partial rendering.
405
- // Send current URL for intercepting route resolution (modal pattern).
406
- const stateTree = segmentCache.serializeStateTree();
407
- const rawCurrentUrl = deps.getCurrentUrl();
408
- const currentUrl = rawCurrentUrl.startsWith('http')
409
- ? new URL(rawCurrentUrl).pathname
410
- : new URL(rawCurrentUrl, 'http://localhost').pathname;
411
- result = await fetchRscPayload(url, deps, stateTree, currentUrl);
412
- }
413
-
414
- // Update the browser history — replace mode overwrites the current entry
415
- if (replace) {
416
- deps.replaceState({ timber: true, scrollY: 0 }, '', url);
417
- } else {
418
- deps.pushState({ timber: true, scrollY: 0 }, '', url);
419
- }
420
-
421
- // Store the payload in the history stack
422
- historyStack.push(url, {
423
- payload: result.payload,
424
- headElements: result.headElements,
425
- params: result.params,
426
- });
427
-
428
- // Update the segment cache with the new route's segment tree.
429
- // This must happen before the next navigation so the state tree
430
- // header reflects the currently mounted segments.
431
- updateSegmentCache(result.segmentInfo);
432
-
433
- // Update navigation state (params + pathname) before rendering.
434
- // The renderRoot callback reads this state and wraps the RSC element
435
- // in NavigationProvider — so the context value and the element tree
436
- // are passed to reactRoot.render() in the same call, making the
437
- // update atomic. Preserved layouts see new params in the same render
438
- // pass as the new tree, preventing the dual-active-row flash.
439
- updateNavigationState(result.params, url);
440
- renderPayload(result.payload);
478
+ const headElements = await renderViaTransition(url, () =>
479
+ performNavigationFetch(url, { replace }),
480
+ );
441
481
 
442
482
  // Update document.title and <meta> tags with the new page's metadata
443
- applyHead(result.headElements);
483
+ applyHead(headElements);
444
484
 
445
485
  // Notify nuqs adapter (and any other listeners) that navigation completed.
446
- // The nuqs adapter syncs its searchParams state from window.location.search
447
- // on this event so URL-bound inputs reflect the new URL after navigation.
448
486
  window.dispatchEvent(new Event('timber:navigation-end'));
449
487
 
450
488
  // Scroll-to-top on forward navigation, or restore captured position
@@ -460,17 +498,12 @@ export function createRouter(deps: RouterDeps): RouterInstance {
460
498
  });
461
499
  } catch (error) {
462
500
  // Server-side redirect during RSC fetch → soft router navigation.
463
- // access.ts called redirect() — the server returns X-Timber-Redirect
464
- // header, and fetchRscPayload throws RedirectError. We re-navigate
465
- // to the redirect target using the router for a seamless SPA transition.
466
501
  if (error instanceof RedirectError) {
467
502
  setPending(false);
468
503
  await navigate(error.redirectUrl, { replace: true });
469
504
  return;
470
505
  }
471
- // Abort errors from the fetch (user refreshed or navigated away
472
- // while the RSC payload was loading) are not application errors.
473
- // Swallow them silently — the page is being replaced.
506
+ // Abort errors are not application errors swallow silently.
474
507
  if (isAbortError(error)) return;
475
508
  throw error;
476
509
  } finally {
@@ -484,23 +517,20 @@ export function createRouter(deps: RouterDeps): RouterInstance {
484
517
  setPending(true, currentUrl);
485
518
 
486
519
  try {
487
- // No state tree sent server renders the complete RSC payload
488
- const result = await fetchRscPayload(currentUrl, deps);
489
-
490
- // Update the history entry with the fresh payload
491
- historyStack.push(currentUrl, {
492
- payload: result.payload,
493
- headElements: result.headElements,
494
- params: result.params,
520
+ const headElements = await renderViaTransition(currentUrl, async () => {
521
+ // No state tree sent — server renders the complete RSC payload
522
+ const result = await fetchRscPayload(currentUrl, deps);
523
+ historyStack.push(currentUrl, {
524
+ payload: result.payload,
525
+ headElements: result.headElements,
526
+ params: result.params,
527
+ });
528
+ updateSegmentCache(result.segmentInfo);
529
+ updateNavigationState(result.params, currentUrl);
530
+ return result;
495
531
  });
496
532
 
497
- // Update segment cache with fresh segment info from full render
498
- updateSegmentCache(result.segmentInfo);
499
-
500
- // Atomic update — see navigate() for rationale on NavigationProvider.
501
- updateNavigationState(result.params, currentUrl);
502
- renderPayload(result.payload);
503
- applyHead(result.headElements);
533
+ applyHead(headElements);
504
534
  } finally {
505
535
  setPending(false);
506
536
  }
@@ -528,17 +558,20 @@ export function createRouter(deps: RouterDeps): RouterInstance {
528
558
  // or when the entry doesn't exist at all.
529
559
  setPending(true, url);
530
560
  try {
531
- const stateTree = segmentCache.serializeStateTree();
532
- const result = await fetchRscPayload(url, deps, stateTree);
533
- updateSegmentCache(result.segmentInfo);
534
- updateNavigationState(result.params, url);
535
- historyStack.push(url, {
536
- payload: result.payload,
537
- headElements: result.headElements,
538
- params: result.params,
561
+ const headElements = await renderViaTransition(url, async () => {
562
+ const stateTree = segmentCache.serializeStateTree();
563
+ const result = await fetchRscPayload(url, deps, stateTree);
564
+ updateSegmentCache(result.segmentInfo);
565
+ updateNavigationState(result.params, url);
566
+ historyStack.push(url, {
567
+ payload: result.payload,
568
+ headElements: result.headElements,
569
+ params: result.params,
570
+ });
571
+ return result;
539
572
  });
540
- renderPayload(result.payload);
541
- applyHead(result.headElements);
573
+
574
+ applyHead(headElements);
542
575
  afterPaint(() => {
543
576
  deps.scrollTo(0, scrollY);
544
577
  window.dispatchEvent(new Event('timber:scroll-restored'));
@@ -11,65 +11,109 @@
11
11
  * a transition update. React keeps the old committed tree visible while
12
12
  * any new Suspense boundaries in the transition resolve.
13
13
  *
14
- * This is the client-side equivalent of deferSuspenseFor on the server:
15
- * the old content stays visible until the new content is ready, avoiding
16
- * flash-of-fallback during fast navigations.
14
+ * Also manages `pendingUrl` via `useOptimistic`. During a navigation
15
+ * transition, the optimistic value (the target URL) shows immediately
16
+ * while the transition is pending, and automatically reverts to null
17
+ * when the transition commits. This ensures useLinkStatus and
18
+ * useNavigationPending show the pending state immediately and clear
19
+ * atomically with the new tree — same pattern Next.js uses with
20
+ * useOptimistic per Link instance, adapted for timber's server-component
21
+ * Link with global click delegation.
17
22
  *
18
23
  * See design/05-streaming.md §"deferSuspenseFor"
24
+ * See design/19-client-navigation.md §"NavigationContext"
19
25
  */
20
26
 
21
- import { useState, startTransition, type ReactNode } from 'react';
27
+ import {
28
+ useState,
29
+ useOptimistic,
30
+ useTransition,
31
+ createElement,
32
+ type ReactNode,
33
+ } from 'react';
34
+ import { PendingNavigationProvider } from './pending-navigation-context.js';
22
35
 
23
- // ─── Module-level render function ────────────────────────────────
36
+ // ─── Module-level functions ──────────────────────────────────────
24
37
 
25
38
  /**
26
39
  * Module-level reference to the state setter wrapped in startTransition.
27
- * Set during TransitionRoot's render. This is safe because there is
28
- * exactly one TransitionRoot per application (the document root).
40
+ * Used for non-navigation renders (applyRevalidation, popstate replay).
29
41
  */
30
42
  let _transitionRender: ((element: ReactNode) => void) | null = null;
31
43
 
44
+ /**
45
+ * Module-level reference to the navigation transition function.
46
+ * Wraps a full navigation (fetch + render) in a single startTransition
47
+ * with useOptimistic for the pending URL.
48
+ */
49
+ let _navigateTransition: ((
50
+ pendingUrl: string,
51
+ perform: () => Promise<ReactNode>,
52
+ ) => Promise<void>) | null = null;
53
+
32
54
  // ─── Component ───────────────────────────────────────────────────
33
55
 
34
56
  /**
35
57
  * Root wrapper component that enables transition-based rendering.
36
58
  *
37
- * Renders no DOM elements returns the current element directly.
38
- * This means the DOM tree matches the server-rendered HTML during
39
- * hydration (TransitionRoot is invisible to the DOM).
59
+ * Renders PendingNavigationProvider around children for the pending URL
60
+ * context. The DOM tree matches the server-rendered HTML during hydration
61
+ * (the provider renders no extra DOM elements).
40
62
  *
41
63
  * Usage in browser-entry.ts:
42
64
  * const rootEl = createElement(TransitionRoot, { initial: wrapped });
43
65
  * reactRoot = hydrateRoot(document, rootEl);
44
66
  *
45
67
  * Subsequent navigations:
68
+ * navigateTransition(url, async () => { fetch; return wrappedElement; });
69
+ *
70
+ * Non-navigation renders:
46
71
  * transitionRender(newWrappedElement);
47
72
  */
48
73
  export function TransitionRoot({ initial }: { initial: ReactNode }): ReactNode {
49
74
  const [element, setElement] = useState<ReactNode>(initial);
75
+ const [optimisticPendingUrl, setOptimisticPendingUrl] = useOptimistic<string | null>(null);
76
+ // useTransition's startTransition (not the standalone import) creates an
77
+ // action context that useOptimistic can track. The standalone startTransition
78
+ // doesn't — optimistic values would never show.
79
+ const [, startTransition] = useTransition();
50
80
 
51
- // Update the module-level ref on every render so it always points
52
- // to the current component instance's setState.
81
+ // Non-navigation render (revalidation, popstate cached replay).
53
82
  _transitionRender = (newElement: ReactNode) => {
54
83
  startTransition(() => {
55
84
  setElement(newElement);
56
85
  });
57
86
  };
58
87
 
59
- return element;
88
+ // Full navigation transition. The entire navigation (fetch + state updates)
89
+ // runs inside startTransition. useOptimistic shows the pending URL immediately
90
+ // (urgent) and reverts to null when the transition commits (atomic with new tree).
91
+ _navigateTransition = (pendingUrl: string, perform: () => Promise<ReactNode>) => {
92
+ return new Promise<void>((resolve, reject) => {
93
+ startTransition(async () => {
94
+ try {
95
+ setOptimisticPendingUrl(pendingUrl);
96
+ const newElement = await perform();
97
+ setElement(newElement);
98
+ resolve();
99
+ } catch (err) {
100
+ reject(err);
101
+ }
102
+ });
103
+ });
104
+ };
105
+
106
+ return createElement(PendingNavigationProvider, { value: optimisticPendingUrl }, element);
60
107
  }
61
108
 
62
109
  // ─── Public API ──────────────────────────────────────────────────
63
110
 
64
111
  /**
65
- * Trigger a transition render. React keeps the old committed tree
66
- * visible while any new Suspense boundaries in the update resolve.
67
- *
68
- * This is the function called by the router's renderRoot callback
69
- * instead of reactRoot.render() directly.
112
+ * Trigger a transition render for non-navigation updates.
113
+ * React keeps the old committed tree visible while any new Suspense
114
+ * boundaries in the update resolve.
70
115
  *
71
- * Falls back to no-op if TransitionRoot hasn't mounted yet (shouldn't
72
- * happen in practice — TransitionRoot mounts during hydration).
116
+ * Used for: applyRevalidation, popstate replay with cached payload.
73
117
  */
74
118
  export function transitionRender(element: ReactNode): void {
75
119
  if (_transitionRender) {
@@ -77,6 +121,30 @@ export function transitionRender(element: ReactNode): void {
77
121
  }
78
122
  }
79
123
 
124
+ /**
125
+ * Run a full navigation inside a React transition with optimistic pending URL.
126
+ *
127
+ * The `perform` callback runs inside `startTransition` — it should fetch the
128
+ * RSC payload, update router state, and return the wrapped React element.
129
+ * The pending URL shows immediately (useOptimistic urgent update) and reverts
130
+ * to null when the transition commits (atomic with the new tree).
131
+ *
132
+ * Returns a Promise that resolves when the async work completes (note: the
133
+ * React transition may not have committed yet, but all state updates are done).
134
+ *
135
+ * Used for: navigate(), refresh(), popstate with fetch.
136
+ */
137
+ export function navigateTransition(
138
+ pendingUrl: string,
139
+ perform: () => Promise<ReactNode>,
140
+ ): Promise<void> {
141
+ if (_navigateTransition) {
142
+ return _navigateTransition(pendingUrl, perform);
143
+ }
144
+ // Fallback: no TransitionRoot mounted (shouldn't happen in production)
145
+ return perform().then(() => {});
146
+ }
147
+
80
148
  /**
81
149
  * Check if the TransitionRoot is mounted and ready for renders.
82
150
  * Used by browser-entry.ts to guard against renders before hydration.
@@ -1,12 +1,11 @@
1
1
  // useNavigationPending — returns true while an RSC navigation is in flight.
2
2
  // See design/19-client-navigation.md §"useNavigationPending()"
3
3
  //
4
- // Reads from NavigationContext so the pending state updates atomically
5
- // with params/pathname in the same React commit. Falls back to the
6
- // router's external store when no NavigationProvider is mounted (SSR,
7
- // tests without a React tree).
4
+ // Reads from PendingNavigationContext (provided by TransitionRoot) so the
5
+ // pending state shows immediately (urgent update) and clears atomically
6
+ // with the new tree (same startTransition commit).
8
7
 
9
- import { useNavigationContext } from './navigation-context.js';
8
+ import { usePendingNavigationUrl } from './pending-navigation-context.js';
10
9
 
11
10
  /**
12
11
  * Returns true while an RSC navigation is in flight.
@@ -33,8 +32,7 @@ import { useNavigationContext } from './navigation-context.js';
33
32
  * ```
34
33
  */
35
34
  export function useNavigationPending(): boolean {
36
- const navState = useNavigationContext();
37
- // During SSR or outside NavigationProvider, no navigation is pending
38
- if (!navState) return false;
39
- return navState.pendingUrl !== null;
35
+ const pendingUrl = usePendingNavigationUrl();
36
+ // During SSR or outside PendingNavigationProvider, no navigation is pending
37
+ return pendingUrl !== null;
40
38
  }