@timber-js/app 0.1.23 → 0.1.25
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/_chunks/{ssr-data-B2yikEEB.js → ssr-data-DLnbYpj1.js} +2 -4
- package/dist/_chunks/{ssr-data-B2yikEEB.js.map → ssr-data-DLnbYpj1.js.map} +1 -1
- package/dist/_chunks/{use-cookie-D5aS4slY.js → use-cookie-dDbpCTx-.js} +2 -2
- package/dist/_chunks/{use-cookie-D5aS4slY.js.map → use-cookie-dDbpCTx-.js.map} +1 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +4 -3
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-dev.d.ts +29 -0
- package/dist/client/browser-dev.d.ts.map +1 -0
- package/dist/client/browser-links.d.ts +32 -0
- package/dist/client/browser-links.d.ts.map +1 -0
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +150 -122
- package/dist/client/index.js.map +1 -1
- package/dist/client/navigation-context.d.ts +52 -0
- package/dist/client/navigation-context.d.ts.map +1 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/transition-root.d.ts +54 -0
- package/dist/client/transition-root.d.ts.map +1 -0
- package/dist/client/use-params.d.ts +35 -25
- package/dist/client/use-params.d.ts.map +1 -1
- package/dist/client/use-pathname.d.ts +11 -4
- package/dist/client/use-pathname.d.ts.map +1 -1
- package/dist/client/use-router.d.ts +14 -0
- package/dist/client/use-router.d.ts.map +1 -1
- package/dist/cookies/index.js +2 -2
- package/dist/server/index.js +264 -218
- package/dist/server/index.js.map +1 -1
- package/dist/server/metadata-platform.d.ts +34 -0
- package/dist/server/metadata-platform.d.ts.map +1 -0
- package/dist/server/metadata-render.d.ts.map +1 -1
- package/dist/server/metadata-social.d.ts +24 -0
- package/dist/server/metadata-social.d.ts.map +1 -0
- package/dist/server/pipeline-interception.d.ts +32 -0
- package/dist/server/pipeline-interception.d.ts.map +1 -0
- package/dist/server/pipeline-metadata.d.ts +31 -0
- package/dist/server/pipeline-metadata.d.ts.map +1 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/nitro.ts +9 -7
- package/src/cli.ts +9 -2
- package/src/client/browser-dev.ts +142 -0
- package/src/client/browser-entry.ts +73 -223
- package/src/client/browser-links.ts +90 -0
- package/src/client/index.ts +4 -0
- package/src/client/navigation-context.ts +118 -0
- package/src/client/router.ts +37 -33
- package/src/client/transition-root.tsx +86 -0
- package/src/client/use-params.ts +50 -54
- package/src/client/use-pathname.ts +31 -24
- package/src/client/use-router.ts +17 -15
- package/src/server/metadata-platform.ts +229 -0
- package/src/server/metadata-render.ts +9 -363
- package/src/server/metadata-social.ts +184 -0
- package/src/server/pipeline-interception.ts +76 -0
- package/src/server/pipeline-metadata.ts +90 -0
- package/src/server/pipeline.ts +2 -148
package/src/client/router.ts
CHANGED
|
@@ -5,8 +5,8 @@ import { SegmentCache, PrefetchCache, buildSegmentTree } from './segment-cache';
|
|
|
5
5
|
import type { SegmentInfo } from './segment-cache';
|
|
6
6
|
import { HistoryStack } from './history';
|
|
7
7
|
import type { HeadElement } from './head';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { setCurrentParams } from './use-params.js';
|
|
9
|
+
import { setNavigationState } from './navigation-context.js';
|
|
10
10
|
|
|
11
11
|
// ─── Types ───────────────────────────────────────────────────────
|
|
12
12
|
|
|
@@ -325,9 +325,26 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
325
325
|
}
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
-
/**
|
|
329
|
-
|
|
330
|
-
|
|
328
|
+
/**
|
|
329
|
+
* Update navigation state (params + pathname) for the next render.
|
|
330
|
+
*
|
|
331
|
+
* Sets both the module-level fallback (for tests and SSR) and the
|
|
332
|
+
* navigation context state (read by renderRoot to wrap the element
|
|
333
|
+
* in NavigationProvider). The context update is atomic with the tree
|
|
334
|
+
* render — both are passed to reactRoot.render() in the same call.
|
|
335
|
+
*/
|
|
336
|
+
function updateNavigationState(
|
|
337
|
+
params: Record<string, string | string[]> | null | undefined,
|
|
338
|
+
url: string
|
|
339
|
+
): void {
|
|
340
|
+
const resolvedParams = params ?? {};
|
|
341
|
+
// Module-level fallback for tests (no NavigationProvider) and SSR
|
|
342
|
+
setCurrentParams(resolvedParams);
|
|
343
|
+
// Navigation context — read by renderRoot to wrap the RSC element
|
|
344
|
+
const pathname = url.startsWith('http')
|
|
345
|
+
? new URL(url).pathname
|
|
346
|
+
: url.split('?')[0] || '/';
|
|
347
|
+
setNavigationState({ params: resolvedParams, pathname });
|
|
331
348
|
}
|
|
332
349
|
|
|
333
350
|
/** Apply head elements (title, meta tags) to the DOM if available. */
|
|
@@ -394,18 +411,14 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
394
411
|
// header reflects the currently mounted segments.
|
|
395
412
|
updateSegmentCache(result.segmentInfo);
|
|
396
413
|
|
|
397
|
-
// Update
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
flushSync(() => {
|
|
406
|
-
renderPayload(result.payload);
|
|
407
|
-
notifyParamsListeners();
|
|
408
|
-
});
|
|
414
|
+
// Update navigation state (params + pathname) before rendering.
|
|
415
|
+
// The renderRoot callback reads this state and wraps the RSC element
|
|
416
|
+
// in NavigationProvider — so the context value and the element tree
|
|
417
|
+
// are passed to reactRoot.render() in the same call, making the
|
|
418
|
+
// update atomic. Preserved layouts see new params in the same render
|
|
419
|
+
// pass as the new tree, preventing the dual-active-row flash.
|
|
420
|
+
updateNavigationState(result.params, url);
|
|
421
|
+
renderPayload(result.payload);
|
|
409
422
|
|
|
410
423
|
// Update document.title and <meta> tags with the new page's metadata
|
|
411
424
|
applyHead(result.headElements);
|
|
@@ -465,12 +478,9 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
465
478
|
// Update segment cache with fresh segment info from full render
|
|
466
479
|
updateSegmentCache(result.segmentInfo);
|
|
467
480
|
|
|
468
|
-
// Atomic update — see navigate() for rationale on
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
renderPayload(result.payload);
|
|
472
|
-
notifyParamsListeners();
|
|
473
|
-
});
|
|
481
|
+
// Atomic update — see navigate() for rationale on NavigationProvider.
|
|
482
|
+
updateNavigationState(result.params, currentUrl);
|
|
483
|
+
renderPayload(result.payload);
|
|
474
484
|
applyHead(result.headElements);
|
|
475
485
|
} finally {
|
|
476
486
|
setPending(false);
|
|
@@ -485,11 +495,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
485
495
|
|
|
486
496
|
if (entry && entry.payload !== null) {
|
|
487
497
|
// Replay cached payload — no server roundtrip
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
renderPayload(entry.payload);
|
|
491
|
-
notifyParamsListeners();
|
|
492
|
-
});
|
|
498
|
+
updateNavigationState(entry.params, url);
|
|
499
|
+
renderPayload(entry.payload);
|
|
493
500
|
applyHead(entry.headElements);
|
|
494
501
|
afterPaint(() => {
|
|
495
502
|
deps.scrollTo(0, scrollY);
|
|
@@ -505,16 +512,13 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
505
512
|
const stateTree = segmentCache.serializeStateTree();
|
|
506
513
|
const result = await fetchRscPayload(url, deps, stateTree);
|
|
507
514
|
updateSegmentCache(result.segmentInfo);
|
|
508
|
-
|
|
515
|
+
updateNavigationState(result.params, url);
|
|
509
516
|
historyStack.push(url, {
|
|
510
517
|
payload: result.payload,
|
|
511
518
|
headElements: result.headElements,
|
|
512
519
|
params: result.params,
|
|
513
520
|
});
|
|
514
|
-
|
|
515
|
-
renderPayload(result.payload);
|
|
516
|
-
notifyParamsListeners();
|
|
517
|
-
});
|
|
521
|
+
renderPayload(result.payload);
|
|
518
522
|
applyHead(result.headElements);
|
|
519
523
|
afterPaint(() => {
|
|
520
524
|
deps.scrollTo(0, scrollY);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TransitionRoot — Wrapper component for transition-based rendering.
|
|
3
|
+
*
|
|
4
|
+
* Solves the "new boundary has no old content" problem for client-side
|
|
5
|
+
* navigation. When React renders a completely new Suspense boundary via
|
|
6
|
+
* root.render(), it shows the fallback immediately — root.render() is
|
|
7
|
+
* always an urgent update regardless of startTransition.
|
|
8
|
+
*
|
|
9
|
+
* TransitionRoot holds the current element in React state. Navigation
|
|
10
|
+
* updates call startTransition(() => setState(newElement)), which IS
|
|
11
|
+
* a transition update. React keeps the old committed tree visible while
|
|
12
|
+
* any new Suspense boundaries in the transition resolve.
|
|
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.
|
|
17
|
+
*
|
|
18
|
+
* See design/05-streaming.md §"deferSuspenseFor"
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useState, startTransition, type ReactNode } from 'react';
|
|
22
|
+
|
|
23
|
+
// ─── Module-level render function ────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 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).
|
|
29
|
+
*/
|
|
30
|
+
let _transitionRender: ((element: ReactNode) => void) | null = null;
|
|
31
|
+
|
|
32
|
+
// ─── Component ───────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Root wrapper component that enables transition-based rendering.
|
|
36
|
+
*
|
|
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).
|
|
40
|
+
*
|
|
41
|
+
* Usage in browser-entry.ts:
|
|
42
|
+
* const rootEl = createElement(TransitionRoot, { initial: wrapped });
|
|
43
|
+
* reactRoot = hydrateRoot(document, rootEl);
|
|
44
|
+
*
|
|
45
|
+
* Subsequent navigations:
|
|
46
|
+
* transitionRender(newWrappedElement);
|
|
47
|
+
*/
|
|
48
|
+
export function TransitionRoot({ initial }: { initial: ReactNode }): ReactNode {
|
|
49
|
+
const [element, setElement] = useState<ReactNode>(initial);
|
|
50
|
+
|
|
51
|
+
// Update the module-level ref on every render so it always points
|
|
52
|
+
// to the current component instance's setState.
|
|
53
|
+
_transitionRender = (newElement: ReactNode) => {
|
|
54
|
+
startTransition(() => {
|
|
55
|
+
setElement(newElement);
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return element;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Public API ──────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
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.
|
|
70
|
+
*
|
|
71
|
+
* Falls back to no-op if TransitionRoot hasn't mounted yet (shouldn't
|
|
72
|
+
* happen in practice — TransitionRoot mounts during hydration).
|
|
73
|
+
*/
|
|
74
|
+
export function transitionRender(element: ReactNode): void {
|
|
75
|
+
if (_transitionRender) {
|
|
76
|
+
_transitionRender(element);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if the TransitionRoot is mounted and ready for renders.
|
|
82
|
+
* Used by browser-entry.ts to guard against renders before hydration.
|
|
83
|
+
*/
|
|
84
|
+
export function isTransitionRootReady(): boolean {
|
|
85
|
+
return _transitionRender !== null;
|
|
86
|
+
}
|
package/src/client/use-params.ts
CHANGED
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
* (populated by ssr-entry.ts) to ensure correct per-request isolation
|
|
19
19
|
* across concurrent requests with streaming Suspense.
|
|
20
20
|
*
|
|
21
|
-
* Reactivity: useParams()
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* Reactivity: On the client, useParams() reads from NavigationContext
|
|
22
|
+
* which is updated atomically with the RSC tree render. This replaces
|
|
23
|
+
* the previous useSyncExternalStore approach that suffered from a
|
|
24
|
+
* timing gap between tree render and store notification — causing
|
|
25
|
+
* preserved layout components to briefly show stale active state.
|
|
25
26
|
*
|
|
26
27
|
* All mutable state is delegated to client/state.ts for singleton guarantees.
|
|
27
28
|
* See design/18-build-system.md §"Singleton State Registry"
|
|
@@ -29,18 +30,20 @@
|
|
|
29
30
|
* Design doc: design/09-typescript.md §"Typed Routes"
|
|
30
31
|
*/
|
|
31
32
|
|
|
32
|
-
import { useSyncExternalStore } from 'react';
|
|
33
33
|
import type { Routes } from '#/index.js';
|
|
34
34
|
import { getSsrData } from './ssr-data.js';
|
|
35
35
|
import { currentParams, _setCurrentParams, paramsListeners } from './state.js';
|
|
36
|
+
import { useNavigationContext } from './navigation-context.js';
|
|
36
37
|
|
|
37
38
|
// ---------------------------------------------------------------------------
|
|
38
|
-
// Module-level subscribe/notify pattern —
|
|
39
|
+
// Module-level subscribe/notify pattern — kept for backward compat and tests
|
|
39
40
|
// ---------------------------------------------------------------------------
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
|
-
* Subscribe to params changes.
|
|
43
|
-
*
|
|
43
|
+
* Subscribe to params changes.
|
|
44
|
+
* Retained for backward compatibility with tests that verify the
|
|
45
|
+
* subscribe/notify contract. On the client, useParams() reads from
|
|
46
|
+
* NavigationContext instead.
|
|
44
47
|
*/
|
|
45
48
|
export function subscribe(callback: () => void): () => void {
|
|
46
49
|
paramsListeners.add(callback);
|
|
@@ -48,51 +51,43 @@ export function subscribe(callback: () => void): () => void {
|
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
/**
|
|
51
|
-
* Get the current params snapshot (
|
|
52
|
-
*
|
|
54
|
+
* Get the current params snapshot (module-level fallback).
|
|
55
|
+
* Used by tests and by the hook when called outside a React component.
|
|
53
56
|
*/
|
|
54
57
|
export function getSnapshot(): Record<string, string | string[]> {
|
|
55
58
|
return currentParams;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
/**
|
|
59
|
-
* Get the server-side params snapshot (SSR).
|
|
60
|
-
* Falls back to the module-level currentParams if no SSR context
|
|
61
|
-
* is available (shouldn't happen, but defensive).
|
|
62
|
-
*/
|
|
63
|
-
function getServerSnapshot(): Record<string, string | string[]> {
|
|
64
|
-
return getSsrData()?.params ?? currentParams;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
61
|
// ---------------------------------------------------------------------------
|
|
68
62
|
// Framework API — called by the segment router on each navigation
|
|
69
63
|
// ---------------------------------------------------------------------------
|
|
70
64
|
|
|
71
65
|
/**
|
|
72
|
-
* Set the current route params
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
66
|
+
* Set the current route params in the module-level store.
|
|
67
|
+
*
|
|
68
|
+
* Called by the router on each navigation. This updates the fallback
|
|
69
|
+
* snapshot used by tests and by the hook when called outside a React
|
|
70
|
+
* component (no NavigationContext available).
|
|
77
71
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
72
|
+
* On the client, the primary reactivity path is NavigationContext —
|
|
73
|
+
* the router calls setNavigationState() then renderRoot() which wraps
|
|
74
|
+
* the element in NavigationProvider. setCurrentParams is still called
|
|
75
|
+
* for the module-level fallback.
|
|
80
76
|
*
|
|
81
|
-
* On the client, the segment router calls this on each navigation.
|
|
82
77
|
* During SSR, params are also available via getSsrData().params
|
|
83
|
-
* (ALS-backed)
|
|
84
|
-
* module-level fallback path.
|
|
78
|
+
* (ALS-backed).
|
|
85
79
|
*/
|
|
86
80
|
export function setCurrentParams(params: Record<string, string | string[]>): void {
|
|
87
81
|
_setCurrentParams(params);
|
|
88
82
|
}
|
|
89
83
|
|
|
90
84
|
/**
|
|
91
|
-
* Notify all
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
85
|
+
* Notify all legacy subscribers that params have changed.
|
|
86
|
+
*
|
|
87
|
+
* Retained for backward compatibility with tests. On the client,
|
|
88
|
+
* the NavigationContext + renderRoot pattern replaces this — params
|
|
89
|
+
* update atomically with the tree render, so explicit notification
|
|
90
|
+
* is no longer needed.
|
|
96
91
|
*/
|
|
97
92
|
export function notifyParamsListeners(): void {
|
|
98
93
|
for (const listener of paramsListeners) {
|
|
@@ -110,9 +105,15 @@ export function notifyParamsListeners(): void {
|
|
|
110
105
|
* The optional `_route` argument exists only for TypeScript narrowing —
|
|
111
106
|
* it does not affect the runtime return value.
|
|
112
107
|
*
|
|
108
|
+
* On the client, reads from NavigationContext (provided by
|
|
109
|
+
* NavigationProvider in renderRoot). This ensures params update
|
|
110
|
+
* atomically with the RSC tree — no timing gap.
|
|
111
|
+
*
|
|
113
112
|
* During SSR, reads from the ALS-backed SSR data context to ensure
|
|
114
|
-
* per-request isolation
|
|
115
|
-
*
|
|
113
|
+
* per-request isolation across concurrent requests with streaming Suspense.
|
|
114
|
+
*
|
|
115
|
+
* When called outside a React component (e.g., in test assertions),
|
|
116
|
+
* falls back to the module-level snapshot.
|
|
116
117
|
*
|
|
117
118
|
* @overload Typed — when a known route path is passed, returns the
|
|
118
119
|
* exact params shape from the generated Routes interface.
|
|
@@ -121,25 +122,20 @@ export function notifyParamsListeners(): void {
|
|
|
121
122
|
export function useParams<R extends keyof Routes>(route: R): Routes[R]['params'];
|
|
122
123
|
export function useParams(route?: string): Record<string, string | string[]>;
|
|
123
124
|
export function useParams(_route?: string): Record<string, string | string[]> {
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
// We must always call the hook (Rules of Hooks — no conditional hook calls).
|
|
129
|
-
// React picks the right snapshot function based on the environment.
|
|
130
|
-
//
|
|
131
|
-
// When called outside a React component (e.g., in test assertions),
|
|
132
|
-
// useSyncExternalStore throws because there's no dispatcher. In that case,
|
|
133
|
-
// fall back to reading the snapshot directly.
|
|
125
|
+
// Try reading from NavigationContext (client-side, inside React tree).
|
|
126
|
+
// During SSR, no NavigationProvider is mounted, so this returns null.
|
|
127
|
+
// When called outside a React component, useContext throws — caught below.
|
|
134
128
|
try {
|
|
135
|
-
|
|
129
|
+
const navContext = useNavigationContext();
|
|
130
|
+
if (navContext !== null) {
|
|
131
|
+
return navContext.params;
|
|
132
|
+
}
|
|
136
133
|
} catch {
|
|
137
|
-
// No React dispatcher available
|
|
138
|
-
//
|
|
139
|
-
// e.g. in test assertions that verify the current params value.
|
|
140
|
-
// Use getServerSnapshot() because it checks the ALS-backed SSR context
|
|
141
|
-
// first (request-isolated), falling back to module-level currentParams
|
|
142
|
-
// only when no SSR context exists (client-side / tests).
|
|
143
|
-
return getServerSnapshot();
|
|
134
|
+
// No React dispatcher available (called outside a component).
|
|
135
|
+
// Fall through to module-level snapshot below.
|
|
144
136
|
}
|
|
137
|
+
|
|
138
|
+
// SSR path: read from ALS-backed SSR data context.
|
|
139
|
+
// Falls back to module-level currentParams for tests.
|
|
140
|
+
return getSsrData()?.params ?? currentParams;
|
|
145
141
|
}
|
|
@@ -4,40 +4,47 @@
|
|
|
4
4
|
* Returns the pathname portion of the current URL (e.g. '/dashboard/settings').
|
|
5
5
|
* Updates when client-side navigation changes the URL.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* On the client, reads from NavigationContext which is updated atomically
|
|
8
|
+
* with the RSC tree render. This replaces the previous useSyncExternalStore
|
|
9
|
+
* approach which only subscribed to popstate events — meaning usePathname()
|
|
10
|
+
* did NOT re-render on forward navigation (pushState). The context approach
|
|
11
|
+
* fixes this: pathname updates in the same render pass as the new tree.
|
|
10
12
|
*
|
|
11
13
|
* During SSR, reads the request pathname from the SSR ALS context
|
|
12
14
|
* (populated by ssr-entry.ts) instead of window.location.
|
|
15
|
+
*
|
|
16
|
+
* Compatible with Next.js's `usePathname()` from `next/navigation`.
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
|
-
import { useSyncExternalStore } from 'react';
|
|
16
19
|
import { getSsrData } from './ssr-data.js';
|
|
17
|
-
|
|
18
|
-
function getPathname(): string {
|
|
19
|
-
if (typeof window !== 'undefined') return window.location.pathname;
|
|
20
|
-
return getSsrData()?.pathname ?? '/';
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function getServerPathname(): string {
|
|
24
|
-
return getSsrData()?.pathname ?? '/';
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function subscribe(callback: () => void): () => void {
|
|
28
|
-
// Listen for popstate (back/forward) and timber's custom navigation events.
|
|
29
|
-
// pushState/replaceState don't fire popstate, but timber's router calls
|
|
30
|
-
// onPendingChange listeners after navigation — components re-render
|
|
31
|
-
// naturally via React's tree update from the new RSC payload.
|
|
32
|
-
window.addEventListener('popstate', callback);
|
|
33
|
-
return () => window.removeEventListener('popstate', callback);
|
|
34
|
-
}
|
|
20
|
+
import { useNavigationContext } from './navigation-context.js';
|
|
35
21
|
|
|
36
22
|
/**
|
|
37
23
|
* Read the current URL pathname.
|
|
38
24
|
*
|
|
39
|
-
*
|
|
25
|
+
* On the client, reads from NavigationContext (provided by
|
|
26
|
+
* NavigationProvider in renderRoot). During SSR, reads from the
|
|
27
|
+
* ALS-backed SSR data context. Falls back to window.location.pathname
|
|
28
|
+
* when called outside a React component (e.g., in tests).
|
|
40
29
|
*/
|
|
41
30
|
export function usePathname(): string {
|
|
42
|
-
|
|
31
|
+
// Try reading from NavigationContext (client-side, inside React tree).
|
|
32
|
+
// During SSR, no NavigationProvider is mounted, so this returns null.
|
|
33
|
+
try {
|
|
34
|
+
const navContext = useNavigationContext();
|
|
35
|
+
if (navContext !== null) {
|
|
36
|
+
return navContext.pathname;
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// No React dispatcher available (called outside a component).
|
|
40
|
+
// Fall through to SSR/fallback below.
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// SSR path: read from ALS-backed SSR data context.
|
|
44
|
+
const ssrData = getSsrData();
|
|
45
|
+
if (ssrData) return ssrData.pathname ?? '/';
|
|
46
|
+
|
|
47
|
+
// Final fallback: window.location (tests, edge cases).
|
|
48
|
+
if (typeof window !== 'undefined') return window.location.pathname;
|
|
49
|
+
return '/';
|
|
43
50
|
}
|
package/src/client/use-router.ts
CHANGED
|
@@ -7,9 +7,22 @@
|
|
|
7
7
|
*
|
|
8
8
|
* This wraps timber's internal RouterInstance in the Next.js-compatible
|
|
9
9
|
* AppRouterInstance shape that ecosystem libraries expect.
|
|
10
|
+
*
|
|
11
|
+
* NOTE: Unlike Next.js, these methods do NOT wrap navigation in
|
|
12
|
+
* startTransition. In Next.js, router state is React state (useReducer)
|
|
13
|
+
* so startTransition defers the update and provides isPending tracking.
|
|
14
|
+
* In timber, navigation calls reactRoot.render() which is a root-level
|
|
15
|
+
* render — startTransition has no effect on root renders.
|
|
16
|
+
*
|
|
17
|
+
* Navigation state (params, pathname) is delivered atomically via
|
|
18
|
+
* NavigationContext embedded in the element tree passed to
|
|
19
|
+
* reactRoot.render(). See design/19-client-navigation.md §"NavigationContext".
|
|
20
|
+
*
|
|
21
|
+
* For loading UI during navigation, use:
|
|
22
|
+
* - useLinkStatus() — per-link pending indicator (inside <Link>)
|
|
23
|
+
* - useNavigationPending() — global navigation pending state
|
|
10
24
|
*/
|
|
11
25
|
|
|
12
|
-
import { startTransition } from 'react';
|
|
13
26
|
import { getRouterOrNull } from './router-ref.js';
|
|
14
27
|
|
|
15
28
|
export interface AppRouterInstance {
|
|
@@ -54,14 +67,7 @@ export function useRouter(): AppRouterInstance {
|
|
|
54
67
|
}
|
|
55
68
|
return;
|
|
56
69
|
}
|
|
57
|
-
|
|
58
|
-
// React 19's startTransition accepts async callbacks — it keeps
|
|
59
|
-
// isPending=true until the returned promise resolves. This means
|
|
60
|
-
// useTransition's isPending reflects the full RSC fetch + render
|
|
61
|
-
// lifecycle when wrapping router.push() in startTransition.
|
|
62
|
-
startTransition(async () => {
|
|
63
|
-
await router.navigate(href, { scroll: options?.scroll });
|
|
64
|
-
});
|
|
70
|
+
void router.navigate(href, { scroll: options?.scroll });
|
|
65
71
|
},
|
|
66
72
|
replace(href: string, options?: { scroll?: boolean }) {
|
|
67
73
|
const router = getRouterOrNull();
|
|
@@ -71,9 +77,7 @@ export function useRouter(): AppRouterInstance {
|
|
|
71
77
|
}
|
|
72
78
|
return;
|
|
73
79
|
}
|
|
74
|
-
|
|
75
|
-
await router.navigate(href, { scroll: options?.scroll, replace: true });
|
|
76
|
-
});
|
|
80
|
+
void router.navigate(href, { scroll: options?.scroll, replace: true });
|
|
77
81
|
},
|
|
78
82
|
refresh() {
|
|
79
83
|
const router = getRouterOrNull();
|
|
@@ -83,9 +87,7 @@ export function useRouter(): AppRouterInstance {
|
|
|
83
87
|
}
|
|
84
88
|
return;
|
|
85
89
|
}
|
|
86
|
-
|
|
87
|
-
await router.refresh();
|
|
88
|
-
});
|
|
90
|
+
void router.refresh();
|
|
89
91
|
},
|
|
90
92
|
back() {
|
|
91
93
|
if (typeof window !== 'undefined') window.history.back();
|