@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.
- package/dist/_chunks/{actions-DzFMnUAR.js → actions-OIZ8siQu.js} +28 -43
- package/dist/_chunks/actions-OIZ8siQu.js.map +1 -0
- package/dist/_chunks/{handler-store-DjISKYEB.js → handler-store-BVDgyTh7.js} +26 -2
- package/dist/_chunks/handler-store-BVDgyTh7.js.map +1 -0
- package/dist/_chunks/{href-validation-EZktELp8.js → href-validation-DyZIzfMJ.js} +18 -2
- package/dist/_chunks/{href-validation-EZktELp8.js.map → href-validation-DyZIzfMJ.js.map} +1 -1
- package/dist/_chunks/{navigation-root-DLc4s4ZC.js → navigation-root-bUDZLw74.js} +23 -2
- package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -0
- package/dist/_chunks/tracing-M_zKubhk.js.map +1 -1
- package/dist/adapters/cloudflare.d.ts +1 -15
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +10 -28
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +18 -5
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.js +39 -12
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/redis-handler.d.ts +72 -8
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/stable-stringify.d.ts +9 -0
- package/dist/cache/stable-stringify.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts +3 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/browser-entry/scroll.d.ts +9 -0
- package/dist/client/browser-entry/scroll.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +11 -6
- package/dist/client/error-boundary.js.map +1 -1
- package/dist/client/index.js +3 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +99 -24
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +13 -0
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/router.d.ts +7 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/dev-tools/404-page.d.ts +6 -2
- package/dist/dev-tools/404-page.d.ts.map +1 -1
- package/dist/dev-tools/dev-page-shell.d.ts +77 -0
- package/dist/dev-tools/dev-page-shell.d.ts.map +1 -0
- package/dist/dev-tools/error-page.d.ts +4 -30
- package/dist/dev-tools/error-page.d.ts.map +1 -1
- package/dist/dev-tools/index.d.ts +2 -1
- package/dist/dev-tools/index.d.ts.map +1 -1
- package/dist/dev-tools/instrumentation.d.ts +11 -1
- package/dist/dev-tools/instrumentation.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +430 -253
- package/dist/index.js.map +1 -1
- package/dist/plugins/cache.d.ts +43 -9
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/cloudflare-wasm.d.ts +4 -1
- package/dist/plugins/cloudflare-wasm.d.ts.map +1 -1
- package/dist/plugins/dev-server.d.ts +16 -0
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts +23 -0
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/actions.d.ts +3 -2
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/cookie-context.d.ts.map +1 -1
- package/dist/server/cookie-parsing.d.ts.map +1 -1
- package/dist/server/fallback-error.d.ts +2 -2
- package/dist/server/fallback-error.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +1 -1
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +24 -6
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +11 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +1 -11
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +6 -4
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/tracing.d.ts +2 -1
- package/dist/server/tracing.d.ts.map +1 -1
- package/dist/shared/head-header.d.ts +54 -0
- package/dist/shared/head-header.d.ts.map +1 -0
- package/dist/shared/href-validation.d.ts +11 -0
- package/dist/shared/href-validation.d.ts.map +1 -1
- package/dist/shared/redirect-type.d.ts +17 -0
- package/dist/shared/redirect-type.d.ts.map +1 -0
- package/dist/shims/headers.d.ts +52 -1
- package/dist/shims/headers.d.ts.map +1 -1
- package/dist/shims/navigation-client.d.ts +33 -11
- package/dist/shims/navigation-client.d.ts.map +1 -1
- package/package.json +10 -9
- package/src/adapters/cloudflare.ts +13 -47
- package/src/adapters/compress-module.ts +9 -3
- package/src/adapters/nitro.ts +9 -2
- package/src/cache/redis-handler.ts +77 -13
- package/src/cache/stable-stringify.ts +65 -2
- package/src/cache/timber-cache.ts +25 -13
- package/src/cli.ts +0 -0
- package/src/client/browser-entry/action-dispatch.ts +7 -2
- package/src/client/browser-entry/router-init.ts +2 -1
- package/src/client/browser-entry/scroll.ts +23 -0
- package/src/client/error-boundary.tsx +19 -5
- package/src/client/link.tsx +4 -1
- package/src/client/navigation-api.ts +5 -1
- package/src/client/navigation-root.tsx +16 -0
- package/src/client/router.ts +107 -29
- package/src/client/rsc-fetch.ts +6 -6
- package/src/dev-tools/404-page.ts +22 -123
- package/src/dev-tools/dev-page-shell.ts +256 -0
- package/src/dev-tools/error-page.ts +27 -203
- package/src/dev-tools/index.ts +7 -1
- package/src/dev-tools/instrumentation.ts +39 -2
- package/src/index.ts +1 -0
- package/src/plugins/cache.ts +79 -21
- package/src/plugins/cloudflare-wasm.ts +48 -10
- package/src/plugins/dev-server.ts +32 -8
- package/src/plugins/server-bundle.ts +25 -9
- package/src/plugins/shims.ts +94 -52
- package/src/server/action-handler.ts +10 -2
- package/src/server/actions.ts +3 -2
- package/src/server/compress.ts +11 -2
- package/src/server/cookie-context.ts +11 -0
- package/src/server/cookie-parsing.ts +16 -4
- package/src/server/fallback-error.ts +9 -4
- package/src/server/node-stream-transforms.ts +61 -24
- package/src/server/pipeline.ts +12 -0
- package/src/server/primitives.ts +10 -31
- package/src/server/rsc-entry/index.ts +7 -2
- package/src/server/rsc-entry/rsc-payload.ts +4 -3
- package/src/server/slot-resolver.ts +27 -14
- package/src/server/ssr-entry.ts +17 -28
- package/src/server/tracing.ts +17 -1
- package/src/shared/head-header.ts +69 -0
- package/src/shared/href-validation.ts +24 -0
- package/src/shared/redirect-type.ts +17 -0
- package/src/shims/headers.ts +119 -2
- package/src/shims/navigation-client.ts +48 -27
- package/LICENSE +0 -8
- package/dist/_chunks/actions-DzFMnUAR.js.map +0 -1
- package/dist/_chunks/handler-store-DjISKYEB.js.map +0 -1
- package/dist/_chunks/navigation-root-DLc4s4ZC.js.map +0 -1
package/src/client/router.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
237
|
-
|
|
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 }, '',
|
|
526
|
+
deps.replaceState({ timber: true, scrollY: 0 }, '', commitUrl);
|
|
489
527
|
} else {
|
|
490
|
-
deps.pushState({ timber: true, scrollY: 0 }, '',
|
|
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(
|
|
549
|
-
performNavigationFetch(
|
|
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,
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
|
|
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
|
-
|
|
578
|
-
|
|
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
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
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
|
}
|
package/src/client/rsc-fetch.ts
CHANGED
|
@@ -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(
|
|
152
|
+
const header = response.headers.get(HEAD_HEADER);
|
|
152
153
|
if (!header) return null;
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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
|
`;
|