@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.
- package/dist/client/index.js +189 -141
- package/dist/client/index.js.map +1 -1
- package/dist/client/link-status-provider.d.ts +2 -8
- package/dist/client/link-status-provider.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts +0 -8
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/pending-navigation-context.d.ts +32 -0
- package/dist/client/pending-navigation-context.d.ts.map +1 -0
- package/dist/client/router.d.ts +12 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/transition-root.d.ts +33 -13
- package/dist/client/transition-root.d.ts.map +1 -1
- package/dist/client/use-navigation-pending.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/browser-entry.ts +26 -19
- package/src/client/link-status-provider.tsx +11 -15
- package/src/client/navigation-context.ts +1 -9
- package/src/client/pending-navigation-context.ts +66 -0
- package/src/client/router.ts +131 -98
- package/src/client/transition-root.tsx +88 -20
- package/src/client/use-navigation-pending.ts +7 -9
package/src/client/router.ts
CHANGED
|
@@ -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 {
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
401
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
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
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
541
|
-
applyHead(
|
|
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
|
-
*
|
|
15
|
-
* the
|
|
16
|
-
*
|
|
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 {
|
|
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
|
|
36
|
+
// ─── Module-level functions ──────────────────────────────────────
|
|
24
37
|
|
|
25
38
|
/**
|
|
26
39
|
* Module-level reference to the state setter wrapped in startTransition.
|
|
27
|
-
*
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
66
|
-
* visible while any new Suspense
|
|
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
|
-
*
|
|
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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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 {
|
|
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
|
|
37
|
-
// During SSR or outside
|
|
38
|
-
|
|
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
|
}
|