@timber-js/app 0.2.0-alpha.66 → 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 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.
@@ -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
- * Scroll positions are stored in history.state (browser History API),
18
- * not in this stack see design/19-client-navigation.md §Scroll Restoration.
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
- push(url: string, entry: HistoryEntry): void;
26
- get(url: string): HistoryEntry | undefined;
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;;;;;;;;;;;;GAYG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,OAAO,CAAmC;IAElD,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,GAAG,IAAI;IAI5C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS;IAI1C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;CAG1B"}
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"}
@@ -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
- * Scroll positions are stored in history.state (browser History API),
428
- * not in this stack see design/19-client-navigation.md §Scroll Restoration.
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
- push(url, entry) {
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
- get(url) {
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.replaceState({
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, { replace })));
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 {