@timber-js/app 0.2.0-alpha.67 → 0.2.0-alpha.69
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/LICENSE +8 -0
- package/dist/client/history.d.ts +19 -4
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/index.js +321 -167
- package/dist/client/index.js.map +1 -1
- package/dist/client/link-pending-store.d.ts +3 -3
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/nav-link-store.d.ts +36 -0
- package/dist/client/nav-link-store.d.ts.map +1 -0
- package/dist/client/navigation-api-types.d.ts +90 -0
- package/dist/client/navigation-api-types.d.ts.map +1 -0
- package/dist/client/navigation-api.d.ts +115 -0
- package/dist/client/navigation-api.d.ts.map +1 -0
- package/dist/client/navigation-context.d.ts +11 -0
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/{transition-root.d.ts → navigation-root.d.ts} +31 -9
- package/dist/client/navigation-root.d.ts.map +1 -0
- package/dist/client/nuqs-adapter.d.ts.map +1 -1
- package/dist/client/router.d.ts +46 -2
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/top-loader.d.ts +2 -2
- package/dist/client/top-loader.d.ts.map +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/route-element-builder.d.ts +10 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-wrappers.d.ts +3 -3
- package/package.json +6 -7
- package/src/cli.ts +0 -0
- package/src/client/browser-entry.ts +92 -19
- package/src/client/history.ts +26 -4
- package/src/client/link-pending-store.ts +3 -3
- package/src/client/link.tsx +31 -9
- package/src/client/nav-link-store.ts +47 -0
- package/src/client/navigation-api-types.ts +112 -0
- package/src/client/navigation-api.ts +315 -0
- package/src/client/navigation-context.ts +22 -2
- package/src/client/navigation-root.tsx +346 -0
- package/src/client/nuqs-adapter.tsx +16 -3
- package/src/client/router.ts +186 -18
- package/src/client/rsc-fetch.ts +4 -3
- package/src/client/top-loader.tsx +12 -4
- package/src/client/use-navigation-pending.ts +1 -1
- package/src/server/route-element-builder.ts +69 -21
- package/src/server/slot-resolver.ts +37 -35
- package/src/server/ssr-entry.ts +1 -1
- package/src/server/ssr-wrappers.tsx +10 -10
- package/dist/client/transition-root.d.ts.map +0 -1
- package/src/client/transition-root.tsx +0 -205
|
@@ -18,6 +18,16 @@ import type { RouteMatch } from './pipeline.js';
|
|
|
18
18
|
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
19
19
|
import { DenySignal, RedirectSignal } from './primitives.js';
|
|
20
20
|
import type { InterceptionContext } from './pipeline.js';
|
|
21
|
+
/**
|
|
22
|
+
* Detect whether a component is a React client reference.
|
|
23
|
+
* Client references have $$typeof set to Symbol.for('react.client.reference')
|
|
24
|
+
* by registerClientReference() in the React Flight server runtime.
|
|
25
|
+
*
|
|
26
|
+
* Used to skip OTEL tracing wrappers that would call the component as a
|
|
27
|
+
* function. Client components must go through createElement only — they are
|
|
28
|
+
* serialized as references in the RSC Flight stream, not executed on the server.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isClientReference(component: unknown): boolean;
|
|
21
31
|
/**
|
|
22
32
|
* Thrown when a defineSegmentParams codec's parse() fails.
|
|
23
33
|
* The pipeline catches this and responds with 404.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAK9D,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAM7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAK9D,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAM7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAezD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,OAAO,GAAG,OAAO,CAM7D;AAID;;;GAGG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;gBAC/B,OAAO,EAAE,MAAM;CAI5B;AAID,qDAAqD;AACrD,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;CACvC;AAED,+CAA+C;AAC/C,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,wFAAwF;IACxF,OAAO,EAAE,KAAK,CAAC,YAAY,CAAC;IAC5B,2CAA2C;IAC3C,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,wDAAwD;IACxD,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,qCAAqC;IACrC,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,eAAe,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED;;;GAGG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;aAE7B,MAAM,EAAE,UAAU,GAAG,cAAc;aACnC,gBAAgB,EAAE,oBAAoB,EAAE;aACxC,QAAQ,EAAE,mBAAmB,EAAE;gBAF/B,MAAM,EAAE,UAAU,GAAG,cAAc,EACnC,gBAAgB,EAAE,oBAAoB,EAAE,EACxC,QAAQ,EAAE,mBAAmB,EAAE;CAIlD;AA8DD;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,YAAY,CAAC,EAAE,mBAAmB,EAClC,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GACnC,OAAO,CAAC,kBAAkB,CAAC,CAiT7B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slot-resolver.d.ts","sourceRoot":"","sources":["../../src/server/slot-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;
|
|
1
|
+
{"version":3,"file":"slot-resolver.d.ts","sourceRoot":"","sources":["../../src/server/slot-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAQH,OAAO,KAAK,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAGrE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAE9D,KAAK,eAAe,GAAG,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,KAAK,CAAC,YAAY,CAAC;AAmHlE;;;;;;;;;;GAUG;AACH,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,mBAAmB,EAC7B,KAAK,EAAE,UAAU,EACjB,CAAC,EAAE,eAAe,EAClB,YAAY,CAAC,EAAE,mBAAmB,GACjC,OAAO,CAAC,KAAK,CAAC,YAAY,GAAG,IAAI,CAAC,CAgGpC"}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Radix UI that rely on useId() internally.
|
|
9
9
|
*
|
|
10
10
|
* The client tree (browser-entry.ts) wraps the RSC element with:
|
|
11
|
-
*
|
|
11
|
+
* NavigationRoot → PendingNavigationProvider → Fragment(TopLoader, ...) →
|
|
12
12
|
* TimberNuqsAdapter → NuqsAdapterProvider → NavigationProvider → element
|
|
13
13
|
*
|
|
14
14
|
* The SSR tree must produce the same component boundaries. These wrappers
|
|
@@ -25,7 +25,7 @@ import { type ReactNode } from 'react';
|
|
|
25
25
|
* on both sides.
|
|
26
26
|
*
|
|
27
27
|
* Client tree (browser-entry.ts):
|
|
28
|
-
*
|
|
28
|
+
* NavigationRoot
|
|
29
29
|
* → PendingNavigationProvider
|
|
30
30
|
* → Fragment(TopLoader, element)
|
|
31
31
|
* → TimberNuqsAdapter
|
|
@@ -34,7 +34,7 @@ import { type ReactNode } from 'react';
|
|
|
34
34
|
* → [RSC element]
|
|
35
35
|
*
|
|
36
36
|
* SSR tree (this function):
|
|
37
|
-
*
|
|
37
|
+
* SsrNavigationRoot
|
|
38
38
|
* → SsrPendingProvider
|
|
39
39
|
* → Fragment(SsrTopLoader, element)
|
|
40
40
|
* → SsrNuqsWrapper
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.69",
|
|
4
4
|
"description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -88,11 +88,6 @@
|
|
|
88
88
|
"publishConfig": {
|
|
89
89
|
"access": "public"
|
|
90
90
|
},
|
|
91
|
-
"scripts": {
|
|
92
|
-
"build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
|
|
93
|
-
"typecheck": "tsgo --noEmit",
|
|
94
|
-
"prepublishOnly": "pnpm run build"
|
|
95
|
-
},
|
|
96
91
|
"dependencies": {
|
|
97
92
|
"@opentelemetry/api": "^1.9.1",
|
|
98
93
|
"@opentelemetry/context-async-hooks": "^2.6.1",
|
|
@@ -131,5 +126,9 @@
|
|
|
131
126
|
},
|
|
132
127
|
"engines": {
|
|
133
128
|
"node": ">=22.12.0"
|
|
129
|
+
},
|
|
130
|
+
"scripts": {
|
|
131
|
+
"build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
|
|
132
|
+
"typecheck": "tsgo --noEmit"
|
|
134
133
|
}
|
|
135
|
-
}
|
|
134
|
+
}
|
package/src/cli.ts
CHANGED
|
File without changes
|
|
@@ -59,11 +59,12 @@ import { setupServerLogReplay, setupClientErrorForwarding } from './browser-dev.
|
|
|
59
59
|
// browser-links.ts removed — Link components own their click/hover handlers directly.
|
|
60
60
|
// See LOCAL-340.
|
|
61
61
|
import {
|
|
62
|
-
|
|
62
|
+
NavigationRoot,
|
|
63
63
|
transitionRender,
|
|
64
64
|
navigateTransition,
|
|
65
65
|
installDeferredNavigation,
|
|
66
|
-
|
|
66
|
+
setHardNavigating,
|
|
67
|
+
} from './navigation-root.js';
|
|
67
68
|
import {
|
|
68
69
|
isStaleClientReference,
|
|
69
70
|
isChunkLoadError,
|
|
@@ -76,6 +77,11 @@ import {
|
|
|
76
77
|
DEPLOYMENT_ID_HEADER,
|
|
77
78
|
RELOAD_HEADER,
|
|
78
79
|
} from './rsc-fetch.js';
|
|
80
|
+
import {
|
|
81
|
+
hasNavigationApi,
|
|
82
|
+
setupNavigationApi,
|
|
83
|
+
type NavigationApiController,
|
|
84
|
+
} from './navigation-api.js';
|
|
79
85
|
|
|
80
86
|
// ─── Server Action Dispatch ──────────────────────────────────────
|
|
81
87
|
|
|
@@ -152,7 +158,10 @@ setServerCallback(async (id: string, args: unknown[]) => {
|
|
|
152
158
|
const router = getRouter();
|
|
153
159
|
void router.navigate(wrapper._redirect);
|
|
154
160
|
} catch {
|
|
155
|
-
// Router not yet initialized — fall back to full navigation
|
|
161
|
+
// Router not yet initialized — fall back to full navigation.
|
|
162
|
+
// Set hard-navigating flag to prevent Navigation API interception
|
|
163
|
+
// and React from rendering during page teardown. See TIM-626.
|
|
164
|
+
setHardNavigating(true);
|
|
156
165
|
window.location.href = wrapper._redirect;
|
|
157
166
|
}
|
|
158
167
|
return undefined;
|
|
@@ -257,6 +266,12 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
257
266
|
// Assigned inside initRouter() which is called in both branches.
|
|
258
267
|
let router!: RouterInstance;
|
|
259
268
|
|
|
269
|
+
// Navigation API controller — initialized when the API is available.
|
|
270
|
+
// Declared here (before the hydration if/else) because initRouter()
|
|
271
|
+
// is called from runPreHydration() inside both branches, and it
|
|
272
|
+
// assigns to this variable. Must be in scope before first use.
|
|
273
|
+
let navApiController: NavigationApiController | null = null;
|
|
274
|
+
|
|
260
275
|
if (timberChunks) {
|
|
261
276
|
const encoder = new TextEncoder();
|
|
262
277
|
|
|
@@ -402,10 +417,10 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
402
417
|
// Hydrate on document — the root layout renders the full <html> tree,
|
|
403
418
|
// so React owns the entire document from the root.
|
|
404
419
|
// Wrap with NavigationProvider (for atomic useParams/usePathname),
|
|
405
|
-
// TimberNuqsAdapter (for nuqs context), and
|
|
420
|
+
// TimberNuqsAdapter (for nuqs context), and NavigationRoot (for
|
|
406
421
|
// transition-based rendering during client navigation).
|
|
407
422
|
//
|
|
408
|
-
//
|
|
423
|
+
// NavigationRoot holds the element in React state and updates via
|
|
409
424
|
// startTransition, so React keeps old UI visible while new Suspense
|
|
410
425
|
// boundaries resolve during navigation. See design/05-streaming.md.
|
|
411
426
|
const navState = getNavigationState();
|
|
@@ -415,7 +430,7 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
415
430
|
element as React.ReactNode
|
|
416
431
|
);
|
|
417
432
|
const wrapped = createElement(TimberNuqsAdapter, null, withNav);
|
|
418
|
-
const rootElement = createElement(
|
|
433
|
+
const rootElement = createElement(NavigationRoot, {
|
|
419
434
|
initial: wrapped,
|
|
420
435
|
topLoaderConfig: _config.topLoader,
|
|
421
436
|
});
|
|
@@ -459,13 +474,13 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
459
474
|
// Instead, installDeferredNavigation sets up one-shot callbacks so the
|
|
460
475
|
// first navigateTransition/transitionRender call creates the root on
|
|
461
476
|
// `document` with the navigated content. After that initial render,
|
|
462
|
-
//
|
|
477
|
+
// NavigationRoot's real startTransition-based callbacks take over.
|
|
463
478
|
//
|
|
464
479
|
// This also fixes TIM-580 (navigation from SSR-only pages) because the
|
|
465
|
-
// deferred callbacks ensure
|
|
480
|
+
// deferred callbacks ensure NavigationRoot is mounted before the first
|
|
466
481
|
// navigation completes.
|
|
467
482
|
installDeferredNavigation((initial) => {
|
|
468
|
-
const rootElement = createElement(
|
|
483
|
+
const rootElement = createElement(NavigationRoot, {
|
|
469
484
|
initial,
|
|
470
485
|
topLoaderConfig: _config.topLoader,
|
|
471
486
|
});
|
|
@@ -478,12 +493,18 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
478
493
|
// Extracted into a function so both the hydration and createRoot paths
|
|
479
494
|
// can call it. Must run before hydrateRoot so useRouter() works during
|
|
480
495
|
// the initial render. renderRoot uses transitionRender which is set
|
|
481
|
-
// by the
|
|
496
|
+
// by the NavigationRoot component during hydration.
|
|
482
497
|
function initRouter(): void {
|
|
498
|
+
// Feature-detect Navigation API. When available, the navigate event
|
|
499
|
+
// replaces popstate for back/forward and catches external navigations.
|
|
500
|
+
// See design/19-client-navigation.md §"Navigation API Integration"
|
|
501
|
+
const useNavApi = hasNavigationApi();
|
|
502
|
+
|
|
483
503
|
const deps: RouterDeps = {
|
|
484
504
|
fetch: (url, init) => window.fetch(url, init),
|
|
485
505
|
pushState: (data, unused, url) => window.history.pushState(data, unused, url),
|
|
486
506
|
replaceState: (data, unused, url) => window.history.replaceState(data, unused, url),
|
|
507
|
+
navigationApiActive: useNavApi,
|
|
487
508
|
scrollTo: (x, y) => {
|
|
488
509
|
// Scroll the document viewport.
|
|
489
510
|
window.scrollTo(x, y);
|
|
@@ -526,7 +547,7 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
526
547
|
}
|
|
527
548
|
},
|
|
528
549
|
|
|
529
|
-
// Render decoded RSC tree via
|
|
550
|
+
// Render decoded RSC tree via NavigationRoot's state-based mechanism.
|
|
530
551
|
// Used for non-navigation renders (popstate cached replay, applyRevalidation).
|
|
531
552
|
// Wraps with NavigationProvider + TimberNuqsAdapter.
|
|
532
553
|
//
|
|
@@ -584,6 +605,37 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
584
605
|
|
|
585
606
|
router = createRouter(deps);
|
|
586
607
|
setGlobalRouter(router);
|
|
608
|
+
|
|
609
|
+
// Set up Navigation API integration after router is created.
|
|
610
|
+
// The navigate event listener delegates to router.navigate and
|
|
611
|
+
// router.handlePopState for external navigations and traversals.
|
|
612
|
+
if (useNavApi) {
|
|
613
|
+
navApiController = setupNavigationApi({
|
|
614
|
+
onExternalNavigate: async (url, { replace, signal, scroll }) => {
|
|
615
|
+
// Navigation intercepted by the Navigation API. Covers both
|
|
616
|
+
// Link <a> clicks (user-initiated) and external navigations.
|
|
617
|
+
// The Navigation API handles the URL update via intercept(),
|
|
618
|
+
// so pass _skipHistory to avoid double pushState.
|
|
619
|
+
await router.navigate(url, {
|
|
620
|
+
replace,
|
|
621
|
+
scroll,
|
|
622
|
+
_signal: signal,
|
|
623
|
+
_skipHistory: true,
|
|
624
|
+
});
|
|
625
|
+
},
|
|
626
|
+
onTraverse: async (url, scrollY, signal) => {
|
|
627
|
+
// Back/forward — delegate to the router's popstate handler.
|
|
628
|
+
await router.handlePopState(url, scrollY, signal);
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// Wire the router-navigating flag into RouterDeps.
|
|
633
|
+
// This must be done after setupNavigationApi returns the controller.
|
|
634
|
+
deps.setRouterNavigating = (v) => navApiController!.setRouterNavigating(v);
|
|
635
|
+
deps.saveNavigationEntryScroll = (y) => navApiController!.saveScrollPosition(y);
|
|
636
|
+
deps.completeRouterNavigation = () => navApiController!.completeRouterNavigation();
|
|
637
|
+
deps.navigationNavigate = (url, replace) => navApiController!.navigate(url, replace);
|
|
638
|
+
}
|
|
587
639
|
}
|
|
588
640
|
|
|
589
641
|
// ── Pre-hydration sequence ──────────────────────────────────────────
|
|
@@ -619,9 +671,17 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
619
671
|
headElements: null, // SSR already set the correct head
|
|
620
672
|
});
|
|
621
673
|
|
|
622
|
-
// Initialize
|
|
623
|
-
//
|
|
624
|
-
|
|
674
|
+
// Initialize scroll state for the initial entry.
|
|
675
|
+
// When Navigation API is available, use per-entry state.
|
|
676
|
+
// Otherwise fall back to history.state.
|
|
677
|
+
// Note: navApiController is assigned inside initRouter() which runs
|
|
678
|
+
// synchronously before this point via runPreHydration().
|
|
679
|
+
const navApi = navApiController as NavigationApiController | null;
|
|
680
|
+
if (navApi) {
|
|
681
|
+
navApi.saveScrollPosition(0);
|
|
682
|
+
} else {
|
|
683
|
+
window.history.replaceState({ timber: true, scrollY: 0 }, '');
|
|
684
|
+
}
|
|
625
685
|
|
|
626
686
|
// Populate the segment cache from server-embedded segment metadata.
|
|
627
687
|
// This enables state tree diffing from the very first client navigation.
|
|
@@ -653,27 +713,40 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
653
713
|
}
|
|
654
714
|
|
|
655
715
|
// Register popstate handler for back/forward navigation.
|
|
716
|
+
// When Navigation API is active, the navigate event covers traversals —
|
|
717
|
+
// popstate is a no-op. When unavailable, popstate handles back/forward.
|
|
718
|
+
//
|
|
656
719
|
// Use pathname+search (not full href) to match the URL format used by
|
|
657
720
|
// navigate() — Link hrefs are relative paths like "/scroll-test/page-a".
|
|
658
721
|
// Read scrollY from history.state — the browser maintains per-entry state
|
|
659
722
|
// so duplicate URLs in history each have their own scroll position.
|
|
660
723
|
window.addEventListener('popstate', () => {
|
|
724
|
+
// Navigation API handles traversals via the navigate event.
|
|
725
|
+
if (navApiController) return;
|
|
726
|
+
|
|
661
727
|
const state = window.history.state;
|
|
662
728
|
const scrollY = state && typeof state.scrollY === 'number' ? state.scrollY : 0;
|
|
663
729
|
void router.handlePopState(window.location.pathname + window.location.search, scrollY);
|
|
664
730
|
});
|
|
665
731
|
|
|
666
|
-
// Keep
|
|
732
|
+
// Keep scroll position up to date as the user scrolls.
|
|
667
733
|
// This ensures that when the user presses back/forward, the departing
|
|
668
734
|
// page's scroll position is already saved in its history entry.
|
|
669
|
-
//
|
|
735
|
+
// When Navigation API is available, uses per-entry state via
|
|
736
|
+
// navigation.updateCurrentEntry(). Otherwise falls back to history.state.
|
|
737
|
+
// Debounced to avoid excessive state updates during smooth scrolling.
|
|
670
738
|
let scrollTimer: ReturnType<typeof setTimeout>;
|
|
671
739
|
function saveScrollPosition(): void {
|
|
672
740
|
clearTimeout(scrollTimer);
|
|
673
741
|
scrollTimer = setTimeout(() => {
|
|
674
|
-
const
|
|
675
|
-
if (
|
|
676
|
-
|
|
742
|
+
const y = getScrollY();
|
|
743
|
+
if (navApiController) {
|
|
744
|
+
navApiController.saveScrollPosition(y);
|
|
745
|
+
} else {
|
|
746
|
+
const state = window.history.state;
|
|
747
|
+
if (state && typeof state === 'object') {
|
|
748
|
+
window.history.replaceState({ ...state, scrollY: y }, '');
|
|
749
|
+
}
|
|
677
750
|
}
|
|
678
751
|
}, 100);
|
|
679
752
|
}
|
package/src/client/history.ts
CHANGED
|
@@ -23,20 +23,42 @@ export interface HistoryEntry {
|
|
|
23
23
|
* On forward navigation, the new page's payload is pushed onto the stack.
|
|
24
24
|
* On popstate, the cached payload is replayed instantly.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
26
|
+
* Supports two keying modes:
|
|
27
|
+
* - **URL-keyed** (default): entries keyed by pathname + search.
|
|
28
|
+
* Used with the History API fallback.
|
|
29
|
+
* - **Entry-key + URL**: when the Navigation API is available,
|
|
30
|
+
* entries can also be stored by Navigation entry key for
|
|
31
|
+
* disambiguation of duplicate URLs in the history stack.
|
|
32
|
+
* Falls back to URL lookup when entry key is not found.
|
|
33
|
+
*
|
|
34
|
+
* Scroll positions are stored in history.state or Navigation API entry
|
|
35
|
+
* state, not in this stack — see design/19-client-navigation.md §Scroll Restoration.
|
|
28
36
|
*
|
|
29
37
|
* Entries persist for the session duration (no expiry) and are cleared
|
|
30
38
|
* when the tab is closed — matching browser back-button behavior.
|
|
31
39
|
*/
|
|
32
40
|
export class HistoryStack {
|
|
33
41
|
private entries = new Map<string, HistoryEntry>();
|
|
42
|
+
/** Entries keyed by Navigation API entry key for duplicate URL disambiguation. */
|
|
43
|
+
private entryKeyMap = new Map<string, HistoryEntry>();
|
|
34
44
|
|
|
35
|
-
push(url: string, entry: HistoryEntry): void {
|
|
45
|
+
push(url: string, entry: HistoryEntry, entryKey?: string): void {
|
|
36
46
|
this.entries.set(url, entry);
|
|
47
|
+
if (entryKey) {
|
|
48
|
+
this.entryKeyMap.set(entryKey, entry);
|
|
49
|
+
}
|
|
37
50
|
}
|
|
38
51
|
|
|
39
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Get an entry. When an entry key is provided (Navigation API),
|
|
54
|
+
* tries the entry-key map first for accurate disambiguation of
|
|
55
|
+
* duplicate URLs, then falls back to URL lookup.
|
|
56
|
+
*/
|
|
57
|
+
get(url: string, entryKey?: string): HistoryEntry | undefined {
|
|
58
|
+
if (entryKey) {
|
|
59
|
+
const byKey = this.entryKeyMap.get(entryKey);
|
|
60
|
+
if (byKey) return byKey;
|
|
61
|
+
}
|
|
40
62
|
return this.entries.get(url);
|
|
41
63
|
}
|
|
42
64
|
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* 1. Link click handler: setLinkForCurrentNavigation(instance) →
|
|
15
15
|
* resets previous link (urgent), sets new link pending (urgent),
|
|
16
16
|
* stores setter + increments navId
|
|
17
|
-
* 2.
|
|
18
|
-
* 3.
|
|
17
|
+
* 2. NavigationRoot startTransition: captures navId, does async work
|
|
18
|
+
* 3. NavigationRoot commit: resetLinkPending(capturedNavId) →
|
|
19
19
|
* calls setter(IDLE) inside the transition (batched, atomic with tree)
|
|
20
20
|
* Only clears if navId matches (prevents stale T1 from clearing T2's link)
|
|
21
21
|
*
|
|
@@ -96,7 +96,7 @@ export function getCurrentNavId(): number {
|
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
98
|
* Reset the current link's pending state to IDLE, but only if the navId
|
|
99
|
-
* matches. Called inside
|
|
99
|
+
* matches. Called inside NavigationRoot's startTransition after the async
|
|
100
100
|
* work completes — the setter call is a transition update, so it commits
|
|
101
101
|
* atomically with the new tree.
|
|
102
102
|
*
|
package/src/client/link.tsx
CHANGED
|
@@ -39,6 +39,8 @@ import {
|
|
|
39
39
|
PENDING_LINK_STATUS,
|
|
40
40
|
type LinkPendingInstance,
|
|
41
41
|
} from './link-pending-store.js';
|
|
42
|
+
import { setNavLinkMetadata } from './nav-link-store.js';
|
|
43
|
+
import { hasNavigationApi } from './navigation-api.js';
|
|
42
44
|
|
|
43
45
|
// ─── Current Search Params ────────────────────────────────────────
|
|
44
46
|
|
|
@@ -407,7 +409,7 @@ export function Link({
|
|
|
407
409
|
// setter is invoked during navigation — zero other links re-render.
|
|
408
410
|
//
|
|
409
411
|
// Eager show: click handler calls setLinkStatus(PENDING) directly (urgent).
|
|
410
|
-
// Atomic clear:
|
|
412
|
+
// Atomic clear: NavigationRoot calls resetLinkPending(navId) inside
|
|
411
413
|
// startTransition — batched with the new tree commit.
|
|
412
414
|
//
|
|
413
415
|
// See design/19-client-navigation.md §"Per-Link Pending State"
|
|
@@ -474,24 +476,44 @@ export function Link({
|
|
|
474
476
|
const router = getRouterOrNull();
|
|
475
477
|
if (!router) return; // SSR or pre-hydration — fall through to browser nav
|
|
476
478
|
|
|
477
|
-
event.preventDefault();
|
|
478
479
|
const shouldScroll = scroll !== false;
|
|
479
480
|
|
|
480
|
-
// Re-merge preserved search params at click time to pick up any
|
|
481
|
-
// URL changes since render (e.g. from other navigations or pushState).
|
|
482
|
-
const navHref = preserveSearchParams
|
|
483
|
-
? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams)
|
|
484
|
-
: resolvedHref;
|
|
485
|
-
|
|
486
481
|
// Eagerly show pending state on this link (urgent update, immediate).
|
|
487
482
|
// Only this Link re-renders — all other Links are unaffected.
|
|
488
483
|
setLinkStatus(PENDING_LINK_STATUS);
|
|
489
484
|
|
|
490
|
-
// Register this link in the pending store so
|
|
485
|
+
// Register this link in the pending store so NavigationRoot can
|
|
491
486
|
// reset it to IDLE inside startTransition (atomic with new tree).
|
|
492
487
|
// Also resets any previous pending link to IDLE.
|
|
493
488
|
setLinkForCurrentNavigation(linkInstanceRef.current);
|
|
494
489
|
|
|
490
|
+
// When Navigation API is active, let the <a> click propagate
|
|
491
|
+
// naturally — do NOT call preventDefault(). The navigate event
|
|
492
|
+
// handler intercepts it and runs the RSC pipeline. This is a
|
|
493
|
+
// user-initiated navigation, so Chrome shows the native loading
|
|
494
|
+
// indicator (tab spinner). Metadata (scroll, link instance) is
|
|
495
|
+
// passed via nav-link-store so the handler can configure the nav.
|
|
496
|
+
//
|
|
497
|
+
// Without Navigation API (fallback), preventDefault and drive
|
|
498
|
+
// navigation through the router as before.
|
|
499
|
+
if (hasNavigationApi()) {
|
|
500
|
+
setNavLinkMetadata({
|
|
501
|
+
scroll: shouldScroll,
|
|
502
|
+
linkInstance: linkInstanceRef.current,
|
|
503
|
+
});
|
|
504
|
+
// Don't preventDefault — let the <a> click fire the navigate event
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// History API fallback — prevent default and navigate via router
|
|
509
|
+
event.preventDefault();
|
|
510
|
+
|
|
511
|
+
// Re-merge preserved search params at click time to pick up any
|
|
512
|
+
// URL changes since render (e.g. from other navigations or pushState).
|
|
513
|
+
const navHref = preserveSearchParams
|
|
514
|
+
? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams)
|
|
515
|
+
: resolvedHref;
|
|
516
|
+
|
|
495
517
|
void router.navigate(navHref, { scroll: shouldScroll });
|
|
496
518
|
}
|
|
497
519
|
: userOnClick; // External links — just pass through user's onClick
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation Link Store — passes per-link metadata from Link's onClick
|
|
3
|
+
* to the Navigation API's navigate event handler.
|
|
4
|
+
*
|
|
5
|
+
* When the Navigation API is active, Link does NOT call event.preventDefault()
|
|
6
|
+
* or router.navigate(). Instead it stores metadata (scroll option, link
|
|
7
|
+
* pending instance) here, and lets the <a> click propagate naturally.
|
|
8
|
+
* The navigate event handler reads this metadata to configure the RSC
|
|
9
|
+
* navigation with the correct options.
|
|
10
|
+
*
|
|
11
|
+
* This store is consumed once per navigation — after reading, the metadata
|
|
12
|
+
* is cleared. If no metadata is present (e.g., a plain <a> tag without
|
|
13
|
+
* our Link component), the navigate handler uses default options.
|
|
14
|
+
*
|
|
15
|
+
* See design/19-client-navigation.md §"Navigation API Integration"
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { LinkPendingInstance } from './link-pending-store.js';
|
|
19
|
+
|
|
20
|
+
export interface NavLinkMetadata {
|
|
21
|
+
/** Whether to scroll to top after navigation. Default: true. */
|
|
22
|
+
scroll: boolean;
|
|
23
|
+
/** The Link's pending state instance for per-link status tracking. */
|
|
24
|
+
linkInstance: LinkPendingInstance | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let pendingMetadata: NavLinkMetadata | null = null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Store metadata from Link's onClick for the next navigate event.
|
|
31
|
+
* Called synchronously in the click handler — the navigate event
|
|
32
|
+
* fires synchronously after onClick returns.
|
|
33
|
+
*/
|
|
34
|
+
export function setNavLinkMetadata(metadata: NavLinkMetadata): void {
|
|
35
|
+
pendingMetadata = metadata;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Consume the stored metadata. Returns null if no Link onClick
|
|
40
|
+
* preceded this navigation (e.g., plain <a> tag, programmatic nav).
|
|
41
|
+
* Clears the store after reading.
|
|
42
|
+
*/
|
|
43
|
+
export function consumeNavLinkMetadata(): NavLinkMetadata | null {
|
|
44
|
+
const metadata = pendingMetadata;
|
|
45
|
+
pendingMetadata = null;
|
|
46
|
+
return metadata;
|
|
47
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient type declarations for the Navigation API.
|
|
3
|
+
*
|
|
4
|
+
* The Navigation API is not yet in TypeScript's standard lib. These types
|
|
5
|
+
* are used internally via type assertions — we never import Navigation API
|
|
6
|
+
* types unconditionally. Progressive enhancement only: the API is feature-
|
|
7
|
+
* detected at runtime.
|
|
8
|
+
*
|
|
9
|
+
* See https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ─── Navigation Entry ────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface NavigationHistoryEntry {
|
|
15
|
+
readonly key: string;
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly url: string | null;
|
|
18
|
+
readonly index: number;
|
|
19
|
+
readonly sameDocument: boolean;
|
|
20
|
+
getState(): unknown;
|
|
21
|
+
addEventListener(type: string, listener: EventListener): void;
|
|
22
|
+
removeEventListener(type: string, listener: EventListener): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Navigation Destination ──────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface NavigationDestination {
|
|
28
|
+
readonly url: string;
|
|
29
|
+
readonly key: string | null;
|
|
30
|
+
readonly id: string | null;
|
|
31
|
+
readonly index: number;
|
|
32
|
+
readonly sameDocument: boolean;
|
|
33
|
+
getState(): unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── Navigate Event ──────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export interface NavigateEvent extends Event {
|
|
39
|
+
readonly navigationType: 'push' | 'replace' | 'reload' | 'traverse';
|
|
40
|
+
readonly destination: NavigationDestination;
|
|
41
|
+
readonly canIntercept: boolean;
|
|
42
|
+
readonly userInitiated: boolean;
|
|
43
|
+
readonly hashChange: boolean;
|
|
44
|
+
readonly signal: AbortSignal;
|
|
45
|
+
readonly formData: FormData | null;
|
|
46
|
+
readonly downloadRequest: string | null;
|
|
47
|
+
readonly info: unknown;
|
|
48
|
+
intercept(options?: NavigateInterceptOptions): void;
|
|
49
|
+
scroll(): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface NavigateInterceptOptions {
|
|
53
|
+
handler?: () => Promise<void>;
|
|
54
|
+
focusReset?: 'after-transition' | 'manual';
|
|
55
|
+
scroll?: 'after-transition' | 'manual';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── Navigation Transition ───────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export interface NavigationTransition {
|
|
61
|
+
readonly navigationType: 'push' | 'replace' | 'reload' | 'traverse';
|
|
62
|
+
readonly from: NavigationHistoryEntry;
|
|
63
|
+
readonly finished: Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Navigation Result ───────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export interface NavigationResult {
|
|
69
|
+
committed: Promise<NavigationHistoryEntry>;
|
|
70
|
+
finished: Promise<NavigationHistoryEntry>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Navigation Interface ────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export interface NavigationApi {
|
|
76
|
+
readonly currentEntry: NavigationHistoryEntry | null;
|
|
77
|
+
readonly transition: NavigationTransition | null;
|
|
78
|
+
readonly canGoBack: boolean;
|
|
79
|
+
readonly canGoForward: boolean;
|
|
80
|
+
entries(): NavigationHistoryEntry[];
|
|
81
|
+
navigate(url: string, options?: NavigationNavigateOptions): NavigationResult;
|
|
82
|
+
reload(options?: NavigationReloadOptions): NavigationResult;
|
|
83
|
+
traverseTo(key: string, options?: NavigationOptions): NavigationResult;
|
|
84
|
+
back(options?: NavigationOptions): NavigationResult;
|
|
85
|
+
forward(options?: NavigationOptions): NavigationResult;
|
|
86
|
+
updateCurrentEntry(options: NavigationUpdateCurrentEntryOptions): void;
|
|
87
|
+
addEventListener(type: 'navigate', listener: (event: NavigateEvent) => void): void;
|
|
88
|
+
addEventListener(type: 'navigatesuccess', listener: (event: Event) => void): void;
|
|
89
|
+
addEventListener(type: 'navigateerror', listener: (event: Event) => void): void;
|
|
90
|
+
addEventListener(type: 'currententrychange', listener: (event: Event) => void): void;
|
|
91
|
+
addEventListener(type: string, listener: EventListener): void;
|
|
92
|
+
removeEventListener(type: string, listener: EventListener): void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface NavigationNavigateOptions {
|
|
96
|
+
state?: unknown;
|
|
97
|
+
history?: 'auto' | 'push' | 'replace';
|
|
98
|
+
info?: unknown;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface NavigationReloadOptions {
|
|
102
|
+
state?: unknown;
|
|
103
|
+
info?: unknown;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface NavigationOptions {
|
|
107
|
+
info?: unknown;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface NavigationUpdateCurrentEntryOptions {
|
|
111
|
+
state: unknown;
|
|
112
|
+
}
|