@timber-js/app 0.2.0-alpha.67 → 0.2.0-alpha.68
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 +105 -23
- package/dist/client/index.js.map +1 -1
- 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/nuqs-adapter.d.ts.map +1 -1
- package/dist/client/router.d.ts +45 -1
- 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.map +1 -1
- package/package.json +6 -7
- package/src/cli.ts +0 -0
- package/src/client/browser-entry.ts +77 -8
- package/src/client/history.ts +26 -4
- package/src/client/link.tsx +29 -7
- package/src/client/nav-link-store.ts +47 -0
- package/src/client/navigation-api-types.ts +112 -0
- package/src/client/navigation-api.ts +305 -0
- package/src/client/navigation-context.ts +20 -0
- package/src/client/nuqs-adapter.tsx +16 -3
- package/src/client/router.ts +148 -16
- package/src/client/rsc-fetch.ts +4 -3
- package/src/client/top-loader.tsx +10 -2
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,21 @@ 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-api.ts
|
|
118
|
+
/**
|
|
119
|
+
* Returns true if the Navigation API is available in the current environment.
|
|
120
|
+
* Feature-detected at runtime — no polyfill.
|
|
121
|
+
*/
|
|
122
|
+
function hasNavigationApi() {
|
|
123
|
+
return typeof window !== "undefined" && "navigation" in window && window.navigation != null;
|
|
124
|
+
}
|
|
110
125
|
//#endregion
|
|
111
126
|
//#region src/client/link.tsx
|
|
112
127
|
/**
|
|
@@ -285,11 +300,18 @@ function Link({ href, prefetch, scroll, segmentParams, searchParams, preserveSea
|
|
|
285
300
|
}
|
|
286
301
|
const router = getRouterOrNull();
|
|
287
302
|
if (!router) return;
|
|
288
|
-
event.preventDefault();
|
|
289
303
|
const shouldScroll = scroll !== false;
|
|
290
|
-
const navHref = preserveSearchParams ? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams) : resolvedHref;
|
|
291
304
|
setLinkStatus(PENDING_LINK_STATUS);
|
|
292
305
|
setLinkForCurrentNavigation(linkInstanceRef.current);
|
|
306
|
+
if (hasNavigationApi()) {
|
|
307
|
+
setNavLinkMetadata({
|
|
308
|
+
scroll: shouldScroll,
|
|
309
|
+
linkInstance: linkInstanceRef.current
|
|
310
|
+
});
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
event.preventDefault();
|
|
314
|
+
const navHref = preserveSearchParams ? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams) : resolvedHref;
|
|
293
315
|
router.navigate(navHref, { scroll: shouldScroll });
|
|
294
316
|
} : userOnClick;
|
|
295
317
|
const handleMouseEnter = internal && prefetch ? (event) => {
|
|
@@ -424,18 +446,38 @@ var PrefetchCache = class PrefetchCache {
|
|
|
424
446
|
* On forward navigation, the new page's payload is pushed onto the stack.
|
|
425
447
|
* On popstate, the cached payload is replayed instantly.
|
|
426
448
|
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
449
|
+
* Supports two keying modes:
|
|
450
|
+
* - **URL-keyed** (default): entries keyed by pathname + search.
|
|
451
|
+
* Used with the History API fallback.
|
|
452
|
+
* - **Entry-key + URL**: when the Navigation API is available,
|
|
453
|
+
* entries can also be stored by Navigation entry key for
|
|
454
|
+
* disambiguation of duplicate URLs in the history stack.
|
|
455
|
+
* Falls back to URL lookup when entry key is not found.
|
|
456
|
+
*
|
|
457
|
+
* Scroll positions are stored in history.state or Navigation API entry
|
|
458
|
+
* state, not in this stack — see design/19-client-navigation.md §Scroll Restoration.
|
|
429
459
|
*
|
|
430
460
|
* Entries persist for the session duration (no expiry) and are cleared
|
|
431
461
|
* when the tab is closed — matching browser back-button behavior.
|
|
432
462
|
*/
|
|
433
463
|
var HistoryStack = class {
|
|
434
464
|
entries = /* @__PURE__ */ new Map();
|
|
435
|
-
|
|
465
|
+
/** Entries keyed by Navigation API entry key for duplicate URL disambiguation. */
|
|
466
|
+
entryKeyMap = /* @__PURE__ */ new Map();
|
|
467
|
+
push(url, entry, entryKey) {
|
|
436
468
|
this.entries.set(url, entry);
|
|
469
|
+
if (entryKey) this.entryKeyMap.set(entryKey, entry);
|
|
437
470
|
}
|
|
438
|
-
|
|
471
|
+
/**
|
|
472
|
+
* Get an entry. When an entry key is provided (Navigation API),
|
|
473
|
+
* tries the entry-key map first for accurate disambiguation of
|
|
474
|
+
* duplicate URLs, then falls back to URL lookup.
|
|
475
|
+
*/
|
|
476
|
+
get(url, entryKey) {
|
|
477
|
+
if (entryKey) {
|
|
478
|
+
const byKey = this.entryKeyMap.get(entryKey);
|
|
479
|
+
if (byKey) return byKey;
|
|
480
|
+
}
|
|
439
481
|
return this.entries.get(url);
|
|
440
482
|
}
|
|
441
483
|
has(url) {
|
|
@@ -979,13 +1021,14 @@ var ServerErrorResponse = class extends Error {
|
|
|
979
1021
|
* Also extracts head elements from the X-Timber-Head response header
|
|
980
1022
|
* so the client can update document.title and <meta> tags after navigation.
|
|
981
1023
|
*/
|
|
982
|
-
async function fetchRscPayload(url, deps, stateTree, currentUrl) {
|
|
1024
|
+
async function fetchRscPayload(url, deps, stateTree, currentUrl, signal) {
|
|
983
1025
|
const rscUrl = appendRscParam(url);
|
|
984
1026
|
const headers = buildRscHeaders(stateTree, currentUrl);
|
|
985
1027
|
if (deps.decodeRsc) {
|
|
986
1028
|
const fetchPromise = deps.fetch(rscUrl, {
|
|
987
1029
|
headers,
|
|
988
|
-
redirect: "manual"
|
|
1030
|
+
redirect: "manual",
|
|
1031
|
+
signal
|
|
989
1032
|
});
|
|
990
1033
|
let headElements = null;
|
|
991
1034
|
let segmentInfo = null;
|
|
@@ -1013,7 +1056,8 @@ async function fetchRscPayload(url, deps, stateTree, currentUrl) {
|
|
|
1013
1056
|
}
|
|
1014
1057
|
const response = await deps.fetch(rscUrl, {
|
|
1015
1058
|
headers,
|
|
1016
|
-
redirect: "manual"
|
|
1059
|
+
redirect: "manual",
|
|
1060
|
+
signal
|
|
1017
1061
|
});
|
|
1018
1062
|
if (response.status >= 300 && response.status < 400) {
|
|
1019
1063
|
const location = response.headers.get("Location");
|
|
@@ -1045,6 +1089,20 @@ function createRouter(deps) {
|
|
|
1045
1089
|
const segmentElementCache = new SegmentElementCache();
|
|
1046
1090
|
let routerPhase = { phase: "idle" };
|
|
1047
1091
|
const pendingListeners = /* @__PURE__ */ new Set();
|
|
1092
|
+
let currentNavAbort = null;
|
|
1093
|
+
/**
|
|
1094
|
+
* Create a new AbortController for a navigation, aborting any
|
|
1095
|
+
* previous in-flight navigation. Optionally links to an external
|
|
1096
|
+
* signal (e.g., from the Navigation API's NavigateEvent.signal).
|
|
1097
|
+
*/
|
|
1098
|
+
function createNavAbort(externalSignal) {
|
|
1099
|
+
currentNavAbort?.abort();
|
|
1100
|
+
const controller = new AbortController();
|
|
1101
|
+
currentNavAbort = controller;
|
|
1102
|
+
if (externalSignal) if (externalSignal.aborted) controller.abort();
|
|
1103
|
+
else externalSignal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
1104
|
+
return controller;
|
|
1105
|
+
}
|
|
1048
1106
|
function setPending(value, url) {
|
|
1049
1107
|
const next = value && url ? {
|
|
1050
1108
|
phase: "navigating",
|
|
@@ -1160,16 +1218,20 @@ function createRouter(deps) {
|
|
|
1160
1218
|
if (result === void 0) {
|
|
1161
1219
|
const stateTree = segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths());
|
|
1162
1220
|
const rawCurrentUrl = deps.getCurrentUrl();
|
|
1163
|
-
result = await fetchRscPayload(url, deps, stateTree, rawCurrentUrl.startsWith("http") ? new URL(rawCurrentUrl).pathname : new URL(rawCurrentUrl, "http://localhost").pathname);
|
|
1221
|
+
result = await fetchRscPayload(url, deps, stateTree, rawCurrentUrl.startsWith("http") ? new URL(rawCurrentUrl).pathname : new URL(rawCurrentUrl, "http://localhost").pathname, options.signal);
|
|
1222
|
+
}
|
|
1223
|
+
if (!options.skipHistory) {
|
|
1224
|
+
deps.setRouterNavigating?.(true);
|
|
1225
|
+
if (options.replace) deps.replaceState({
|
|
1226
|
+
timber: true,
|
|
1227
|
+
scrollY: 0
|
|
1228
|
+
}, "", url);
|
|
1229
|
+
else deps.pushState({
|
|
1230
|
+
timber: true,
|
|
1231
|
+
scrollY: 0
|
|
1232
|
+
}, "", url);
|
|
1233
|
+
deps.setRouterNavigating?.(false);
|
|
1164
1234
|
}
|
|
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
1235
|
updateSegmentCache(result.segmentInfo);
|
|
1174
1236
|
const navState = updateNavigationState(result.params, url);
|
|
1175
1237
|
return {
|
|
@@ -1180,14 +1242,29 @@ function createRouter(deps) {
|
|
|
1180
1242
|
async function navigate(url, options = {}) {
|
|
1181
1243
|
const scroll = options.scroll !== false;
|
|
1182
1244
|
const replace = options.replace === true;
|
|
1245
|
+
const externalSignal = options._signal;
|
|
1246
|
+
const skipHistory = options._skipHistory === true;
|
|
1247
|
+
const navAbort = createNavAbort(externalSignal);
|
|
1183
1248
|
const currentScrollY = deps.getScrollY();
|
|
1184
|
-
deps.
|
|
1249
|
+
if (deps.saveNavigationEntryScroll) deps.saveNavigationEntryScroll(currentScrollY);
|
|
1250
|
+
else deps.replaceState({
|
|
1185
1251
|
timber: true,
|
|
1186
1252
|
scrollY: currentScrollY
|
|
1187
1253
|
}, "", deps.getCurrentUrl());
|
|
1254
|
+
let effectiveSkipHistory = skipHistory;
|
|
1255
|
+
if (!skipHistory && deps.navigationNavigate) {
|
|
1256
|
+
deps.setRouterNavigating?.(true);
|
|
1257
|
+
deps.navigationNavigate(url, replace);
|
|
1258
|
+
deps.setRouterNavigating?.(false);
|
|
1259
|
+
effectiveSkipHistory = true;
|
|
1260
|
+
}
|
|
1188
1261
|
setPending(true, url);
|
|
1189
1262
|
try {
|
|
1190
|
-
applyHead(await renderViaTransition(url, () => performNavigationFetch(url, {
|
|
1263
|
+
applyHead(await renderViaTransition(url, () => performNavigationFetch(url, {
|
|
1264
|
+
replace,
|
|
1265
|
+
signal: navAbort.signal,
|
|
1266
|
+
skipHistory: effectiveSkipHistory
|
|
1267
|
+
})));
|
|
1191
1268
|
window.dispatchEvent(new Event("timber:navigation-end"));
|
|
1192
1269
|
restoreScrollAfterPaint(scroll ? 0 : currentScrollY);
|
|
1193
1270
|
} catch (error) {
|
|
@@ -1198,6 +1275,7 @@ function createRouter(deps) {
|
|
|
1198
1275
|
}
|
|
1199
1276
|
if (error instanceof RedirectError) {
|
|
1200
1277
|
setPending(false);
|
|
1278
|
+
deps.completeRouterNavigation?.();
|
|
1201
1279
|
await navigate(error.redirectUrl, { replace: true });
|
|
1202
1280
|
return;
|
|
1203
1281
|
}
|
|
@@ -1209,14 +1287,16 @@ function createRouter(deps) {
|
|
|
1209
1287
|
throw error;
|
|
1210
1288
|
} finally {
|
|
1211
1289
|
setPending(false);
|
|
1290
|
+
deps.completeRouterNavigation?.();
|
|
1212
1291
|
}
|
|
1213
1292
|
}
|
|
1214
1293
|
async function refresh() {
|
|
1215
1294
|
const currentUrl = deps.getCurrentUrl();
|
|
1295
|
+
const navAbort = createNavAbort();
|
|
1216
1296
|
setPending(true, currentUrl);
|
|
1217
1297
|
try {
|
|
1218
1298
|
applyHead(await renderViaTransition(currentUrl, async () => {
|
|
1219
|
-
const result = await fetchRscPayload(currentUrl, deps);
|
|
1299
|
+
const result = await fetchRscPayload(currentUrl, deps, void 0, void 0, navAbort.signal);
|
|
1220
1300
|
updateSegmentCache(result.segmentInfo);
|
|
1221
1301
|
const navState = updateNavigationState(result.params, currentUrl);
|
|
1222
1302
|
return {
|
|
@@ -1226,9 +1306,10 @@ function createRouter(deps) {
|
|
|
1226
1306
|
}));
|
|
1227
1307
|
} finally {
|
|
1228
1308
|
setPending(false);
|
|
1309
|
+
deps.completeRouterNavigation?.();
|
|
1229
1310
|
}
|
|
1230
1311
|
}
|
|
1231
|
-
async function handlePopState(url, scrollY = 0) {
|
|
1312
|
+
async function handlePopState(url, scrollY = 0, externalSignal) {
|
|
1232
1313
|
const entry = historyStack.get(url);
|
|
1233
1314
|
if (entry && entry.payload !== null) {
|
|
1234
1315
|
const navState = updateNavigationState(entry.params, url);
|
|
@@ -1236,10 +1317,11 @@ function createRouter(deps) {
|
|
|
1236
1317
|
applyHead(entry.headElements);
|
|
1237
1318
|
restoreScrollAfterPaint(scrollY);
|
|
1238
1319
|
} else {
|
|
1320
|
+
const navAbort = createNavAbort(externalSignal);
|
|
1239
1321
|
setPending(true, url);
|
|
1240
1322
|
try {
|
|
1241
1323
|
applyHead(await renderViaTransition(url, async () => {
|
|
1242
|
-
const result = await fetchRscPayload(url, deps, segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths()));
|
|
1324
|
+
const result = await fetchRscPayload(url, deps, segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths()), void 0, navAbort.signal);
|
|
1243
1325
|
updateSegmentCache(result.segmentInfo);
|
|
1244
1326
|
const navState = updateNavigationState(result.params, url);
|
|
1245
1327
|
return {
|