@timber-js/app 0.1.24 → 0.1.25
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/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +4 -3
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-dev.d.ts +29 -0
- package/dist/client/browser-dev.d.ts.map +1 -0
- package/dist/client/browser-links.d.ts +32 -0
- package/dist/client/browser-links.d.ts.map +1 -0
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +46 -20
- package/dist/client/index.js.map +1 -1
- package/dist/client/navigation-context.d.ts +10 -8
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/transition-root.d.ts +54 -0
- package/dist/client/transition-root.d.ts.map +1 -0
- package/dist/client/use-router.d.ts +14 -0
- package/dist/client/use-router.d.ts.map +1 -1
- package/dist/server/index.js +264 -218
- package/dist/server/index.js.map +1 -1
- package/dist/server/metadata-platform.d.ts +34 -0
- package/dist/server/metadata-platform.d.ts.map +1 -0
- package/dist/server/metadata-render.d.ts.map +1 -1
- package/dist/server/metadata-social.d.ts +24 -0
- package/dist/server/metadata-social.d.ts.map +1 -0
- package/dist/server/pipeline-interception.d.ts +32 -0
- package/dist/server/pipeline-interception.d.ts.map +1 -0
- package/dist/server/pipeline-metadata.d.ts +31 -0
- package/dist/server/pipeline-metadata.d.ts.map +1 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/nitro.ts +9 -7
- package/src/cli.ts +9 -2
- package/src/client/browser-dev.ts +142 -0
- package/src/client/browser-entry.ts +32 -222
- package/src/client/browser-links.ts +90 -0
- package/src/client/index.ts +1 -1
- package/src/client/navigation-context.ts +39 -9
- package/src/client/transition-root.tsx +86 -0
- package/src/client/use-router.ts +17 -15
- package/src/server/metadata-platform.ts +229 -0
- package/src/server/metadata-render.ts +9 -363
- package/src/server/metadata-social.ts +184 -0
- package/src/server/pipeline-interception.ts +76 -0
- package/src/server/pipeline-metadata.ts +90 -0
- package/src/server/pipeline.ts +2 -148
|
@@ -49,8 +49,10 @@ import type { RouterDeps, RouterInstance } from '@timber-js/app/client';
|
|
|
49
49
|
import { applyHeadElements } from './head.js';
|
|
50
50
|
import { TimberNuqsAdapter } from './nuqs-adapter.js';
|
|
51
51
|
import { isPageUnloading } from './unload-guard.js';
|
|
52
|
-
import { ON_NAVIGATE_KEY } from './link-navigate-interceptor.js';
|
|
53
52
|
import { NavigationProvider, getNavigationState, setNavigationState } from './navigation-context.js';
|
|
53
|
+
import { setupServerLogReplay, setupClientErrorForwarding } from './browser-dev.js';
|
|
54
|
+
import { handleLinkClick, handleLinkHover } from './browser-links.js';
|
|
55
|
+
import { TransitionRoot, transitionRender } from './transition-root.js';
|
|
54
56
|
|
|
55
57
|
// ─── Server Action Dispatch ──────────────────────────────────────
|
|
56
58
|
|
|
@@ -179,7 +181,7 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
179
181
|
|
|
180
182
|
const timberChunks = (self as unknown as Record<string, FlightSegment[]>).__timber_f;
|
|
181
183
|
|
|
182
|
-
let
|
|
184
|
+
let _reactRoot: Root | null = null;
|
|
183
185
|
let initialElement: unknown = null;
|
|
184
186
|
// Declared here so it's accessible after the if/else hydration block.
|
|
185
187
|
// Assigned inside initRouter() which is called in both branches.
|
|
@@ -272,8 +274,8 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
272
274
|
// hydrateRoot() synchronously executes component render functions.
|
|
273
275
|
// Components that call useRouter() during render need the global
|
|
274
276
|
// router to be available, otherwise they get a stale no-op reference.
|
|
275
|
-
// The
|
|
276
|
-
//
|
|
277
|
+
// The router must be initialized before hydration so useRouter() works.
|
|
278
|
+
// renderRoot uses transitionRender (no direct reactRoot dependency).
|
|
277
279
|
initRouter();
|
|
278
280
|
|
|
279
281
|
// ── Initialize navigation state BEFORE hydration ───────────────────
|
|
@@ -298,12 +300,18 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
298
300
|
|
|
299
301
|
// Hydrate on document — the root layout renders the full <html> tree,
|
|
300
302
|
// so React owns the entire document from the root.
|
|
301
|
-
// Wrap with NavigationProvider (for atomic useParams/usePathname)
|
|
302
|
-
// TimberNuqsAdapter (for nuqs context)
|
|
303
|
+
// Wrap with NavigationProvider (for atomic useParams/usePathname),
|
|
304
|
+
// TimberNuqsAdapter (for nuqs context), and TransitionRoot (for
|
|
305
|
+
// transition-based rendering during client navigation).
|
|
306
|
+
//
|
|
307
|
+
// TransitionRoot holds the element in React state and updates via
|
|
308
|
+
// startTransition, so React keeps old UI visible while new Suspense
|
|
309
|
+
// boundaries resolve during navigation. See design/05-streaming.md.
|
|
303
310
|
const navState = getNavigationState();
|
|
304
311
|
const withNav = createElement(NavigationProvider, { value: navState }, element as React.ReactNode);
|
|
305
312
|
const wrapped = createElement(TimberNuqsAdapter, null, withNav);
|
|
306
|
-
|
|
313
|
+
const rootElement = createElement(TransitionRoot, { initial: wrapped });
|
|
314
|
+
_reactRoot = hydrateRoot(document, rootElement, {
|
|
307
315
|
// Suppress recoverable hydration errors from deny/error signals
|
|
308
316
|
// inside Suspense boundaries. The server already handled these
|
|
309
317
|
// (wrapStreamWithErrorHandling closes the stream cleanly after
|
|
@@ -327,14 +335,14 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
327
335
|
// The initial SSR HTML remains as-is; the first client navigation will
|
|
328
336
|
// replace it with a React-managed tree.
|
|
329
337
|
initRouter();
|
|
330
|
-
|
|
338
|
+
_reactRoot = createRoot(document);
|
|
331
339
|
}
|
|
332
340
|
|
|
333
341
|
// ── Router initialization (hoisted above hydrateRoot) ────────────────
|
|
334
342
|
// Extracted into a function so both the hydration and createRoot paths
|
|
335
343
|
// can call it. Must run before hydrateRoot so useRouter() works during
|
|
336
|
-
// the initial render.
|
|
337
|
-
//
|
|
344
|
+
// the initial render. renderRoot uses transitionRender which is set
|
|
345
|
+
// by the TransitionRoot component during hydration.
|
|
338
346
|
function initRouter(): void {
|
|
339
347
|
const deps: RouterDeps = {
|
|
340
348
|
fetch: (url, init) => window.fetch(url, init),
|
|
@@ -359,25 +367,28 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
359
367
|
return createFromFetch(fetchPromise);
|
|
360
368
|
},
|
|
361
369
|
|
|
362
|
-
// Render decoded RSC tree
|
|
370
|
+
// Render decoded RSC tree via TransitionRoot's state-based mechanism.
|
|
363
371
|
// Wraps with NavigationProvider (for atomic useParams/usePathname updates)
|
|
364
|
-
// and TimberNuqsAdapter (for nuqs context).
|
|
365
|
-
// navigation state from closures — both set before this callback fires.
|
|
372
|
+
// and TimberNuqsAdapter (for nuqs context).
|
|
366
373
|
//
|
|
367
374
|
// The router calls setNavigationState() before renderRoot(), so
|
|
368
375
|
// getNavigationState() returns the new params/pathname. By wrapping
|
|
369
376
|
// the element in NavigationProvider here, the context value and the
|
|
370
|
-
// RSC tree are passed to
|
|
371
|
-
// making the update atomic. Preserved layout components that call
|
|
377
|
+
// RSC tree are passed to startTransition(() => setState()) in the same
|
|
378
|
+
// call — making the update atomic. Preserved layout components that call
|
|
372
379
|
// useParams() or usePathname() re-render in the same pass as the
|
|
373
380
|
// new tree, preventing the dual-active-state flash.
|
|
381
|
+
//
|
|
382
|
+
// Using transitionRender instead of reactRoot.render() enables
|
|
383
|
+
// client-side Suspense deferral: React keeps the old committed tree
|
|
384
|
+
// visible while new Suspense boundaries in the navigation resolve.
|
|
385
|
+
// This is the client-side equivalent of deferSuspenseFor on the server.
|
|
386
|
+
// See design/05-streaming.md.
|
|
374
387
|
renderRoot: (element: unknown) => {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
reactRoot.render(wrapped);
|
|
380
|
-
}
|
|
388
|
+
const navState = getNavigationState();
|
|
389
|
+
const withNav = createElement(NavigationProvider, { value: navState }, element as React.ReactNode);
|
|
390
|
+
const wrapped = createElement(TimberNuqsAdapter, null, withNav);
|
|
391
|
+
transitionRender(wrapped);
|
|
381
392
|
},
|
|
382
393
|
|
|
383
394
|
// Schedule a callback after the next paint so scroll operations
|
|
@@ -521,208 +532,7 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
521
532
|
}
|
|
522
533
|
}
|
|
523
534
|
|
|
524
|
-
// ─── Server Log Replay (Dev Only) ─────────────────────────────────
|
|
525
|
-
|
|
526
|
-
/** Payload shape from plugins/dev-logs.ts */
|
|
527
|
-
interface ServerLogPayload {
|
|
528
|
-
level: 'log' | 'warn' | 'error' | 'debug' | 'info';
|
|
529
|
-
args: unknown[];
|
|
530
|
-
location: string | null;
|
|
531
|
-
timestamp: number;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
/**
|
|
535
|
-
* Deserialize a serialized arg back into a console-friendly value.
|
|
536
|
-
*
|
|
537
|
-
* Handles Error objects (serialized as { __type: 'Error', ... }),
|
|
538
|
-
* Maps, Sets, and passes everything else through.
|
|
539
|
-
*/
|
|
540
|
-
function deserializeArg(arg: unknown): unknown {
|
|
541
|
-
if (arg === '[undefined]') return undefined;
|
|
542
|
-
if (arg === null || typeof arg !== 'object') return arg;
|
|
543
|
-
|
|
544
|
-
const obj = arg as Record<string, unknown>;
|
|
545
|
-
|
|
546
|
-
if (obj.__type === 'Error') {
|
|
547
|
-
const err = new Error(obj.message as string);
|
|
548
|
-
err.name = obj.name as string;
|
|
549
|
-
if (obj.stack) err.stack = obj.stack as string;
|
|
550
|
-
return err;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
if (obj.__type === 'Map') {
|
|
554
|
-
return new Map(
|
|
555
|
-
Object.entries(obj.entries as Record<string, unknown>).map(([k, v]) => [k, deserializeArg(v)])
|
|
556
|
-
);
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
if (obj.__type === 'Set') {
|
|
560
|
-
return new Set((obj.values as unknown[]).map(deserializeArg));
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
if (Array.isArray(arg)) {
|
|
564
|
-
return arg.map(deserializeArg);
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// Plain object — recurse
|
|
568
|
-
const result: Record<string, unknown> = {};
|
|
569
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
570
|
-
result[key] = deserializeArg(value);
|
|
571
|
-
}
|
|
572
|
-
return result;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
/**
|
|
576
|
-
* Set up the HMR listener that replays server console output in the browser.
|
|
577
|
-
*
|
|
578
|
-
* Each message arrives with a log level and serialized args. We prepend
|
|
579
|
-
* a styled "[SERVER]" badge and call the matching console method.
|
|
580
|
-
*/
|
|
581
|
-
function setupServerLogReplay(hot: {
|
|
582
|
-
on(event: string, cb: (...args: unknown[]) => void): void;
|
|
583
|
-
}): void {
|
|
584
|
-
/** CSS styles for the [SERVER] badge in browser console. */
|
|
585
|
-
const BADGE_STYLES: Record<string, string> = {
|
|
586
|
-
log: 'background: #0070f3; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
587
|
-
info: 'background: #0070f3; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
588
|
-
warn: 'background: #f5a623; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
589
|
-
error:
|
|
590
|
-
'background: #e00; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
591
|
-
debug:
|
|
592
|
-
'background: #666; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
593
|
-
};
|
|
594
|
-
|
|
595
|
-
hot.on('timber:server-log', (data: unknown) => {
|
|
596
|
-
const payload = data as ServerLogPayload;
|
|
597
|
-
const level = payload.level;
|
|
598
|
-
const fn = console[level] ?? console.log;
|
|
599
|
-
const args = payload.args.map(deserializeArg);
|
|
600
|
-
|
|
601
|
-
const badge = `%cSERVER`;
|
|
602
|
-
const style = BADGE_STYLES[level] ?? BADGE_STYLES.log;
|
|
603
|
-
const locationSuffix = payload.location ? ` (${payload.location})` : '';
|
|
604
|
-
|
|
605
|
-
fn.call(console, badge, style, ...args, locationSuffix ? `\n → ${payload.location}` : '');
|
|
606
|
-
});
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// ─── Client Error Forwarding (Dev Only) ──────────────────────────
|
|
610
|
-
|
|
611
|
-
/**
|
|
612
|
-
* Set up global error handlers that forward uncaught client-side
|
|
613
|
-
* errors to the dev server via Vite's HMR channel.
|
|
614
|
-
*
|
|
615
|
-
* The server receives 'timber:client-error' events, and echoes them
|
|
616
|
-
* back as Vite '{ type: "error" }' payloads to trigger the overlay.
|
|
617
|
-
*/
|
|
618
|
-
function setupClientErrorForwarding(hot: { send(event: string, data: unknown): void }): void {
|
|
619
|
-
window.addEventListener('error', (event: ErrorEvent) => {
|
|
620
|
-
// Skip errors without useful information
|
|
621
|
-
if (!event.error && !event.message) return;
|
|
622
|
-
// Skip errors during page unload — these are abort-related, not application errors
|
|
623
|
-
if (isPageUnloading()) return;
|
|
624
|
-
|
|
625
|
-
const error = event.error;
|
|
626
|
-
hot.send('timber:client-error', {
|
|
627
|
-
message: error?.message ?? event.message,
|
|
628
|
-
stack: error?.stack ?? '',
|
|
629
|
-
componentStack: error?.componentStack ?? null,
|
|
630
|
-
});
|
|
631
|
-
});
|
|
632
|
-
|
|
633
|
-
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
|
|
634
|
-
const reason = event.reason;
|
|
635
|
-
if (!reason) return;
|
|
636
|
-
// Skip rejections during page unload — aborted fetches/streams cause these
|
|
637
|
-
if (isPageUnloading()) return;
|
|
638
|
-
|
|
639
|
-
const message = reason instanceof Error ? reason.message : String(reason);
|
|
640
|
-
const stack = reason instanceof Error ? (reason.stack ?? '') : '';
|
|
641
|
-
|
|
642
|
-
hot.send('timber:client-error', {
|
|
643
|
-
message,
|
|
644
|
-
stack,
|
|
645
|
-
componentStack: null,
|
|
646
|
-
});
|
|
647
|
-
});
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
// ─── Link Click Interception ─────────────────────────────────────
|
|
651
|
-
|
|
652
|
-
/**
|
|
653
|
-
* Handle click events on timber links. Intercepts clicks on <a> elements
|
|
654
|
-
* marked with data-timber-link and triggers SPA navigation instead of
|
|
655
|
-
* a full page load.
|
|
656
|
-
*
|
|
657
|
-
* Passes through to default browser behavior when:
|
|
658
|
-
* - Modified keys are held (Ctrl, Meta, Shift, Alt) — open in new tab
|
|
659
|
-
* - The click is not the primary button
|
|
660
|
-
* - The link has a target attribute (e.g., target="_blank")
|
|
661
|
-
* - The link has a download attribute
|
|
662
|
-
*/
|
|
663
|
-
function handleLinkClick(event: MouseEvent, router: RouterInstance): void {
|
|
664
|
-
// Only intercept primary clicks without modifier keys
|
|
665
|
-
if (event.button !== 0) return;
|
|
666
|
-
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
667
|
-
if (event.defaultPrevented) return;
|
|
668
|
-
|
|
669
|
-
// Find the closest <a> ancestor with data-timber-link
|
|
670
|
-
const anchor = (event.target as Element).closest?.(
|
|
671
|
-
'a[data-timber-link]'
|
|
672
|
-
) as HTMLAnchorElement | null;
|
|
673
|
-
if (!anchor) return;
|
|
674
|
-
|
|
675
|
-
// Don't intercept links that should open externally
|
|
676
|
-
if (anchor.target && anchor.target !== '_self') return;
|
|
677
|
-
if (anchor.hasAttribute('download')) return;
|
|
678
|
-
|
|
679
|
-
const href = anchor.getAttribute('href');
|
|
680
|
-
if (!href) return;
|
|
681
|
-
|
|
682
|
-
// Prevent default navigation
|
|
683
|
-
event.preventDefault();
|
|
684
|
-
|
|
685
|
-
// Call onNavigate if registered on this anchor (via LinkNavigateInterceptor).
|
|
686
|
-
// If the handler calls preventDefault(), skip the default SPA navigation —
|
|
687
|
-
// the caller is responsible for navigating (e.g. via router.push()).
|
|
688
|
-
const onNavigate = anchor[ON_NAVIGATE_KEY];
|
|
689
|
-
if (onNavigate) {
|
|
690
|
-
let prevented = false;
|
|
691
|
-
onNavigate({
|
|
692
|
-
preventDefault: () => {
|
|
693
|
-
prevented = true;
|
|
694
|
-
},
|
|
695
|
-
});
|
|
696
|
-
if (prevented) return;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
// Check scroll preference from data attribute
|
|
700
|
-
const scroll = anchor.getAttribute('data-timber-scroll') !== 'false';
|
|
701
|
-
|
|
702
|
-
// Trigger SPA navigation
|
|
703
|
-
void router.navigate(href, { scroll });
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
// ─── Prefetch on Hover ───────────────────────────────────────────
|
|
707
535
|
|
|
708
|
-
/**
|
|
709
|
-
* Handle mouseenter events on prefetch-enabled links. When the user
|
|
710
|
-
* hovers over <a data-timber-prefetch>, the RSC payload is fetched
|
|
711
|
-
* and cached for near-instant navigation.
|
|
712
|
-
*
|
|
713
|
-
* See design/19-client-navigation.md §"Prefetch Cache"
|
|
714
|
-
*/
|
|
715
|
-
function handleLinkHover(event: MouseEvent, router: RouterInstance): void {
|
|
716
|
-
const anchor = (event.target as Element).closest?.(
|
|
717
|
-
'a[data-timber-prefetch]'
|
|
718
|
-
) as HTMLAnchorElement | null;
|
|
719
|
-
if (!anchor) return;
|
|
720
|
-
|
|
721
|
-
const href = anchor.getAttribute('href');
|
|
722
|
-
if (!href) return;
|
|
723
|
-
|
|
724
|
-
router.prefetch(href);
|
|
725
|
-
}
|
|
726
536
|
|
|
727
537
|
bootstrap(config);
|
|
728
538
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link click interception and hover prefetch for SPA navigation.
|
|
3
|
+
*
|
|
4
|
+
* Handles click events on <a data-timber-link> and mouseenter events
|
|
5
|
+
* on <a data-timber-prefetch> for client-side navigation.
|
|
6
|
+
*
|
|
7
|
+
* Extracted from browser-entry.ts to keep files under 500 lines.
|
|
8
|
+
*
|
|
9
|
+
* See design/19-client-navigation.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { RouterInstance } from '@timber-js/app/client';
|
|
13
|
+
import { ON_NAVIGATE_KEY } from './link-navigate-interceptor.js';
|
|
14
|
+
|
|
15
|
+
// ─── Link Click Interception ─────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Handle click events on timber links. Intercepts clicks on <a> elements
|
|
19
|
+
* marked with data-timber-link and triggers SPA navigation instead of
|
|
20
|
+
* a full page load.
|
|
21
|
+
*
|
|
22
|
+
* Passes through to default browser behavior when:
|
|
23
|
+
* - Modified keys are held (Ctrl, Meta, Shift, Alt) — open in new tab
|
|
24
|
+
* - The click is not the primary button
|
|
25
|
+
* - The link has a target attribute (e.g., target="_blank")
|
|
26
|
+
* - The link has a download attribute
|
|
27
|
+
*/
|
|
28
|
+
export function handleLinkClick(event: MouseEvent, router: RouterInstance): void {
|
|
29
|
+
// Only intercept primary clicks without modifier keys
|
|
30
|
+
if (event.button !== 0) return;
|
|
31
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
32
|
+
if (event.defaultPrevented) return;
|
|
33
|
+
|
|
34
|
+
// Find the closest <a> ancestor with data-timber-link
|
|
35
|
+
const anchor = (event.target as Element).closest?.(
|
|
36
|
+
'a[data-timber-link]'
|
|
37
|
+
) as HTMLAnchorElement | null;
|
|
38
|
+
if (!anchor) return;
|
|
39
|
+
|
|
40
|
+
// Don't intercept links that should open externally
|
|
41
|
+
if (anchor.target && anchor.target !== '_self') return;
|
|
42
|
+
if (anchor.hasAttribute('download')) return;
|
|
43
|
+
|
|
44
|
+
const href = anchor.getAttribute('href');
|
|
45
|
+
if (!href) return;
|
|
46
|
+
|
|
47
|
+
// Prevent default navigation
|
|
48
|
+
event.preventDefault();
|
|
49
|
+
|
|
50
|
+
// Call onNavigate if registered on this anchor (via LinkNavigateInterceptor).
|
|
51
|
+
// If the handler calls preventDefault(), skip the default SPA navigation —
|
|
52
|
+
// the caller is responsible for navigating (e.g. via router.push()).
|
|
53
|
+
const onNavigate = anchor[ON_NAVIGATE_KEY];
|
|
54
|
+
if (onNavigate) {
|
|
55
|
+
let prevented = false;
|
|
56
|
+
onNavigate({
|
|
57
|
+
preventDefault: () => {
|
|
58
|
+
prevented = true;
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
if (prevented) return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check scroll preference from data attribute
|
|
65
|
+
const scroll = anchor.getAttribute('data-timber-scroll') !== 'false';
|
|
66
|
+
|
|
67
|
+
// Trigger SPA navigation
|
|
68
|
+
void router.navigate(href, { scroll });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Prefetch on Hover ───────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Handle mouseenter events on prefetch-enabled links. When the user
|
|
75
|
+
* hovers over <a data-timber-prefetch>, the RSC payload is fetched
|
|
76
|
+
* and cached for near-instant navigation.
|
|
77
|
+
*
|
|
78
|
+
* See design/19-client-navigation.md §"Prefetch Cache"
|
|
79
|
+
*/
|
|
80
|
+
export function handleLinkHover(event: MouseEvent, router: RouterInstance): void {
|
|
81
|
+
const anchor = (event.target as Element).closest?.(
|
|
82
|
+
'a[data-timber-prefetch]'
|
|
83
|
+
) as HTMLAnchorElement | null;
|
|
84
|
+
if (!anchor) return;
|
|
85
|
+
|
|
86
|
+
const href = anchor.getAttribute('href');
|
|
87
|
+
if (!href) return;
|
|
88
|
+
|
|
89
|
+
router.prefetch(href);
|
|
90
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -45,7 +45,7 @@ export type { UseActionStateFn, UseActionStateReturn, FormErrorsResult } from '.
|
|
|
45
45
|
export { useParams, setCurrentParams } from './use-params';
|
|
46
46
|
|
|
47
47
|
// Navigation context (framework-internal, used by browser-entry for atomic updates)
|
|
48
|
-
export { NavigationProvider,
|
|
48
|
+
export { NavigationProvider, getNavigationState, setNavigationState } from './navigation-context';
|
|
49
49
|
export type { NavigationState } from './navigation-context';
|
|
50
50
|
|
|
51
51
|
// Query states (URL-synced search params)
|
|
@@ -17,13 +17,19 @@
|
|
|
17
17
|
* During SSR, no NavigationProvider is mounted. Hooks fall back to
|
|
18
18
|
* the ALS-backed getSsrData() for per-request isolation.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
20
|
+
* IMPORTANT: createContext and useContext are NOT available in the RSC
|
|
21
|
+
* environment (React Server Components use a stripped-down React).
|
|
22
|
+
* The context is lazily initialized on first access, and all functions
|
|
23
|
+
* that depend on these APIs are safe to call from any environment —
|
|
24
|
+
* they return null or no-op when the APIs aren't available.
|
|
25
|
+
*
|
|
26
|
+
* See design/19-client-navigation.md §"NavigationContext"
|
|
21
27
|
*/
|
|
22
28
|
|
|
23
|
-
import
|
|
29
|
+
import React, { createElement, type ReactNode } from 'react';
|
|
24
30
|
|
|
25
31
|
// ---------------------------------------------------------------------------
|
|
26
|
-
// Context type
|
|
32
|
+
// Context type
|
|
27
33
|
// ---------------------------------------------------------------------------
|
|
28
34
|
|
|
29
35
|
export interface NavigationState {
|
|
@@ -31,18 +37,37 @@ export interface NavigationState {
|
|
|
31
37
|
pathname: string;
|
|
32
38
|
}
|
|
33
39
|
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Lazy context initialization
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
34
44
|
/**
|
|
35
|
-
* The context
|
|
36
|
-
*
|
|
45
|
+
* The context is created lazily to avoid calling createContext at module
|
|
46
|
+
* level. In the RSC environment, React.createContext doesn't exist —
|
|
47
|
+
* calling it at import time would crash the server.
|
|
37
48
|
*/
|
|
38
|
-
|
|
49
|
+
let _context: React.Context<NavigationState | null> | undefined;
|
|
50
|
+
|
|
51
|
+
function getOrCreateContext(): React.Context<NavigationState | null> | undefined {
|
|
52
|
+
if (_context !== undefined) return _context;
|
|
53
|
+
// createContext may not exist in the RSC environment
|
|
54
|
+
if (typeof React.createContext === 'function') {
|
|
55
|
+
_context = React.createContext<NavigationState | null>(null);
|
|
56
|
+
}
|
|
57
|
+
return _context;
|
|
58
|
+
}
|
|
39
59
|
|
|
40
60
|
/**
|
|
41
|
-
* Read the navigation context. Returns null during SSR (no provider)
|
|
61
|
+
* Read the navigation context. Returns null during SSR (no provider)
|
|
62
|
+
* or in the RSC environment (no context available).
|
|
42
63
|
* Internal — used by useParams() and usePathname().
|
|
43
64
|
*/
|
|
44
65
|
export function useNavigationContext(): NavigationState | null {
|
|
45
|
-
|
|
66
|
+
const ctx = getOrCreateContext();
|
|
67
|
+
if (!ctx) return null;
|
|
68
|
+
// useContext may not exist in the RSC environment — caller wraps in try/catch
|
|
69
|
+
if (typeof React.useContext !== 'function') return null;
|
|
70
|
+
return React.useContext(ctx);
|
|
46
71
|
}
|
|
47
72
|
|
|
48
73
|
// ---------------------------------------------------------------------------
|
|
@@ -61,7 +86,12 @@ export interface NavigationProviderProps {
|
|
|
61
86
|
* so that navigation state updates atomically with the tree render.
|
|
62
87
|
*/
|
|
63
88
|
export function NavigationProvider({ value, children }: NavigationProviderProps): React.ReactElement {
|
|
64
|
-
|
|
89
|
+
const ctx = getOrCreateContext();
|
|
90
|
+
if (!ctx) {
|
|
91
|
+
// RSC environment — no context available. Return children as-is.
|
|
92
|
+
return children as React.ReactElement;
|
|
93
|
+
}
|
|
94
|
+
return createElement(ctx.Provider, { value }, children);
|
|
65
95
|
}
|
|
66
96
|
|
|
67
97
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TransitionRoot — Wrapper component for transition-based rendering.
|
|
3
|
+
*
|
|
4
|
+
* Solves the "new boundary has no old content" problem for client-side
|
|
5
|
+
* navigation. When React renders a completely new Suspense boundary via
|
|
6
|
+
* root.render(), it shows the fallback immediately — root.render() is
|
|
7
|
+
* always an urgent update regardless of startTransition.
|
|
8
|
+
*
|
|
9
|
+
* TransitionRoot holds the current element in React state. Navigation
|
|
10
|
+
* updates call startTransition(() => setState(newElement)), which IS
|
|
11
|
+
* a transition update. React keeps the old committed tree visible while
|
|
12
|
+
* any new Suspense boundaries in the transition resolve.
|
|
13
|
+
*
|
|
14
|
+
* This is the client-side equivalent of deferSuspenseFor on the server:
|
|
15
|
+
* the old content stays visible until the new content is ready, avoiding
|
|
16
|
+
* flash-of-fallback during fast navigations.
|
|
17
|
+
*
|
|
18
|
+
* See design/05-streaming.md §"deferSuspenseFor"
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useState, startTransition, type ReactNode } from 'react';
|
|
22
|
+
|
|
23
|
+
// ─── Module-level render function ────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Module-level reference to the state setter wrapped in startTransition.
|
|
27
|
+
* Set during TransitionRoot's render. This is safe because there is
|
|
28
|
+
* exactly one TransitionRoot per application (the document root).
|
|
29
|
+
*/
|
|
30
|
+
let _transitionRender: ((element: ReactNode) => void) | null = null;
|
|
31
|
+
|
|
32
|
+
// ─── Component ───────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Root wrapper component that enables transition-based rendering.
|
|
36
|
+
*
|
|
37
|
+
* Renders no DOM elements — returns the current element directly.
|
|
38
|
+
* This means the DOM tree matches the server-rendered HTML during
|
|
39
|
+
* hydration (TransitionRoot is invisible to the DOM).
|
|
40
|
+
*
|
|
41
|
+
* Usage in browser-entry.ts:
|
|
42
|
+
* const rootEl = createElement(TransitionRoot, { initial: wrapped });
|
|
43
|
+
* reactRoot = hydrateRoot(document, rootEl);
|
|
44
|
+
*
|
|
45
|
+
* Subsequent navigations:
|
|
46
|
+
* transitionRender(newWrappedElement);
|
|
47
|
+
*/
|
|
48
|
+
export function TransitionRoot({ initial }: { initial: ReactNode }): ReactNode {
|
|
49
|
+
const [element, setElement] = useState<ReactNode>(initial);
|
|
50
|
+
|
|
51
|
+
// Update the module-level ref on every render so it always points
|
|
52
|
+
// to the current component instance's setState.
|
|
53
|
+
_transitionRender = (newElement: ReactNode) => {
|
|
54
|
+
startTransition(() => {
|
|
55
|
+
setElement(newElement);
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return element;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Public API ──────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Trigger a transition render. React keeps the old committed tree
|
|
66
|
+
* visible while any new Suspense boundaries in the update resolve.
|
|
67
|
+
*
|
|
68
|
+
* This is the function called by the router's renderRoot callback
|
|
69
|
+
* instead of reactRoot.render() directly.
|
|
70
|
+
*
|
|
71
|
+
* Falls back to no-op if TransitionRoot hasn't mounted yet (shouldn't
|
|
72
|
+
* happen in practice — TransitionRoot mounts during hydration).
|
|
73
|
+
*/
|
|
74
|
+
export function transitionRender(element: ReactNode): void {
|
|
75
|
+
if (_transitionRender) {
|
|
76
|
+
_transitionRender(element);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if the TransitionRoot is mounted and ready for renders.
|
|
82
|
+
* Used by browser-entry.ts to guard against renders before hydration.
|
|
83
|
+
*/
|
|
84
|
+
export function isTransitionRootReady(): boolean {
|
|
85
|
+
return _transitionRender !== null;
|
|
86
|
+
}
|
package/src/client/use-router.ts
CHANGED
|
@@ -7,9 +7,22 @@
|
|
|
7
7
|
*
|
|
8
8
|
* This wraps timber's internal RouterInstance in the Next.js-compatible
|
|
9
9
|
* AppRouterInstance shape that ecosystem libraries expect.
|
|
10
|
+
*
|
|
11
|
+
* NOTE: Unlike Next.js, these methods do NOT wrap navigation in
|
|
12
|
+
* startTransition. In Next.js, router state is React state (useReducer)
|
|
13
|
+
* so startTransition defers the update and provides isPending tracking.
|
|
14
|
+
* In timber, navigation calls reactRoot.render() which is a root-level
|
|
15
|
+
* render — startTransition has no effect on root renders.
|
|
16
|
+
*
|
|
17
|
+
* Navigation state (params, pathname) is delivered atomically via
|
|
18
|
+
* NavigationContext embedded in the element tree passed to
|
|
19
|
+
* reactRoot.render(). See design/19-client-navigation.md §"NavigationContext".
|
|
20
|
+
*
|
|
21
|
+
* For loading UI during navigation, use:
|
|
22
|
+
* - useLinkStatus() — per-link pending indicator (inside <Link>)
|
|
23
|
+
* - useNavigationPending() — global navigation pending state
|
|
10
24
|
*/
|
|
11
25
|
|
|
12
|
-
import { startTransition } from 'react';
|
|
13
26
|
import { getRouterOrNull } from './router-ref.js';
|
|
14
27
|
|
|
15
28
|
export interface AppRouterInstance {
|
|
@@ -54,14 +67,7 @@ export function useRouter(): AppRouterInstance {
|
|
|
54
67
|
}
|
|
55
68
|
return;
|
|
56
69
|
}
|
|
57
|
-
|
|
58
|
-
// React 19's startTransition accepts async callbacks — it keeps
|
|
59
|
-
// isPending=true until the returned promise resolves. This means
|
|
60
|
-
// useTransition's isPending reflects the full RSC fetch + render
|
|
61
|
-
// lifecycle when wrapping router.push() in startTransition.
|
|
62
|
-
startTransition(async () => {
|
|
63
|
-
await router.navigate(href, { scroll: options?.scroll });
|
|
64
|
-
});
|
|
70
|
+
void router.navigate(href, { scroll: options?.scroll });
|
|
65
71
|
},
|
|
66
72
|
replace(href: string, options?: { scroll?: boolean }) {
|
|
67
73
|
const router = getRouterOrNull();
|
|
@@ -71,9 +77,7 @@ export function useRouter(): AppRouterInstance {
|
|
|
71
77
|
}
|
|
72
78
|
return;
|
|
73
79
|
}
|
|
74
|
-
|
|
75
|
-
await router.navigate(href, { scroll: options?.scroll, replace: true });
|
|
76
|
-
});
|
|
80
|
+
void router.navigate(href, { scroll: options?.scroll, replace: true });
|
|
77
81
|
},
|
|
78
82
|
refresh() {
|
|
79
83
|
const router = getRouterOrNull();
|
|
@@ -83,9 +87,7 @@ export function useRouter(): AppRouterInstance {
|
|
|
83
87
|
}
|
|
84
88
|
return;
|
|
85
89
|
}
|
|
86
|
-
|
|
87
|
-
await router.refresh();
|
|
88
|
-
});
|
|
90
|
+
void router.refresh();
|
|
89
91
|
},
|
|
90
92
|
back() {
|
|
91
93
|
if (typeof window !== 'undefined') window.history.back();
|