@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
package/LICENSE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
DONTFUCKINGUSE LICENSE
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Daniel Saewitz
|
|
4
|
+
|
|
5
|
+
This software may not be used, copied, modified, merged, published,
|
|
6
|
+
distributed, sublicensed, or sold by anyone other than the copyright holder.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
package/dist/client/history.d.ts
CHANGED
|
@@ -14,16 +14,31 @@ export interface HistoryEntry {
|
|
|
14
14
|
* On forward navigation, the new page's payload is pushed onto the stack.
|
|
15
15
|
* On popstate, the cached payload is replayed instantly.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* Supports two keying modes:
|
|
18
|
+
* - **URL-keyed** (default): entries keyed by pathname + search.
|
|
19
|
+
* Used with the History API fallback.
|
|
20
|
+
* - **Entry-key + URL**: when the Navigation API is available,
|
|
21
|
+
* entries can also be stored by Navigation entry key for
|
|
22
|
+
* disambiguation of duplicate URLs in the history stack.
|
|
23
|
+
* Falls back to URL lookup when entry key is not found.
|
|
24
|
+
*
|
|
25
|
+
* Scroll positions are stored in history.state or Navigation API entry
|
|
26
|
+
* state, not in this stack — see design/19-client-navigation.md §Scroll Restoration.
|
|
19
27
|
*
|
|
20
28
|
* Entries persist for the session duration (no expiry) and are cleared
|
|
21
29
|
* when the tab is closed — matching browser back-button behavior.
|
|
22
30
|
*/
|
|
23
31
|
export declare class HistoryStack {
|
|
24
32
|
private entries;
|
|
25
|
-
|
|
26
|
-
|
|
33
|
+
/** Entries keyed by Navigation API entry key for duplicate URL disambiguation. */
|
|
34
|
+
private entryKeyMap;
|
|
35
|
+
push(url: string, entry: HistoryEntry, entryKey?: string): void;
|
|
36
|
+
/**
|
|
37
|
+
* Get an entry. When an entry key is provided (Navigation API),
|
|
38
|
+
* tries the entry-key map first for accurate disambiguation of
|
|
39
|
+
* duplicate URLs, then falls back to URL lookup.
|
|
40
|
+
*/
|
|
41
|
+
get(url: string, entryKey?: string): HistoryEntry | undefined;
|
|
27
42
|
has(url: string): boolean;
|
|
28
43
|
}
|
|
29
44
|
//# sourceMappingURL=history.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"history.d.ts","sourceRoot":"","sources":["../../src/client/history.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAI1C,MAAM,WAAW,YAAY;IAC3B,kEAAkE;IAClE,OAAO,EAAE,OAAO,CAAC;IACjB,4FAA4F;IAC5F,YAAY,CAAC,EAAE,WAAW,EAAE,GAAG,IAAI,CAAC;IACpC,+EAA+E;IAC/E,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG,IAAI,CAAC;CACnD;AAID
|
|
1
|
+
{"version":3,"file":"history.d.ts","sourceRoot":"","sources":["../../src/client/history.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAI1C,MAAM,WAAW,YAAY;IAC3B,kEAAkE;IAClE,OAAO,EAAE,OAAO,CAAC;IACjB,4FAA4F;IAC5F,YAAY,CAAC,EAAE,WAAW,EAAE,GAAG,IAAI,CAAC;IACpC,+EAA+E;IAC/E,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG,IAAI,CAAC;CACnD;AAID;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,OAAO,CAAmC;IAClD,kFAAkF;IAClF,OAAO,CAAC,WAAW,CAAmC;IAEtD,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI;IAO/D;;;;OAIG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS;IAQ7D,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;CAG1B"}
|
package/dist/client/index.js
CHANGED
|
@@ -107,6 +107,226 @@ function unmountLinkForCurrentNavigation(link) {
|
|
|
107
107
|
const store = getStore();
|
|
108
108
|
if (store.current === link) store.current = null;
|
|
109
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Store metadata from Link's onClick for the next navigate event.
|
|
112
|
+
* Called synchronously in the click handler — the navigate event
|
|
113
|
+
* fires synchronously after onClick returns.
|
|
114
|
+
*/
|
|
115
|
+
function setNavLinkMetadata(metadata) {}
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/client/navigation-context.ts
|
|
118
|
+
/**
|
|
119
|
+
* NavigationContext — React context for navigation state.
|
|
120
|
+
*
|
|
121
|
+
* Holds the current route params and pathname, updated atomically
|
|
122
|
+
* with the RSC tree on each navigation. This replaces the previous
|
|
123
|
+
* useSyncExternalStore approach for useSegmentParams() and usePathname(),
|
|
124
|
+
* which suffered from a timing gap: the new tree could commit before
|
|
125
|
+
* the external store re-renders fired, causing a frame where both
|
|
126
|
+
* old and new active states were visible simultaneously.
|
|
127
|
+
*
|
|
128
|
+
* By wrapping the RSC payload element in NavigationProvider inside
|
|
129
|
+
* renderRoot(), the context value and the element tree are passed to
|
|
130
|
+
* reactRoot.render() in the same call — atomic by construction.
|
|
131
|
+
* All consumers (useParams, usePathname) see the new values in the
|
|
132
|
+
* same render pass as the new tree.
|
|
133
|
+
*
|
|
134
|
+
* During SSR, no NavigationProvider is mounted. Hooks fall back to
|
|
135
|
+
* the ALS-backed getSsrData() for per-request isolation.
|
|
136
|
+
*
|
|
137
|
+
* IMPORTANT: createContext and useContext are NOT available in the RSC
|
|
138
|
+
* environment (React Server Components use a stripped-down React).
|
|
139
|
+
* The context is lazily initialized on first access, and all functions
|
|
140
|
+
* that depend on these APIs are safe to call from any environment —
|
|
141
|
+
* they return null or no-op when the APIs aren't available.
|
|
142
|
+
*
|
|
143
|
+
* SINGLETON GUARANTEE: All shared mutable state uses globalThis via
|
|
144
|
+
* Symbol.for keys. The RSC client bundler can duplicate this module
|
|
145
|
+
* across chunks (browser-entry graph + client-reference graph). With
|
|
146
|
+
* ESM output, each chunk gets its own module scope — module-level
|
|
147
|
+
* variables would create separate singleton instances per chunk.
|
|
148
|
+
* globalThis guarantees a single instance regardless of duplication.
|
|
149
|
+
*
|
|
150
|
+
* This workaround will be removed when Rolldown ships `format: 'app'`
|
|
151
|
+
* (module registry format that deduplicates like webpack/Turbopack).
|
|
152
|
+
* See design/27-chunking-strategy.md.
|
|
153
|
+
*
|
|
154
|
+
* See design/19-client-navigation.md §"NavigationContext"
|
|
155
|
+
*/
|
|
156
|
+
/**
|
|
157
|
+
* The context is created lazily to avoid calling createContext at module
|
|
158
|
+
* level. In the RSC environment, React.createContext doesn't exist —
|
|
159
|
+
* calling it at import time would crash the server.
|
|
160
|
+
*
|
|
161
|
+
* Context instances are stored on globalThis (NOT in module-level
|
|
162
|
+
* variables) because the ESM bundler can duplicate this module across
|
|
163
|
+
* chunks. Module-level variables would create separate instances per
|
|
164
|
+
* chunk — the provider in NavigationRoot (index chunk) would use
|
|
165
|
+
* context A while the consumer in useNavigationPending (shared chunk)
|
|
166
|
+
* reads from context B. globalThis guarantees a single instance.
|
|
167
|
+
*
|
|
168
|
+
* See design/27-chunking-strategy.md §"Singleton Safety"
|
|
169
|
+
*/
|
|
170
|
+
var NAV_CTX_KEY = Symbol.for("__timber_nav_ctx");
|
|
171
|
+
var PENDING_CTX_KEY = Symbol.for("__timber_pending_nav_ctx");
|
|
172
|
+
function getOrCreateContext() {
|
|
173
|
+
const existing = globalThis[NAV_CTX_KEY];
|
|
174
|
+
if (existing !== void 0) return existing;
|
|
175
|
+
if (typeof React.createContext === "function") {
|
|
176
|
+
const ctx = React.createContext(null);
|
|
177
|
+
globalThis[NAV_CTX_KEY] = ctx;
|
|
178
|
+
return ctx;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Read the navigation context. Returns null during SSR (no provider)
|
|
183
|
+
* or in the RSC environment (no context available).
|
|
184
|
+
* Internal — used by useSegmentParams() and usePathname().
|
|
185
|
+
*/
|
|
186
|
+
function useNavigationContext() {
|
|
187
|
+
const ctx = getOrCreateContext();
|
|
188
|
+
if (!ctx) return null;
|
|
189
|
+
if (typeof React.useContext !== "function") return null;
|
|
190
|
+
return React.useContext(ctx);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Wraps children with NavigationContext.Provider.
|
|
194
|
+
*
|
|
195
|
+
* Used in browser-entry.ts renderRoot to wrap the RSC payload element
|
|
196
|
+
* so that navigation state updates atomically with the tree render.
|
|
197
|
+
*/
|
|
198
|
+
function NavigationProvider({ value, children }) {
|
|
199
|
+
const ctx = getOrCreateContext();
|
|
200
|
+
if (!ctx) return children;
|
|
201
|
+
return createElement(ctx.Provider, { value }, children);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Navigation state communicated between the router and renderRoot.
|
|
205
|
+
*
|
|
206
|
+
* The router calls setNavigationState() before renderRoot(). The
|
|
207
|
+
* renderRoot callback reads via getNavigationState() to create the
|
|
208
|
+
* NavigationProvider with the correct params/pathname.
|
|
209
|
+
*
|
|
210
|
+
* This is NOT used by hooks directly — hooks read from React context.
|
|
211
|
+
*
|
|
212
|
+
* Stored on globalThis (like the context instances above) because the
|
|
213
|
+
* router lives in one chunk while renderRoot lives in another. Module-
|
|
214
|
+
* level variables would be separate per chunk.
|
|
215
|
+
*/
|
|
216
|
+
var NAV_STATE_KEY = Symbol.for("__timber_nav_state");
|
|
217
|
+
function _getNavStateStore() {
|
|
218
|
+
const g = globalThis;
|
|
219
|
+
if (!g[NAV_STATE_KEY]) g[NAV_STATE_KEY] = { current: {
|
|
220
|
+
params: {},
|
|
221
|
+
pathname: "/"
|
|
222
|
+
} };
|
|
223
|
+
return g[NAV_STATE_KEY];
|
|
224
|
+
}
|
|
225
|
+
function setNavigationState(state) {
|
|
226
|
+
_getNavStateStore().current = state;
|
|
227
|
+
}
|
|
228
|
+
function getNavigationState() {
|
|
229
|
+
return _getNavStateStore().current;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Separate context for the in-flight navigation URL. Provided by
|
|
233
|
+
* NavigationRoot (urgent useState), consumed by useNavigationPending
|
|
234
|
+
* and TopLoader. Per-link pending state uses useOptimistic instead
|
|
235
|
+
* (see link-pending-store.ts).
|
|
236
|
+
*
|
|
237
|
+
* Uses globalThis via Symbol.for for the same reason as NavigationContext
|
|
238
|
+
* above — the bundler may duplicate this module across chunks, and module-
|
|
239
|
+
* level variables would create separate context instances.
|
|
240
|
+
*/
|
|
241
|
+
function getOrCreatePendingContext() {
|
|
242
|
+
const existing = globalThis[PENDING_CTX_KEY];
|
|
243
|
+
if (existing !== void 0) return existing;
|
|
244
|
+
if (typeof React.createContext === "function") {
|
|
245
|
+
const ctx = React.createContext(null);
|
|
246
|
+
globalThis[PENDING_CTX_KEY] = ctx;
|
|
247
|
+
return ctx;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Read the pending navigation URL from context.
|
|
252
|
+
* Returns null during SSR (no provider) or in the RSC environment.
|
|
253
|
+
*/
|
|
254
|
+
function usePendingNavigationUrl() {
|
|
255
|
+
const ctx = getOrCreatePendingContext();
|
|
256
|
+
if (!ctx) return null;
|
|
257
|
+
if (typeof React.useContext !== "function") return null;
|
|
258
|
+
return React.useContext(ctx);
|
|
259
|
+
}
|
|
260
|
+
//#endregion
|
|
261
|
+
//#region src/client/top-loader.tsx
|
|
262
|
+
/**
|
|
263
|
+
* TopLoader — Built-in progress bar for client navigations.
|
|
264
|
+
*
|
|
265
|
+
* Shows an animated progress bar at the top of the viewport while an RSC
|
|
266
|
+
* navigation is in flight. Injected automatically by the framework into
|
|
267
|
+
* NavigationRoot — users never render this component directly.
|
|
268
|
+
*
|
|
269
|
+
* Configuration is via timber.config.ts `topLoader` key. Enabled by default.
|
|
270
|
+
* Users who want a fully custom progress indicator disable the built-in one
|
|
271
|
+
* (`topLoader: { enabled: false }`) and use `useNavigationPending()` directly.
|
|
272
|
+
*
|
|
273
|
+
* Animation approach: pure CSS @keyframes. The bar crawls from 0% to ~90%
|
|
274
|
+
* width over ~30s using ease-out timing. When navigation completes, the bar
|
|
275
|
+
* snaps to 100% and fades out over 200ms. No JS animation loops (RAF, setInterval).
|
|
276
|
+
*
|
|
277
|
+
* Phase transitions are derived synchronously during render (React's
|
|
278
|
+
* getDerivedStateFromProps pattern) — no useEffect needed for state tracking.
|
|
279
|
+
* The finishing → hidden cleanup uses onTransitionEnd from the CSS transition.
|
|
280
|
+
*
|
|
281
|
+
* When delay > 0, CSS animation-delay + a visibility keyframe ensure the bar
|
|
282
|
+
* stays invisible during the delay period. If navigation finishes before the
|
|
283
|
+
* delay, the bar was never visible so the finish transition is also invisible.
|
|
284
|
+
*
|
|
285
|
+
* See design/19-client-navigation.md §"useNavigationPending()"
|
|
286
|
+
* See LOCAL-336 for design decisions.
|
|
287
|
+
*/
|
|
288
|
+
//#endregion
|
|
289
|
+
//#region src/client/navigation-root.tsx
|
|
290
|
+
/**
|
|
291
|
+
* Module-level flag indicating a hard (MPA) navigation is in progress.
|
|
292
|
+
*
|
|
293
|
+
* When true:
|
|
294
|
+
* - NavigationRoot throws an unresolved thenable to suspend forever,
|
|
295
|
+
* preventing React from rendering children during page teardown
|
|
296
|
+
* (avoids "Rendered more hooks" crashes).
|
|
297
|
+
* - The Navigation API handler skips interception, letting the browser
|
|
298
|
+
* perform a full page load (prevents infinite loops where
|
|
299
|
+
* window.location.href → navigate event → router.navigate → 500 →
|
|
300
|
+
* window.location.href → ...).
|
|
301
|
+
*
|
|
302
|
+
* Uses globalThis for singleton guarantee across chunks (same pattern
|
|
303
|
+
* as NavigationContext). See design/19-client-navigation.md §"Singleton
|
|
304
|
+
* Guarantee via globalThis".
|
|
305
|
+
*/
|
|
306
|
+
var HARD_NAV_KEY = Symbol.for("__timber_hard_navigating");
|
|
307
|
+
function getHardNavStore() {
|
|
308
|
+
const g = globalThis;
|
|
309
|
+
if (!g[HARD_NAV_KEY]) g[HARD_NAV_KEY] = { value: false };
|
|
310
|
+
return g[HARD_NAV_KEY];
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Set the hard-navigating flag. Call this BEFORE setting
|
|
314
|
+
* window.location.href or window.location.reload() to prevent:
|
|
315
|
+
* 1. React from rendering children during page teardown
|
|
316
|
+
* 2. Navigation API from intercepting the hard navigation
|
|
317
|
+
*/
|
|
318
|
+
function setHardNavigating(value) {
|
|
319
|
+
getHardNavStore().value = value;
|
|
320
|
+
}
|
|
321
|
+
//#endregion
|
|
322
|
+
//#region src/client/navigation-api.ts
|
|
323
|
+
/**
|
|
324
|
+
* Returns true if the Navigation API is available in the current environment.
|
|
325
|
+
* Feature-detected at runtime — no polyfill.
|
|
326
|
+
*/
|
|
327
|
+
function hasNavigationApi() {
|
|
328
|
+
return typeof window !== "undefined" && "navigation" in window && window.navigation != null;
|
|
329
|
+
}
|
|
110
330
|
//#endregion
|
|
111
331
|
//#region src/client/link.tsx
|
|
112
332
|
/**
|
|
@@ -285,11 +505,18 @@ function Link({ href, prefetch, scroll, segmentParams, searchParams, preserveSea
|
|
|
285
505
|
}
|
|
286
506
|
const router = getRouterOrNull();
|
|
287
507
|
if (!router) return;
|
|
288
|
-
event.preventDefault();
|
|
289
508
|
const shouldScroll = scroll !== false;
|
|
290
|
-
const navHref = preserveSearchParams ? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams) : resolvedHref;
|
|
291
509
|
setLinkStatus(PENDING_LINK_STATUS);
|
|
292
510
|
setLinkForCurrentNavigation(linkInstanceRef.current);
|
|
511
|
+
if (hasNavigationApi()) {
|
|
512
|
+
setNavLinkMetadata({
|
|
513
|
+
scroll: shouldScroll,
|
|
514
|
+
linkInstance: linkInstanceRef.current
|
|
515
|
+
});
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
event.preventDefault();
|
|
519
|
+
const navHref = preserveSearchParams ? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams) : resolvedHref;
|
|
293
520
|
router.navigate(navHref, { scroll: shouldScroll });
|
|
294
521
|
} : userOnClick;
|
|
295
522
|
const handleMouseEnter = internal && prefetch ? (event) => {
|
|
@@ -424,18 +651,38 @@ var PrefetchCache = class PrefetchCache {
|
|
|
424
651
|
* On forward navigation, the new page's payload is pushed onto the stack.
|
|
425
652
|
* On popstate, the cached payload is replayed instantly.
|
|
426
653
|
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
654
|
+
* Supports two keying modes:
|
|
655
|
+
* - **URL-keyed** (default): entries keyed by pathname + search.
|
|
656
|
+
* Used with the History API fallback.
|
|
657
|
+
* - **Entry-key + URL**: when the Navigation API is available,
|
|
658
|
+
* entries can also be stored by Navigation entry key for
|
|
659
|
+
* disambiguation of duplicate URLs in the history stack.
|
|
660
|
+
* Falls back to URL lookup when entry key is not found.
|
|
661
|
+
*
|
|
662
|
+
* Scroll positions are stored in history.state or Navigation API entry
|
|
663
|
+
* state, not in this stack — see design/19-client-navigation.md §Scroll Restoration.
|
|
429
664
|
*
|
|
430
665
|
* Entries persist for the session duration (no expiry) and are cleared
|
|
431
666
|
* when the tab is closed — matching browser back-button behavior.
|
|
432
667
|
*/
|
|
433
668
|
var HistoryStack = class {
|
|
434
669
|
entries = /* @__PURE__ */ new Map();
|
|
435
|
-
|
|
670
|
+
/** Entries keyed by Navigation API entry key for duplicate URL disambiguation. */
|
|
671
|
+
entryKeyMap = /* @__PURE__ */ new Map();
|
|
672
|
+
push(url, entry, entryKey) {
|
|
436
673
|
this.entries.set(url, entry);
|
|
674
|
+
if (entryKey) this.entryKeyMap.set(entryKey, entry);
|
|
437
675
|
}
|
|
438
|
-
|
|
676
|
+
/**
|
|
677
|
+
* Get an entry. When an entry key is provided (Navigation API),
|
|
678
|
+
* tries the entry-key map first for accurate disambiguation of
|
|
679
|
+
* duplicate URLs, then falls back to URL lookup.
|
|
680
|
+
*/
|
|
681
|
+
get(url, entryKey) {
|
|
682
|
+
if (entryKey) {
|
|
683
|
+
const byKey = this.entryKeyMap.get(entryKey);
|
|
684
|
+
if (byKey) return byKey;
|
|
685
|
+
}
|
|
439
686
|
return this.entries.get(url);
|
|
440
687
|
}
|
|
441
688
|
has(url) {
|
|
@@ -443,150 +690,6 @@ var HistoryStack = class {
|
|
|
443
690
|
}
|
|
444
691
|
};
|
|
445
692
|
//#endregion
|
|
446
|
-
//#region src/client/navigation-context.ts
|
|
447
|
-
/**
|
|
448
|
-
* NavigationContext — React context for navigation state.
|
|
449
|
-
*
|
|
450
|
-
* Holds the current route params and pathname, updated atomically
|
|
451
|
-
* with the RSC tree on each navigation. This replaces the previous
|
|
452
|
-
* useSyncExternalStore approach for useSegmentParams() and usePathname(),
|
|
453
|
-
* which suffered from a timing gap: the new tree could commit before
|
|
454
|
-
* the external store re-renders fired, causing a frame where both
|
|
455
|
-
* old and new active states were visible simultaneously.
|
|
456
|
-
*
|
|
457
|
-
* By wrapping the RSC payload element in NavigationProvider inside
|
|
458
|
-
* renderRoot(), the context value and the element tree are passed to
|
|
459
|
-
* reactRoot.render() in the same call — atomic by construction.
|
|
460
|
-
* All consumers (useParams, usePathname) see the new values in the
|
|
461
|
-
* same render pass as the new tree.
|
|
462
|
-
*
|
|
463
|
-
* During SSR, no NavigationProvider is mounted. Hooks fall back to
|
|
464
|
-
* the ALS-backed getSsrData() for per-request isolation.
|
|
465
|
-
*
|
|
466
|
-
* IMPORTANT: createContext and useContext are NOT available in the RSC
|
|
467
|
-
* environment (React Server Components use a stripped-down React).
|
|
468
|
-
* The context is lazily initialized on first access, and all functions
|
|
469
|
-
* that depend on these APIs are safe to call from any environment —
|
|
470
|
-
* they return null or no-op when the APIs aren't available.
|
|
471
|
-
*
|
|
472
|
-
* SINGLETON GUARANTEE: All shared mutable state uses globalThis via
|
|
473
|
-
* Symbol.for keys. The RSC client bundler can duplicate this module
|
|
474
|
-
* across chunks (browser-entry graph + client-reference graph). With
|
|
475
|
-
* ESM output, each chunk gets its own module scope — module-level
|
|
476
|
-
* variables would create separate singleton instances per chunk.
|
|
477
|
-
* globalThis guarantees a single instance regardless of duplication.
|
|
478
|
-
*
|
|
479
|
-
* This workaround will be removed when Rolldown ships `format: 'app'`
|
|
480
|
-
* (module registry format that deduplicates like webpack/Turbopack).
|
|
481
|
-
* See design/27-chunking-strategy.md.
|
|
482
|
-
*
|
|
483
|
-
* See design/19-client-navigation.md §"NavigationContext"
|
|
484
|
-
*/
|
|
485
|
-
/**
|
|
486
|
-
* The context is created lazily to avoid calling createContext at module
|
|
487
|
-
* level. In the RSC environment, React.createContext doesn't exist —
|
|
488
|
-
* calling it at import time would crash the server.
|
|
489
|
-
*
|
|
490
|
-
* Context instances are stored on globalThis (NOT in module-level
|
|
491
|
-
* variables) because the ESM bundler can duplicate this module across
|
|
492
|
-
* chunks. Module-level variables would create separate instances per
|
|
493
|
-
* chunk — the provider in TransitionRoot (index chunk) would use
|
|
494
|
-
* context A while the consumer in useNavigationPending (shared chunk)
|
|
495
|
-
* reads from context B. globalThis guarantees a single instance.
|
|
496
|
-
*
|
|
497
|
-
* See design/27-chunking-strategy.md §"Singleton Safety"
|
|
498
|
-
*/
|
|
499
|
-
var NAV_CTX_KEY = Symbol.for("__timber_nav_ctx");
|
|
500
|
-
var PENDING_CTX_KEY = Symbol.for("__timber_pending_nav_ctx");
|
|
501
|
-
function getOrCreateContext() {
|
|
502
|
-
const existing = globalThis[NAV_CTX_KEY];
|
|
503
|
-
if (existing !== void 0) return existing;
|
|
504
|
-
if (typeof React.createContext === "function") {
|
|
505
|
-
const ctx = React.createContext(null);
|
|
506
|
-
globalThis[NAV_CTX_KEY] = ctx;
|
|
507
|
-
return ctx;
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
/**
|
|
511
|
-
* Read the navigation context. Returns null during SSR (no provider)
|
|
512
|
-
* or in the RSC environment (no context available).
|
|
513
|
-
* Internal — used by useSegmentParams() and usePathname().
|
|
514
|
-
*/
|
|
515
|
-
function useNavigationContext() {
|
|
516
|
-
const ctx = getOrCreateContext();
|
|
517
|
-
if (!ctx) return null;
|
|
518
|
-
if (typeof React.useContext !== "function") return null;
|
|
519
|
-
return React.useContext(ctx);
|
|
520
|
-
}
|
|
521
|
-
/**
|
|
522
|
-
* Wraps children with NavigationContext.Provider.
|
|
523
|
-
*
|
|
524
|
-
* Used in browser-entry.ts renderRoot to wrap the RSC payload element
|
|
525
|
-
* so that navigation state updates atomically with the tree render.
|
|
526
|
-
*/
|
|
527
|
-
function NavigationProvider({ value, children }) {
|
|
528
|
-
const ctx = getOrCreateContext();
|
|
529
|
-
if (!ctx) return children;
|
|
530
|
-
return createElement(ctx.Provider, { value }, children);
|
|
531
|
-
}
|
|
532
|
-
/**
|
|
533
|
-
* Navigation state communicated between the router and renderRoot.
|
|
534
|
-
*
|
|
535
|
-
* The router calls setNavigationState() before renderRoot(). The
|
|
536
|
-
* renderRoot callback reads via getNavigationState() to create the
|
|
537
|
-
* NavigationProvider with the correct params/pathname.
|
|
538
|
-
*
|
|
539
|
-
* This is NOT used by hooks directly — hooks read from React context.
|
|
540
|
-
*
|
|
541
|
-
* Stored on globalThis (like the context instances above) because the
|
|
542
|
-
* router lives in one chunk while renderRoot lives in another. Module-
|
|
543
|
-
* level variables would be separate per chunk.
|
|
544
|
-
*/
|
|
545
|
-
var NAV_STATE_KEY = Symbol.for("__timber_nav_state");
|
|
546
|
-
function _getNavStateStore() {
|
|
547
|
-
const g = globalThis;
|
|
548
|
-
if (!g[NAV_STATE_KEY]) g[NAV_STATE_KEY] = { current: {
|
|
549
|
-
params: {},
|
|
550
|
-
pathname: "/"
|
|
551
|
-
} };
|
|
552
|
-
return g[NAV_STATE_KEY];
|
|
553
|
-
}
|
|
554
|
-
function setNavigationState(state) {
|
|
555
|
-
_getNavStateStore().current = state;
|
|
556
|
-
}
|
|
557
|
-
function getNavigationState() {
|
|
558
|
-
return _getNavStateStore().current;
|
|
559
|
-
}
|
|
560
|
-
/**
|
|
561
|
-
* Separate context for the in-flight navigation URL. Provided by
|
|
562
|
-
* TransitionRoot (urgent useState), consumed by useNavigationPending
|
|
563
|
-
* and TopLoader. Per-link pending state uses useOptimistic instead
|
|
564
|
-
* (see link-pending-store.ts).
|
|
565
|
-
*
|
|
566
|
-
* Uses globalThis via Symbol.for for the same reason as NavigationContext
|
|
567
|
-
* above — the bundler may duplicate this module across chunks, and module-
|
|
568
|
-
* level variables would create separate context instances.
|
|
569
|
-
*/
|
|
570
|
-
function getOrCreatePendingContext() {
|
|
571
|
-
const existing = globalThis[PENDING_CTX_KEY];
|
|
572
|
-
if (existing !== void 0) return existing;
|
|
573
|
-
if (typeof React.createContext === "function") {
|
|
574
|
-
const ctx = React.createContext(null);
|
|
575
|
-
globalThis[PENDING_CTX_KEY] = ctx;
|
|
576
|
-
return ctx;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
/**
|
|
580
|
-
* Read the pending navigation URL from context.
|
|
581
|
-
* Returns null during SSR (no provider) or in the RSC environment.
|
|
582
|
-
*/
|
|
583
|
-
function usePendingNavigationUrl() {
|
|
584
|
-
const ctx = getOrCreatePendingContext();
|
|
585
|
-
if (!ctx) return null;
|
|
586
|
-
if (typeof React.useContext !== "function") return null;
|
|
587
|
-
return React.useContext(ctx);
|
|
588
|
-
}
|
|
589
|
-
//#endregion
|
|
590
693
|
//#region src/client/use-params.ts
|
|
591
694
|
/**
|
|
592
695
|
* Set the current route params in the module-level store.
|
|
@@ -979,13 +1082,14 @@ var ServerErrorResponse = class extends Error {
|
|
|
979
1082
|
* Also extracts head elements from the X-Timber-Head response header
|
|
980
1083
|
* so the client can update document.title and <meta> tags after navigation.
|
|
981
1084
|
*/
|
|
982
|
-
async function fetchRscPayload(url, deps, stateTree, currentUrl) {
|
|
1085
|
+
async function fetchRscPayload(url, deps, stateTree, currentUrl, signal) {
|
|
983
1086
|
const rscUrl = appendRscParam(url);
|
|
984
1087
|
const headers = buildRscHeaders(stateTree, currentUrl);
|
|
985
1088
|
if (deps.decodeRsc) {
|
|
986
1089
|
const fetchPromise = deps.fetch(rscUrl, {
|
|
987
1090
|
headers,
|
|
988
|
-
redirect: "manual"
|
|
1091
|
+
redirect: "manual",
|
|
1092
|
+
signal
|
|
989
1093
|
});
|
|
990
1094
|
let headElements = null;
|
|
991
1095
|
let segmentInfo = null;
|
|
@@ -1013,7 +1117,8 @@ async function fetchRscPayload(url, deps, stateTree, currentUrl) {
|
|
|
1013
1117
|
}
|
|
1014
1118
|
const response = await deps.fetch(rscUrl, {
|
|
1015
1119
|
headers,
|
|
1016
|
-
redirect: "manual"
|
|
1120
|
+
redirect: "manual",
|
|
1121
|
+
signal
|
|
1017
1122
|
});
|
|
1018
1123
|
if (response.status >= 300 && response.status < 400) {
|
|
1019
1124
|
const location = response.headers.get("Location");
|
|
@@ -1045,6 +1150,20 @@ function createRouter(deps) {
|
|
|
1045
1150
|
const segmentElementCache = new SegmentElementCache();
|
|
1046
1151
|
let routerPhase = { phase: "idle" };
|
|
1047
1152
|
const pendingListeners = /* @__PURE__ */ new Set();
|
|
1153
|
+
let currentNavAbort = null;
|
|
1154
|
+
/**
|
|
1155
|
+
* Create a new AbortController for a navigation, aborting any
|
|
1156
|
+
* previous in-flight navigation. Optionally links to an external
|
|
1157
|
+
* signal (e.g., from the Navigation API's NavigateEvent.signal).
|
|
1158
|
+
*/
|
|
1159
|
+
function createNavAbort(externalSignal) {
|
|
1160
|
+
currentNavAbort?.abort();
|
|
1161
|
+
const controller = new AbortController();
|
|
1162
|
+
currentNavAbort = controller;
|
|
1163
|
+
if (externalSignal) if (externalSignal.aborted) controller.abort();
|
|
1164
|
+
else externalSignal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
1165
|
+
return controller;
|
|
1166
|
+
}
|
|
1048
1167
|
function setPending(value, url) {
|
|
1049
1168
|
const next = value && url ? {
|
|
1050
1169
|
phase: "navigating",
|
|
@@ -1160,16 +1279,20 @@ function createRouter(deps) {
|
|
|
1160
1279
|
if (result === void 0) {
|
|
1161
1280
|
const stateTree = segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths());
|
|
1162
1281
|
const rawCurrentUrl = deps.getCurrentUrl();
|
|
1163
|
-
result = await fetchRscPayload(url, deps, stateTree, rawCurrentUrl.startsWith("http") ? new URL(rawCurrentUrl).pathname : new URL(rawCurrentUrl, "http://localhost").pathname);
|
|
1282
|
+
result = await fetchRscPayload(url, deps, stateTree, rawCurrentUrl.startsWith("http") ? new URL(rawCurrentUrl).pathname : new URL(rawCurrentUrl, "http://localhost").pathname, options.signal);
|
|
1283
|
+
}
|
|
1284
|
+
if (!options.skipHistory) {
|
|
1285
|
+
deps.setRouterNavigating?.(true);
|
|
1286
|
+
if (options.replace) deps.replaceState({
|
|
1287
|
+
timber: true,
|
|
1288
|
+
scrollY: 0
|
|
1289
|
+
}, "", url);
|
|
1290
|
+
else deps.pushState({
|
|
1291
|
+
timber: true,
|
|
1292
|
+
scrollY: 0
|
|
1293
|
+
}, "", url);
|
|
1294
|
+
deps.setRouterNavigating?.(false);
|
|
1164
1295
|
}
|
|
1165
|
-
if (options.replace) deps.replaceState({
|
|
1166
|
-
timber: true,
|
|
1167
|
-
scrollY: 0
|
|
1168
|
-
}, "", url);
|
|
1169
|
-
else deps.pushState({
|
|
1170
|
-
timber: true,
|
|
1171
|
-
scrollY: 0
|
|
1172
|
-
}, "", url);
|
|
1173
1296
|
updateSegmentCache(result.segmentInfo);
|
|
1174
1297
|
const navState = updateNavigationState(result.params, url);
|
|
1175
1298
|
return {
|
|
@@ -1180,43 +1303,64 @@ function createRouter(deps) {
|
|
|
1180
1303
|
async function navigate(url, options = {}) {
|
|
1181
1304
|
const scroll = options.scroll !== false;
|
|
1182
1305
|
const replace = options.replace === true;
|
|
1306
|
+
const externalSignal = options._signal;
|
|
1307
|
+
const skipHistory = options._skipHistory === true;
|
|
1308
|
+
const navAbort = createNavAbort(externalSignal);
|
|
1183
1309
|
const currentScrollY = deps.getScrollY();
|
|
1184
|
-
deps.
|
|
1310
|
+
if (deps.saveNavigationEntryScroll) deps.saveNavigationEntryScroll(currentScrollY);
|
|
1311
|
+
else deps.replaceState({
|
|
1185
1312
|
timber: true,
|
|
1186
1313
|
scrollY: currentScrollY
|
|
1187
1314
|
}, "", deps.getCurrentUrl());
|
|
1315
|
+
let effectiveSkipHistory = skipHistory;
|
|
1316
|
+
if (!skipHistory && deps.navigationNavigate) {
|
|
1317
|
+
deps.setRouterNavigating?.(true);
|
|
1318
|
+
deps.navigationNavigate(url, replace);
|
|
1319
|
+
deps.setRouterNavigating?.(false);
|
|
1320
|
+
effectiveSkipHistory = true;
|
|
1321
|
+
}
|
|
1188
1322
|
setPending(true, url);
|
|
1189
1323
|
try {
|
|
1190
|
-
applyHead(await renderViaTransition(url, () => performNavigationFetch(url, {
|
|
1324
|
+
applyHead(await renderViaTransition(url, () => performNavigationFetch(url, {
|
|
1325
|
+
replace,
|
|
1326
|
+
signal: navAbort.signal,
|
|
1327
|
+
skipHistory: effectiveSkipHistory
|
|
1328
|
+
})));
|
|
1191
1329
|
window.dispatchEvent(new Event("timber:navigation-end"));
|
|
1192
1330
|
restoreScrollAfterPaint(scroll ? 0 : currentScrollY);
|
|
1193
1331
|
} catch (error) {
|
|
1194
1332
|
if (error instanceof VersionSkewError) {
|
|
1333
|
+
setHardNavigating(true);
|
|
1195
1334
|
const { triggerStaleReload } = await import("../_chunks/stale-reload-BeyHXZ5B.js");
|
|
1196
1335
|
triggerStaleReload();
|
|
1197
1336
|
return new Promise(() => {});
|
|
1198
1337
|
}
|
|
1199
1338
|
if (error instanceof RedirectError) {
|
|
1200
1339
|
setPending(false);
|
|
1340
|
+
deps.completeRouterNavigation?.();
|
|
1201
1341
|
await navigate(error.redirectUrl, { replace: true });
|
|
1202
1342
|
return;
|
|
1203
1343
|
}
|
|
1204
1344
|
if (error instanceof ServerErrorResponse) {
|
|
1345
|
+
setHardNavigating(true);
|
|
1205
1346
|
window.location.href = error.url;
|
|
1206
1347
|
return new Promise(() => {});
|
|
1207
1348
|
}
|
|
1208
1349
|
if (isAbortError(error)) return;
|
|
1209
1350
|
throw error;
|
|
1210
1351
|
} finally {
|
|
1352
|
+
if (currentNavAbort === navAbort) currentNavAbort = null;
|
|
1211
1353
|
setPending(false);
|
|
1354
|
+
deps.completeRouterNavigation?.();
|
|
1212
1355
|
}
|
|
1213
1356
|
}
|
|
1214
1357
|
async function refresh() {
|
|
1215
1358
|
const currentUrl = deps.getCurrentUrl();
|
|
1359
|
+
const navAbort = createNavAbort();
|
|
1216
1360
|
setPending(true, currentUrl);
|
|
1217
1361
|
try {
|
|
1218
1362
|
applyHead(await renderViaTransition(currentUrl, async () => {
|
|
1219
|
-
const result = await fetchRscPayload(currentUrl, deps);
|
|
1363
|
+
const result = await fetchRscPayload(currentUrl, deps, void 0, void 0, navAbort.signal);
|
|
1220
1364
|
updateSegmentCache(result.segmentInfo);
|
|
1221
1365
|
const navState = updateNavigationState(result.params, currentUrl);
|
|
1222
1366
|
return {
|
|
@@ -1224,11 +1368,16 @@ function createRouter(deps) {
|
|
|
1224
1368
|
navState
|
|
1225
1369
|
};
|
|
1226
1370
|
}));
|
|
1371
|
+
} catch (error) {
|
|
1372
|
+
if (isAbortError(error)) return;
|
|
1373
|
+
throw error;
|
|
1227
1374
|
} finally {
|
|
1375
|
+
if (currentNavAbort === navAbort) currentNavAbort = null;
|
|
1228
1376
|
setPending(false);
|
|
1377
|
+
deps.completeRouterNavigation?.();
|
|
1229
1378
|
}
|
|
1230
1379
|
}
|
|
1231
|
-
async function handlePopState(url, scrollY = 0) {
|
|
1380
|
+
async function handlePopState(url, scrollY = 0, externalSignal) {
|
|
1232
1381
|
const entry = historyStack.get(url);
|
|
1233
1382
|
if (entry && entry.payload !== null) {
|
|
1234
1383
|
const navState = updateNavigationState(entry.params, url);
|
|
@@ -1236,10 +1385,11 @@ function createRouter(deps) {
|
|
|
1236
1385
|
applyHead(entry.headElements);
|
|
1237
1386
|
restoreScrollAfterPaint(scrollY);
|
|
1238
1387
|
} else {
|
|
1388
|
+
const navAbort = createNavAbort(externalSignal);
|
|
1239
1389
|
setPending(true, url);
|
|
1240
1390
|
try {
|
|
1241
1391
|
applyHead(await renderViaTransition(url, async () => {
|
|
1242
|
-
const result = await fetchRscPayload(url, deps, segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths()));
|
|
1392
|
+
const result = await fetchRscPayload(url, deps, segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths()), void 0, navAbort.signal);
|
|
1243
1393
|
updateSegmentCache(result.segmentInfo);
|
|
1244
1394
|
const navState = updateNavigationState(result.params, url);
|
|
1245
1395
|
return {
|
|
@@ -1248,7 +1398,11 @@ function createRouter(deps) {
|
|
|
1248
1398
|
};
|
|
1249
1399
|
}));
|
|
1250
1400
|
restoreScrollAfterPaint(scrollY);
|
|
1401
|
+
} catch (error) {
|
|
1402
|
+
if (isAbortError(error)) return;
|
|
1403
|
+
throw error;
|
|
1251
1404
|
} finally {
|
|
1405
|
+
if (currentNavAbort === navAbort) currentNavAbort = null;
|
|
1252
1406
|
setPending(false);
|
|
1253
1407
|
}
|
|
1254
1408
|
}
|