@timber-js/app 0.2.0-alpha.145 → 0.2.0-alpha.146

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.
Files changed (149) hide show
  1. package/dist/_chunks/{actions-DzFMnUAR.js → actions-OIZ8siQu.js} +28 -43
  2. package/dist/_chunks/actions-OIZ8siQu.js.map +1 -0
  3. package/dist/_chunks/{handler-store-DjISKYEB.js → handler-store-BVDgyTh7.js} +26 -2
  4. package/dist/_chunks/handler-store-BVDgyTh7.js.map +1 -0
  5. package/dist/_chunks/{href-validation-EZktELp8.js → href-validation-DyZIzfMJ.js} +18 -2
  6. package/dist/_chunks/{href-validation-EZktELp8.js.map → href-validation-DyZIzfMJ.js.map} +1 -1
  7. package/dist/_chunks/{navigation-root-DLc4s4ZC.js → navigation-root-bUDZLw74.js} +23 -2
  8. package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -0
  9. package/dist/_chunks/tracing-M_zKubhk.js.map +1 -1
  10. package/dist/adapters/cloudflare.d.ts +1 -15
  11. package/dist/adapters/cloudflare.d.ts.map +1 -1
  12. package/dist/adapters/cloudflare.js +10 -28
  13. package/dist/adapters/cloudflare.js.map +1 -1
  14. package/dist/adapters/compress-module.d.ts.map +1 -1
  15. package/dist/adapters/nitro.d.ts.map +1 -1
  16. package/dist/adapters/nitro.js +18 -5
  17. package/dist/adapters/nitro.js.map +1 -1
  18. package/dist/cache/index.js +39 -12
  19. package/dist/cache/index.js.map +1 -1
  20. package/dist/cache/redis-handler.d.ts +72 -8
  21. package/dist/cache/redis-handler.d.ts.map +1 -1
  22. package/dist/cache/stable-stringify.d.ts +9 -0
  23. package/dist/cache/stable-stringify.d.ts.map +1 -1
  24. package/dist/cache/timber-cache.d.ts +3 -1
  25. package/dist/cache/timber-cache.d.ts.map +1 -1
  26. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  27. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  28. package/dist/client/browser-entry/scroll.d.ts +9 -0
  29. package/dist/client/browser-entry/scroll.d.ts.map +1 -1
  30. package/dist/client/error-boundary.d.ts.map +1 -1
  31. package/dist/client/error-boundary.js +11 -6
  32. package/dist/client/error-boundary.js.map +1 -1
  33. package/dist/client/index.js +3 -3
  34. package/dist/client/index.js.map +1 -1
  35. package/dist/client/internal.js +99 -24
  36. package/dist/client/internal.js.map +1 -1
  37. package/dist/client/link.d.ts.map +1 -1
  38. package/dist/client/navigation-api.d.ts.map +1 -1
  39. package/dist/client/navigation-root.d.ts +13 -0
  40. package/dist/client/navigation-root.d.ts.map +1 -1
  41. package/dist/client/router.d.ts +7 -0
  42. package/dist/client/router.d.ts.map +1 -1
  43. package/dist/client/rsc-fetch.d.ts.map +1 -1
  44. package/dist/dev-tools/404-page.d.ts +6 -2
  45. package/dist/dev-tools/404-page.d.ts.map +1 -1
  46. package/dist/dev-tools/dev-page-shell.d.ts +77 -0
  47. package/dist/dev-tools/dev-page-shell.d.ts.map +1 -0
  48. package/dist/dev-tools/error-page.d.ts +4 -30
  49. package/dist/dev-tools/error-page.d.ts.map +1 -1
  50. package/dist/dev-tools/index.d.ts +2 -1
  51. package/dist/dev-tools/index.d.ts.map +1 -1
  52. package/dist/dev-tools/instrumentation.d.ts +11 -1
  53. package/dist/dev-tools/instrumentation.d.ts.map +1 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +430 -253
  56. package/dist/index.js.map +1 -1
  57. package/dist/plugins/cache.d.ts +43 -9
  58. package/dist/plugins/cache.d.ts.map +1 -1
  59. package/dist/plugins/cloudflare-wasm.d.ts +4 -1
  60. package/dist/plugins/cloudflare-wasm.d.ts.map +1 -1
  61. package/dist/plugins/dev-server.d.ts +16 -0
  62. package/dist/plugins/dev-server.d.ts.map +1 -1
  63. package/dist/plugins/server-bundle.d.ts.map +1 -1
  64. package/dist/plugins/shims.d.ts +23 -0
  65. package/dist/plugins/shims.d.ts.map +1 -1
  66. package/dist/server/action-handler.d.ts.map +1 -1
  67. package/dist/server/actions.d.ts +3 -2
  68. package/dist/server/actions.d.ts.map +1 -1
  69. package/dist/server/cookie-context.d.ts.map +1 -1
  70. package/dist/server/cookie-parsing.d.ts.map +1 -1
  71. package/dist/server/fallback-error.d.ts +2 -2
  72. package/dist/server/fallback-error.d.ts.map +1 -1
  73. package/dist/server/index.js +1 -1
  74. package/dist/server/internal.js +1 -1
  75. package/dist/server/internal.js.map +1 -1
  76. package/dist/server/node-stream-transforms.d.ts +24 -6
  77. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  78. package/dist/server/pipeline.d.ts +11 -0
  79. package/dist/server/pipeline.d.ts.map +1 -1
  80. package/dist/server/primitives.d.ts +1 -11
  81. package/dist/server/primitives.d.ts.map +1 -1
  82. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  83. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  84. package/dist/server/slot-resolver.d.ts +6 -4
  85. package/dist/server/slot-resolver.d.ts.map +1 -1
  86. package/dist/server/ssr-entry.d.ts.map +1 -1
  87. package/dist/server/tracing.d.ts +2 -1
  88. package/dist/server/tracing.d.ts.map +1 -1
  89. package/dist/shared/head-header.d.ts +54 -0
  90. package/dist/shared/head-header.d.ts.map +1 -0
  91. package/dist/shared/href-validation.d.ts +11 -0
  92. package/dist/shared/href-validation.d.ts.map +1 -1
  93. package/dist/shared/redirect-type.d.ts +17 -0
  94. package/dist/shared/redirect-type.d.ts.map +1 -0
  95. package/dist/shims/headers.d.ts +52 -1
  96. package/dist/shims/headers.d.ts.map +1 -1
  97. package/dist/shims/navigation-client.d.ts +33 -11
  98. package/dist/shims/navigation-client.d.ts.map +1 -1
  99. package/package.json +10 -9
  100. package/src/adapters/cloudflare.ts +13 -47
  101. package/src/adapters/compress-module.ts +9 -3
  102. package/src/adapters/nitro.ts +9 -2
  103. package/src/cache/redis-handler.ts +77 -13
  104. package/src/cache/stable-stringify.ts +65 -2
  105. package/src/cache/timber-cache.ts +25 -13
  106. package/src/cli.ts +0 -0
  107. package/src/client/browser-entry/action-dispatch.ts +7 -2
  108. package/src/client/browser-entry/router-init.ts +2 -1
  109. package/src/client/browser-entry/scroll.ts +23 -0
  110. package/src/client/error-boundary.tsx +19 -5
  111. package/src/client/link.tsx +4 -1
  112. package/src/client/navigation-api.ts +5 -1
  113. package/src/client/navigation-root.tsx +16 -0
  114. package/src/client/router.ts +107 -29
  115. package/src/client/rsc-fetch.ts +6 -6
  116. package/src/dev-tools/404-page.ts +22 -123
  117. package/src/dev-tools/dev-page-shell.ts +256 -0
  118. package/src/dev-tools/error-page.ts +27 -203
  119. package/src/dev-tools/index.ts +7 -1
  120. package/src/dev-tools/instrumentation.ts +39 -2
  121. package/src/index.ts +1 -0
  122. package/src/plugins/cache.ts +79 -21
  123. package/src/plugins/cloudflare-wasm.ts +48 -10
  124. package/src/plugins/dev-server.ts +32 -8
  125. package/src/plugins/server-bundle.ts +25 -9
  126. package/src/plugins/shims.ts +94 -52
  127. package/src/server/action-handler.ts +10 -2
  128. package/src/server/actions.ts +3 -2
  129. package/src/server/compress.ts +11 -2
  130. package/src/server/cookie-context.ts +11 -0
  131. package/src/server/cookie-parsing.ts +16 -4
  132. package/src/server/fallback-error.ts +9 -4
  133. package/src/server/node-stream-transforms.ts +61 -24
  134. package/src/server/pipeline.ts +12 -0
  135. package/src/server/primitives.ts +10 -31
  136. package/src/server/rsc-entry/index.ts +7 -2
  137. package/src/server/rsc-entry/rsc-payload.ts +4 -3
  138. package/src/server/slot-resolver.ts +27 -14
  139. package/src/server/ssr-entry.ts +17 -28
  140. package/src/server/tracing.ts +17 -1
  141. package/src/shared/head-header.ts +69 -0
  142. package/src/shared/href-validation.ts +24 -0
  143. package/src/shared/redirect-type.ts +17 -0
  144. package/src/shims/headers.ts +119 -2
  145. package/src/shims/navigation-client.ts +48 -27
  146. package/LICENSE +0 -8
  147. package/dist/_chunks/actions-DzFMnUAR.js.map +0 -1
  148. package/dist/_chunks/handler-store-DjISKYEB.js.map +0 -1
  149. package/dist/_chunks/navigation-root-DLc4s4ZC.js.map +0 -1
@@ -18,7 +18,7 @@ import {
18
18
  ServerErrorResponse,
19
19
  VersionSkewError,
20
20
  } from './rsc-fetch.js';
21
- import { setHardNavigating } from './navigation-root.js';
21
+ import { setHardNavigating, supersedeNavigationTransitions } from './navigation-root.js';
22
22
  import type { FetchResult } from './rsc-fetch.js';
23
23
 
24
24
  // ─── Types ───────────────────────────────────────────────────────
@@ -150,6 +150,14 @@ export interface RouterDeps {
150
150
  * unavailable.
151
151
  */
152
152
  navigationNavigate?: (url: string, replace: boolean) => void;
153
+
154
+ /**
155
+ * Scroll the element matching a URL #fragment into view. Returns true
156
+ * when a matching element was found and scrolled. When absent or false,
157
+ * the router falls back to scroll-to-top on forward navigation — same
158
+ * as a full page load with an unknown fragment landing at the top.
159
+ */
160
+ scrollToHash?: (hash: string) => boolean;
153
161
  }
154
162
 
155
163
  export interface RouterInstance {
@@ -228,13 +236,25 @@ export function createRouter(deps: RouterDeps): RouterInstance {
228
236
  let currentNavAbort: AbortController | null = null;
229
237
 
230
238
  /**
231
- * Create a new AbortController for a navigation, aborting any
239
+ * Create a new AbortController for a navigation, superseding any
232
240
  * previous in-flight navigation. Optionally links to an external
233
241
  * signal (e.g., from the Navigation API's NavigateEvent.signal).
242
+ *
243
+ * Superseding is one operation with three parts:
244
+ * 1. Abort the previous navigation's fetch.
245
+ * 2. Invalidate its render transition so a response that already
246
+ * arrived can't commit a stale tree (NavigationRoot's transId guard).
247
+ * 3. Resolve its Navigation API deferred — the superseded navigation's
248
+ * finally block is staleness-guarded (see TIM-1034) and no longer
249
+ * cleans up after itself, so the browser's native loading state for
250
+ * the dead navigation is cleared here.
234
251
  */
235
252
  function createNavAbort(externalSignal?: AbortSignal): AbortController {
236
- // Abort previous navigation's fetch
237
- currentNavAbort?.abort();
253
+ if (currentNavAbort) {
254
+ currentNavAbort.abort();
255
+ supersedeNavigationTransitions();
256
+ deps.completeRouterNavigation?.();
257
+ }
238
258
  const controller = new AbortController();
239
259
  currentNavAbort = controller;
240
260
 
@@ -445,13 +465,28 @@ export function createRouter(deps: RouterDeps): RouterInstance {
445
465
  });
446
466
  }
447
467
 
468
+ /**
469
+ * Scroll to the element matching the URL #fragment after paint, falling
470
+ * back to scroll-to-top when no element matches (matching full-page-load
471
+ * behavior for an unknown fragment). Used by forward navigation to a
472
+ * hash-bearing URL (TIM-1035).
473
+ */
474
+ function scrollToHashAfterPaint(hash: string): void {
475
+ afterPaint(() => {
476
+ if (deps.scrollToHash?.(hash) !== true) {
477
+ deps.scrollTo(0, 0);
478
+ }
479
+ window.dispatchEvent(new Event('timber:scroll-restored'));
480
+ });
481
+ }
482
+
448
483
  /**
449
484
  * Core navigation logic shared between the transition and fallback paths.
450
485
  * Fetches the RSC payload, updates all state, and returns the result.
451
486
  */
452
487
  async function performNavigationFetch(
453
488
  url: string,
454
- options: { replace: boolean; signal?: AbortSignal; skipHistory?: boolean }
489
+ options: { replace: boolean; commitUrl?: string; signal?: AbortSignal; skipHistory?: boolean }
455
490
  ): Promise<FetchResult & { navState: NavigationState }> {
456
491
  // Check prefetch cache first. PrefetchResult has optional segmentInfo/params
457
492
  // fields — normalize to null for FetchResult compatibility.
@@ -480,14 +515,17 @@ export function createRouter(deps: RouterDeps): RouterInstance {
480
515
 
481
516
  // Update the browser history — skip when the Navigation API has already
482
517
  // updated the URL via event.intercept() (external navigations).
518
+ // The committed URL keeps the #fragment (commitUrl) even though the
519
+ // fetch/history-stack URL is hash-less (TIM-1035).
483
520
  if (!options.skipHistory) {
521
+ const commitUrl = options.commitUrl ?? url;
484
522
  // Set the router-navigating flag so the Navigation API's navigate
485
523
  // listener doesn't double-intercept this pushState/replaceState.
486
524
  deps.setRouterNavigating?.(true);
487
525
  if (options.replace) {
488
- deps.replaceState({ timber: true, scrollY: 0 }, '', url);
526
+ deps.replaceState({ timber: true, scrollY: 0 }, '', commitUrl);
489
527
  } else {
490
- deps.pushState({ timber: true, scrollY: 0 }, '', url);
528
+ deps.pushState({ timber: true, scrollY: 0 }, '', commitUrl);
491
529
  }
492
530
  deps.setRouterNavigating?.(false);
493
531
  }
@@ -512,6 +550,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
512
550
  const externalSignal = options._signal as AbortSignal | undefined;
513
551
  const skipHistory = options._skipHistory === true;
514
552
 
553
+ // Split the #fragment off the navigation URL (TIM-1035). The full URL
554
+ // (with hash) is committed to the address bar; the hash-less URL is used
555
+ // for the RSC fetch (fragments are client-only — keeping it would also
556
+ // swallow the ?_rsc cache-bust param into the fragment) and for
557
+ // history-stack/prefetch keys (popstate lookups use pathname + search).
558
+ const hashIndex = url.indexOf('#');
559
+ const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
560
+ const fetchUrl = hashIndex === -1 ? url : url.slice(0, hashIndex);
561
+
515
562
  // Create an abort controller for this navigation. Links to the external
516
563
  // signal (Navigation API's event.signal) when provided.
517
564
  const navAbort = createNavAbort(externalSignal);
@@ -545,9 +592,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
545
592
  setPending(true, url);
546
593
 
547
594
  try {
548
- const { headElements } = await renderViaTransition(url, () =>
549
- performNavigationFetch(url, {
595
+ const { headElements } = await renderViaTransition(fetchUrl, () =>
596
+ performNavigationFetch(fetchUrl, {
550
597
  replace,
598
+ commitUrl: url,
551
599
  signal: navAbort.signal,
552
600
  skipHistory: effectiveSkipHistory,
553
601
  })
@@ -559,10 +607,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
559
607
  // Notify nuqs adapter (and any other listeners) that navigation completed.
560
608
  window.dispatchEvent(new Event('timber:navigation-end'));
561
609
 
562
- // Scroll-to-top on forward navigation, or restore captured position
563
- // for scroll={false}. React's render() on the document root can reset
564
- // scroll during DOM reconciliation, so all scroll must be actively managed.
565
- restoreScrollAfterPaint(scroll ? 0 : currentScrollY);
610
+ // Scroll-to-top on forward navigation, scroll to the #fragment target
611
+ // when the URL has one, or restore captured position for scroll={false}.
612
+ // React's render() on the document root can reset scroll during DOM
613
+ // reconciliation, so all scroll must be actively managed.
614
+ if (scroll && hash) {
615
+ scrollToHashAfterPaint(hash);
616
+ } else {
617
+ restoreScrollAfterPaint(scroll ? 0 : currentScrollY);
618
+ }
566
619
  } catch (error) {
567
620
  // Version skew — server has been redeployed. Full page reload
568
621
  // so the browser fetches the new bundle. See TIM-446.
@@ -574,8 +627,12 @@ export function createRouter(deps: RouterDeps): RouterInstance {
574
627
  // Server-side redirect during RSC fetch → soft router navigation.
575
628
  // The redirect navigate will push/replace its own URL.
576
629
  if (error instanceof RedirectError) {
577
- setPending(false);
578
- deps.completeRouterNavigation?.();
630
+ // Drop the redirect if a newer navigation superseded this one
631
+ // while the redirect response was in flight — the newer
632
+ // navigation wins, same as the stale-transition guard.
633
+ if (currentNavAbort !== navAbort) return;
634
+ // The recursive navigate() supersedes this one via createNavAbort,
635
+ // which also resolves this navigation's Navigation API deferred.
579
636
  await navigate(error.redirectUrl, { replace: true });
580
637
  return;
581
638
  }
@@ -597,19 +654,27 @@ export function createRouter(deps: RouterDeps): RouterInstance {
597
654
  if (isAbortError(error)) return;
598
655
  throw error;
599
656
  } finally {
600
- // Clear the abort controller so we don't abort a completed navigation
601
- // when the next one starts. In dev mode, the RSC body stream stays
602
- // open after data arrives (React's Flight client waits for debug rows).
603
- // Aborting a "completed" navigation kills the open stream reader →
604
- // "BodyStreamBuffer was aborted". By clearing the controller here,
605
- // createNavAbort() becomes a no-op for completed navigations.
657
+ // Staleness guard (TIM-1034): only clean up when this navigation
658
+ // still owns the abort controller. When a newer navigation
659
+ // superseded this one, createNavAbort() already replaced
660
+ // currentNavAbort — running setPending(false) /
661
+ // completeRouterNavigation() here would clear the NEWER
662
+ // navigation's pending state and resolve its Navigation API
663
+ // deferred. The superseding navigation resolved OUR deferred in
664
+ // createNavAbort().
606
665
  if (currentNavAbort === navAbort) {
666
+ // Clear the abort controller so we don't abort a completed
667
+ // navigation when the next one starts. In dev mode, the RSC body
668
+ // stream stays open after data arrives (React's Flight client
669
+ // waits for debug rows). Aborting a "completed" navigation kills
670
+ // the open stream reader → "BodyStreamBuffer was aborted".
607
671
  currentNavAbort = null;
672
+ setPending(false);
673
+ // Resolve the Navigation API deferred — clears the browser's
674
+ // native loading state (tab spinner) at the same time as the
675
+ // TopLoader.
676
+ deps.completeRouterNavigation?.();
608
677
  }
609
- setPending(false);
610
- // Resolve the Navigation API deferred — clears the browser's native
611
- // loading state (tab spinner) at the same time as the TopLoader.
612
- deps.completeRouterNavigation?.();
613
678
  }
614
679
  }
615
680
 
@@ -640,11 +705,12 @@ export function createRouter(deps: RouterDeps): RouterInstance {
640
705
  if (isAbortError(error)) return;
641
706
  throw error;
642
707
  } finally {
708
+ // Staleness guard (TIM-1034) — see navigate()'s finally block.
643
709
  if (currentNavAbort === navAbort) {
644
710
  currentNavAbort = null;
711
+ setPending(false);
712
+ deps.completeRouterNavigation?.();
645
713
  }
646
- setPending(false);
647
- deps.completeRouterNavigation?.();
648
714
  }
649
715
  }
650
716
 
@@ -659,7 +725,18 @@ export function createRouter(deps: RouterDeps): RouterInstance {
659
725
  const entry = historyStack.get(url);
660
726
 
661
727
  if (entry && entry.payload !== null) {
662
- // Replay cached payload — no server roundtrip
728
+ // Replay cached payload — no server roundtrip.
729
+ //
730
+ // A forward navigation may still be in flight (back pressed while a
731
+ // slow RSC fetch was pending). createNavAbort() supersedes it: aborts
732
+ // its fetch and invalidates its render transition so the stale
733
+ // forward payload can't commit over this replay (TIM-1022). The
734
+ // replay itself is synchronous — nothing to abort later — so the
735
+ // controller is cleared immediately, and any pending state left by
736
+ // the superseded navigation is reset.
737
+ createNavAbort(externalSignal);
738
+ currentNavAbort = null;
739
+ setPending(false);
663
740
  const navState = updateNavigationState(entry.params, url);
664
741
  renderPayload(entry.payload, navState);
665
742
  applyHead(entry.headElements);
@@ -687,10 +764,11 @@ export function createRouter(deps: RouterDeps): RouterInstance {
687
764
  if (isAbortError(error)) return;
688
765
  throw error;
689
766
  } finally {
767
+ // Staleness guard (TIM-1034) — see navigate()'s finally block.
690
768
  if (currentNavAbort === navAbort) {
691
769
  currentNavAbort = null;
770
+ setPending(false);
692
771
  }
693
- setPending(false);
694
772
  }
695
773
  }
696
774
  }
@@ -14,6 +14,7 @@
14
14
  import type { SegmentInfo } from './segment-cache';
15
15
  import type { HeadElement } from './head';
16
16
  import type { RouterDeps } from './router';
17
+ import { decodeHeadHeader, HEAD_HEADER } from '../shared/head-header.js';
17
18
 
18
19
  // ─── Types ───────────────────────────────────────────────────────
19
20
 
@@ -148,14 +149,13 @@ function warnMalformedHeader(headerName: string, raw: string): void {
148
149
  * Returns null if the header is missing or malformed.
149
150
  */
150
151
  export function extractHeadElements(response: Response): HeadElement[] | null {
151
- const header = response.headers.get('X-Timber-Head');
152
+ const header = response.headers.get(HEAD_HEADER);
152
153
  if (!header) return null;
153
- try {
154
- return JSON.parse(decodeURIComponent(header));
155
- } catch {
156
- warnMalformedHeader('X-Timber-Head', header);
157
- return null;
154
+ const decoded = decodeHeadHeader(header);
155
+ if (decoded === null) {
156
+ warnMalformedHeader(HEAD_HEADER, header);
158
157
  }
158
+ return decoded as HeadElement[] | null;
159
159
  }
160
160
 
161
161
  /**
@@ -16,6 +16,7 @@
16
16
  import type { SegmentNode } from '../routing/types.js';
17
17
  import { collectLeafRoutes } from '../routing/walkers.js';
18
18
  import { escapeHtml } from '../server/utils/escape-html.js';
19
+ import { renderDevPageShell, type DevErrorHmrOptions } from './dev-page-shell.js';
19
20
 
20
21
  // ─── Types ──────────────────────────────────────────────────────────────────
21
22
 
@@ -112,23 +113,20 @@ const esc = escapeHtml;
112
113
  /**
113
114
  * Generate a dev-mode 404 page with route listing and suggestions.
114
115
  *
115
- * Returns an HTML string for a self-contained error page.
116
+ * Returns an HTML string for a self-contained error page. `hmrOptions`
117
+ * carries the resolved Vite HMR connection options (including the WS token
118
+ * Vite 8 requires on browser upgrades) so the auto-reload script can
119
+ * actually connect (TIM-1067, B33).
116
120
  */
117
- export function generateDev404Page(requestedPath: string, routes: RouteInfo[]): string {
121
+ export function generateDev404Page(
122
+ requestedPath: string,
123
+ routes: RouteInfo[],
124
+ hmrOptions?: DevErrorHmrOptions
125
+ ): string {
118
126
  const suggestions = findSimilarRoutes(requestedPath, routes);
119
127
  const isEmpty = routes.length === 0;
120
128
 
121
- return `<!DOCTYPE html>
122
- <html lang="en">
123
- <head>
124
- <meta charset="utf-8">
125
- <meta name="viewport" content="width=device-width, initial-scale=1">
126
- <title>404 — ${esc(requestedPath)} | timber.js</title>
127
- <style>${CSS}</style>
128
- </head>
129
- <body>
130
- <div class="container">
131
- <header class="header">
129
+ const bodyHtml = `<header class="header">
132
130
  <div class="badge">404 Not Found</div>
133
131
  <h1 class="message">No route matches <code>${esc(requestedPath)}</code></h1>
134
132
  </header>
@@ -169,105 +167,38 @@ export function generateDev404Page(requestedPath: string, routes: RouteInfo[]):
169
167
  </ul>
170
168
  </div>`
171
169
  : ''
172
- }
173
-
174
- <footer class="footer">
175
- <span class="timber-logo">🪵 timber.js</span>
176
- <span class="footer-hint">Add or edit routes and this page will reload automatically.</span>
177
- </footer>
178
- </div>
179
-
180
- <script>
181
- // Auto-reload when Vite HMR fires (route added, file changed, etc.)
182
- (function() {
183
- try {
184
- const ws = new WebSocket('ws://' + location.host, 'vite-hmr');
185
- ws.addEventListener('message', function(e) {
186
- try {
187
- const data = JSON.parse(e.data);
188
- if (data.type === 'full-reload' || data.type === 'update') {
189
- location.reload();
190
- }
191
- } catch {}
192
- });
193
- } catch {}
194
- })();
195
- </script>
196
- </body>
197
- </html>`;
170
+ }`;
171
+
172
+ return renderDevPageShell({
173
+ title: `404 — ${requestedPath} | timber.js`,
174
+ extraCss: NOT_FOUND_CSS,
175
+ bodyHtml,
176
+ footerHint: 'Add or edit routes and this page will reload automatically.',
177
+ hmrOptions,
178
+ });
198
179
  }
199
180
 
200
- // ─── CSS ────────────────────────────────────────────────────────────────────
181
+ // ─── Page-Specific CSS ──────────────────────────────────────────────────────
201
182
 
202
- const CSS = `
183
+ const NOT_FOUND_CSS = `
203
184
  :root {
204
- --bg: #fff;
205
- --fg: #1a1a1a;
206
- --fg-dim: #6b7280;
207
- --border: #e5e7eb;
208
185
  --badge-bg: #fefce8;
209
186
  --badge-fg: #854d0e;
210
187
  --badge-border: #fde68a;
211
- --source-bg: #fafafa;
212
- --link: #2563eb;
213
- --code-bg: #f3f4f6;
214
- --btn-bg: #f3f4f6;
215
188
  --route-type-bg: #e0f2fe;
216
189
  --route-type-fg: #0369a1;
217
- --code-font: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
218
190
  }
219
191
 
220
192
  @media (prefers-color-scheme: dark) {
221
193
  :root {
222
- --bg: #0a0a0a;
223
- --fg: #f5f5f5;
224
- --fg-dim: #9ca3af;
225
- --border: #27272a;
226
194
  --badge-bg: #422006;
227
195
  --badge-fg: #fde68a;
228
196
  --badge-border: #854d0e;
229
- --source-bg: #18181b;
230
- --link: #60a5fa;
231
- --code-bg: #27272a;
232
- --btn-bg: #27272a;
233
197
  --route-type-bg: #0c4a6e;
234
198
  --route-type-fg: #bae6fd;
235
199
  }
236
200
  }
237
201
 
238
- * { margin: 0; padding: 0; box-sizing: border-box; }
239
-
240
- body {
241
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
242
- background: var(--bg);
243
- color: var(--fg);
244
- line-height: 1.6;
245
- padding: 2rem;
246
- }
247
-
248
- .container { max-width: 56rem; margin: 0 auto; }
249
- .header { margin-bottom: 1.5rem; }
250
-
251
- .badge {
252
- display: inline-block;
253
- font-size: 0.75rem;
254
- font-weight: 600;
255
- text-transform: uppercase;
256
- letter-spacing: 0.05em;
257
- padding: 0.25rem 0.625rem;
258
- border-radius: 0.375rem;
259
- background: var(--badge-bg);
260
- color: var(--badge-fg);
261
- border: 1px solid var(--badge-border);
262
- margin-bottom: 0.75rem;
263
- }
264
-
265
- .message {
266
- font-size: 1.375rem;
267
- font-weight: 600;
268
- line-height: 1.3;
269
- }
270
-
271
202
  .message code {
272
203
  font-family: var(--code-font);
273
204
  background: var(--code-bg);
@@ -317,24 +248,6 @@ const CSS = `
317
248
  margin: 0;
318
249
  }
319
250
 
320
- .section {
321
- margin-bottom: 1rem;
322
- border: 1px solid var(--border);
323
- border-radius: 0.5rem;
324
- overflow: hidden;
325
- }
326
-
327
- .section-header {
328
- font-size: 0.75rem;
329
- font-weight: 600;
330
- text-transform: uppercase;
331
- letter-spacing: 0.05em;
332
- padding: 0.5rem 0.75rem;
333
- background: var(--source-bg);
334
- border-bottom: 1px solid var(--border);
335
- color: var(--fg-dim);
336
- }
337
-
338
251
  .route-list {
339
252
  list-style: none;
340
253
  padding: 0.5rem 0;
@@ -370,18 +283,4 @@ const CSS = `
370
283
  }
371
284
 
372
285
  .suggestions { background: var(--source-bg); }
373
-
374
- .hint { font-size: 0.875rem; color: var(--fg-dim); }
375
-
376
- .footer {
377
- display: flex;
378
- align-items: center;
379
- gap: 0.75rem;
380
- padding-top: 1rem;
381
- border-top: 1px solid var(--border);
382
- font-size: 0.75rem;
383
- color: var(--fg-dim);
384
- }
385
-
386
- .timber-logo { font-weight: 600; white-space: nowrap; }
387
286
  `;