@timber-js/app 0.1.23 → 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/_chunks/{ssr-data-B2yikEEB.js → ssr-data-DLnbYpj1.js} +2 -4
- package/dist/_chunks/{ssr-data-B2yikEEB.js.map → ssr-data-DLnbYpj1.js.map} +1 -1
- package/dist/_chunks/{use-cookie-D5aS4slY.js → use-cookie-dDbpCTx-.js} +2 -2
- package/dist/_chunks/{use-cookie-D5aS4slY.js.map → use-cookie-dDbpCTx-.js.map} +1 -1
- 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/error-boundary.js +1 -1
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +150 -122
- package/dist/client/index.js.map +1 -1
- package/dist/client/navigation-context.d.ts +52 -0
- package/dist/client/navigation-context.d.ts.map +1 -0
- package/dist/client/router.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-params.d.ts +35 -25
- package/dist/client/use-params.d.ts.map +1 -1
- package/dist/client/use-pathname.d.ts +11 -4
- package/dist/client/use-pathname.d.ts.map +1 -1
- package/dist/client/use-router.d.ts +14 -0
- package/dist/client/use-router.d.ts.map +1 -1
- package/dist/cookies/index.js +2 -2
- 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 +73 -223
- package/src/client/browser-links.ts +90 -0
- package/src/client/index.ts +4 -0
- package/src/client/navigation-context.ts +118 -0
- package/src/client/router.ts +37 -33
- package/src/client/transition-root.tsx +86 -0
- package/src/client/use-params.ts +50 -54
- package/src/client/use-pathname.ts +31 -24
- 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,7 +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 {
|
|
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';
|
|
53
56
|
|
|
54
57
|
// ─── Server Action Dispatch ──────────────────────────────────────
|
|
55
58
|
|
|
@@ -178,7 +181,7 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
178
181
|
|
|
179
182
|
const timberChunks = (self as unknown as Record<string, FlightSegment[]>).__timber_f;
|
|
180
183
|
|
|
181
|
-
let
|
|
184
|
+
let _reactRoot: Root | null = null;
|
|
182
185
|
let initialElement: unknown = null;
|
|
183
186
|
// Declared here so it's accessible after the if/else hydration block.
|
|
184
187
|
// Assigned inside initRouter() which is called in both branches.
|
|
@@ -271,15 +274,44 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
271
274
|
// hydrateRoot() synchronously executes component render functions.
|
|
272
275
|
// Components that call useRouter() during render need the global
|
|
273
276
|
// router to be available, otherwise they get a stale no-op reference.
|
|
274
|
-
// The
|
|
275
|
-
//
|
|
277
|
+
// The router must be initialized before hydration so useRouter() works.
|
|
278
|
+
// renderRoot uses transitionRender (no direct reactRoot dependency).
|
|
276
279
|
initRouter();
|
|
277
280
|
|
|
281
|
+
// ── Initialize navigation state BEFORE hydration ───────────────────
|
|
282
|
+
// Read server-embedded params and set navigation state so that
|
|
283
|
+
// useParams() and usePathname() return correct values during hydration.
|
|
284
|
+
// This must happen before hydrateRoot so the NavigationProvider
|
|
285
|
+
// wrapping the element has the right values on the initial render.
|
|
286
|
+
const earlyParams = (self as unknown as Record<string, unknown>).__timber_params;
|
|
287
|
+
if (earlyParams && typeof earlyParams === 'object') {
|
|
288
|
+
setCurrentParams(earlyParams as Record<string, string | string[]>);
|
|
289
|
+
setNavigationState({
|
|
290
|
+
params: earlyParams as Record<string, string | string[]>,
|
|
291
|
+
pathname: window.location.pathname,
|
|
292
|
+
});
|
|
293
|
+
delete (self as unknown as Record<string, unknown>).__timber_params;
|
|
294
|
+
} else {
|
|
295
|
+
setNavigationState({
|
|
296
|
+
params: {},
|
|
297
|
+
pathname: window.location.pathname,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
278
301
|
// Hydrate on document — the root layout renders the full <html> tree,
|
|
279
302
|
// so React owns the entire document from the root.
|
|
280
|
-
// Wrap with
|
|
281
|
-
|
|
282
|
-
|
|
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.
|
|
310
|
+
const navState = getNavigationState();
|
|
311
|
+
const withNav = createElement(NavigationProvider, { value: navState }, element as React.ReactNode);
|
|
312
|
+
const wrapped = createElement(TimberNuqsAdapter, null, withNav);
|
|
313
|
+
const rootElement = createElement(TransitionRoot, { initial: wrapped });
|
|
314
|
+
_reactRoot = hydrateRoot(document, rootElement, {
|
|
283
315
|
// Suppress recoverable hydration errors from deny/error signals
|
|
284
316
|
// inside Suspense boundaries. The server already handled these
|
|
285
317
|
// (wrapStreamWithErrorHandling closes the stream cleanly after
|
|
@@ -303,14 +335,14 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
303
335
|
// The initial SSR HTML remains as-is; the first client navigation will
|
|
304
336
|
// replace it with a React-managed tree.
|
|
305
337
|
initRouter();
|
|
306
|
-
|
|
338
|
+
_reactRoot = createRoot(document);
|
|
307
339
|
}
|
|
308
340
|
|
|
309
341
|
// ── Router initialization (hoisted above hydrateRoot) ────────────────
|
|
310
342
|
// Extracted into a function so both the hydration and createRoot paths
|
|
311
343
|
// can call it. Must run before hydrateRoot so useRouter() works during
|
|
312
|
-
// the initial render.
|
|
313
|
-
//
|
|
344
|
+
// the initial render. renderRoot uses transitionRender which is set
|
|
345
|
+
// by the TransitionRoot component during hydration.
|
|
314
346
|
function initRouter(): void {
|
|
315
347
|
const deps: RouterDeps = {
|
|
316
348
|
fetch: (url, init) => window.fetch(url, init),
|
|
@@ -335,14 +367,28 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
335
367
|
return createFromFetch(fetchPromise);
|
|
336
368
|
},
|
|
337
369
|
|
|
338
|
-
// Render decoded RSC tree
|
|
339
|
-
//
|
|
340
|
-
//
|
|
370
|
+
// Render decoded RSC tree via TransitionRoot's state-based mechanism.
|
|
371
|
+
// Wraps with NavigationProvider (for atomic useParams/usePathname updates)
|
|
372
|
+
// and TimberNuqsAdapter (for nuqs context).
|
|
373
|
+
//
|
|
374
|
+
// The router calls setNavigationState() before renderRoot(), so
|
|
375
|
+
// getNavigationState() returns the new params/pathname. By wrapping
|
|
376
|
+
// the element in NavigationProvider here, the context value and the
|
|
377
|
+
// RSC tree are passed to startTransition(() => setState()) in the same
|
|
378
|
+
// call — making the update atomic. Preserved layout components that call
|
|
379
|
+
// useParams() or usePathname() re-render in the same pass as the
|
|
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.
|
|
341
387
|
renderRoot: (element: unknown) => {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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);
|
|
346
392
|
},
|
|
347
393
|
|
|
348
394
|
// Schedule a callback after the next paint so scroll operations
|
|
@@ -384,11 +430,16 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
384
430
|
delete (self as unknown as Record<string, unknown>).__timber_segments;
|
|
385
431
|
}
|
|
386
432
|
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
433
|
+
// Note: __timber_params is read before hydrateRoot (see above) so that
|
|
434
|
+
// NavigationProvider has correct values during hydration. If the hydration
|
|
435
|
+
// path was skipped (no RSC payload), populate the fallback here.
|
|
436
|
+
const lateTimberParams = (self as unknown as Record<string, unknown>).__timber_params;
|
|
437
|
+
if (lateTimberParams && typeof lateTimberParams === 'object') {
|
|
438
|
+
setCurrentParams(lateTimberParams as Record<string, string | string[]>);
|
|
439
|
+
setNavigationState({
|
|
440
|
+
params: lateTimberParams as Record<string, string | string[]>,
|
|
441
|
+
pathname: window.location.pathname,
|
|
442
|
+
});
|
|
392
443
|
delete (self as unknown as Record<string, unknown>).__timber_params;
|
|
393
444
|
}
|
|
394
445
|
|
|
@@ -481,208 +532,7 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
481
532
|
}
|
|
482
533
|
}
|
|
483
534
|
|
|
484
|
-
// ─── Server Log Replay (Dev Only) ─────────────────────────────────
|
|
485
|
-
|
|
486
|
-
/** Payload shape from plugins/dev-logs.ts */
|
|
487
|
-
interface ServerLogPayload {
|
|
488
|
-
level: 'log' | 'warn' | 'error' | 'debug' | 'info';
|
|
489
|
-
args: unknown[];
|
|
490
|
-
location: string | null;
|
|
491
|
-
timestamp: number;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* Deserialize a serialized arg back into a console-friendly value.
|
|
496
|
-
*
|
|
497
|
-
* Handles Error objects (serialized as { __type: 'Error', ... }),
|
|
498
|
-
* Maps, Sets, and passes everything else through.
|
|
499
|
-
*/
|
|
500
|
-
function deserializeArg(arg: unknown): unknown {
|
|
501
|
-
if (arg === '[undefined]') return undefined;
|
|
502
|
-
if (arg === null || typeof arg !== 'object') return arg;
|
|
503
|
-
|
|
504
|
-
const obj = arg as Record<string, unknown>;
|
|
505
|
-
|
|
506
|
-
if (obj.__type === 'Error') {
|
|
507
|
-
const err = new Error(obj.message as string);
|
|
508
|
-
err.name = obj.name as string;
|
|
509
|
-
if (obj.stack) err.stack = obj.stack as string;
|
|
510
|
-
return err;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
if (obj.__type === 'Map') {
|
|
514
|
-
return new Map(
|
|
515
|
-
Object.entries(obj.entries as Record<string, unknown>).map(([k, v]) => [k, deserializeArg(v)])
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
if (obj.__type === 'Set') {
|
|
520
|
-
return new Set((obj.values as unknown[]).map(deserializeArg));
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
if (Array.isArray(arg)) {
|
|
524
|
-
return arg.map(deserializeArg);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
// Plain object — recurse
|
|
528
|
-
const result: Record<string, unknown> = {};
|
|
529
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
530
|
-
result[key] = deserializeArg(value);
|
|
531
|
-
}
|
|
532
|
-
return result;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
/**
|
|
536
|
-
* Set up the HMR listener that replays server console output in the browser.
|
|
537
|
-
*
|
|
538
|
-
* Each message arrives with a log level and serialized args. We prepend
|
|
539
|
-
* a styled "[SERVER]" badge and call the matching console method.
|
|
540
|
-
*/
|
|
541
|
-
function setupServerLogReplay(hot: {
|
|
542
|
-
on(event: string, cb: (...args: unknown[]) => void): void;
|
|
543
|
-
}): void {
|
|
544
|
-
/** CSS styles for the [SERVER] badge in browser console. */
|
|
545
|
-
const BADGE_STYLES: Record<string, string> = {
|
|
546
|
-
log: 'background: #0070f3; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
547
|
-
info: 'background: #0070f3; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
548
|
-
warn: 'background: #f5a623; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
549
|
-
error:
|
|
550
|
-
'background: #e00; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
551
|
-
debug:
|
|
552
|
-
'background: #666; color: white; padding: 1px 5px; border-radius: 3px; font-weight: bold;',
|
|
553
|
-
};
|
|
554
|
-
|
|
555
|
-
hot.on('timber:server-log', (data: unknown) => {
|
|
556
|
-
const payload = data as ServerLogPayload;
|
|
557
|
-
const level = payload.level;
|
|
558
|
-
const fn = console[level] ?? console.log;
|
|
559
|
-
const args = payload.args.map(deserializeArg);
|
|
560
|
-
|
|
561
|
-
const badge = `%cSERVER`;
|
|
562
|
-
const style = BADGE_STYLES[level] ?? BADGE_STYLES.log;
|
|
563
|
-
const locationSuffix = payload.location ? ` (${payload.location})` : '';
|
|
564
|
-
|
|
565
|
-
fn.call(console, badge, style, ...args, locationSuffix ? `\n → ${payload.location}` : '');
|
|
566
|
-
});
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// ─── Client Error Forwarding (Dev Only) ──────────────────────────
|
|
570
535
|
|
|
571
|
-
/**
|
|
572
|
-
* Set up global error handlers that forward uncaught client-side
|
|
573
|
-
* errors to the dev server via Vite's HMR channel.
|
|
574
|
-
*
|
|
575
|
-
* The server receives 'timber:client-error' events, and echoes them
|
|
576
|
-
* back as Vite '{ type: "error" }' payloads to trigger the overlay.
|
|
577
|
-
*/
|
|
578
|
-
function setupClientErrorForwarding(hot: { send(event: string, data: unknown): void }): void {
|
|
579
|
-
window.addEventListener('error', (event: ErrorEvent) => {
|
|
580
|
-
// Skip errors without useful information
|
|
581
|
-
if (!event.error && !event.message) return;
|
|
582
|
-
// Skip errors during page unload — these are abort-related, not application errors
|
|
583
|
-
if (isPageUnloading()) return;
|
|
584
|
-
|
|
585
|
-
const error = event.error;
|
|
586
|
-
hot.send('timber:client-error', {
|
|
587
|
-
message: error?.message ?? event.message,
|
|
588
|
-
stack: error?.stack ?? '',
|
|
589
|
-
componentStack: error?.componentStack ?? null,
|
|
590
|
-
});
|
|
591
|
-
});
|
|
592
|
-
|
|
593
|
-
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
|
|
594
|
-
const reason = event.reason;
|
|
595
|
-
if (!reason) return;
|
|
596
|
-
// Skip rejections during page unload — aborted fetches/streams cause these
|
|
597
|
-
if (isPageUnloading()) return;
|
|
598
|
-
|
|
599
|
-
const message = reason instanceof Error ? reason.message : String(reason);
|
|
600
|
-
const stack = reason instanceof Error ? (reason.stack ?? '') : '';
|
|
601
|
-
|
|
602
|
-
hot.send('timber:client-error', {
|
|
603
|
-
message,
|
|
604
|
-
stack,
|
|
605
|
-
componentStack: null,
|
|
606
|
-
});
|
|
607
|
-
});
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
// ─── Link Click Interception ─────────────────────────────────────
|
|
611
|
-
|
|
612
|
-
/**
|
|
613
|
-
* Handle click events on timber links. Intercepts clicks on <a> elements
|
|
614
|
-
* marked with data-timber-link and triggers SPA navigation instead of
|
|
615
|
-
* a full page load.
|
|
616
|
-
*
|
|
617
|
-
* Passes through to default browser behavior when:
|
|
618
|
-
* - Modified keys are held (Ctrl, Meta, Shift, Alt) — open in new tab
|
|
619
|
-
* - The click is not the primary button
|
|
620
|
-
* - The link has a target attribute (e.g., target="_blank")
|
|
621
|
-
* - The link has a download attribute
|
|
622
|
-
*/
|
|
623
|
-
function handleLinkClick(event: MouseEvent, router: RouterInstance): void {
|
|
624
|
-
// Only intercept primary clicks without modifier keys
|
|
625
|
-
if (event.button !== 0) return;
|
|
626
|
-
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
627
|
-
if (event.defaultPrevented) return;
|
|
628
|
-
|
|
629
|
-
// Find the closest <a> ancestor with data-timber-link
|
|
630
|
-
const anchor = (event.target as Element).closest?.(
|
|
631
|
-
'a[data-timber-link]'
|
|
632
|
-
) as HTMLAnchorElement | null;
|
|
633
|
-
if (!anchor) return;
|
|
634
|
-
|
|
635
|
-
// Don't intercept links that should open externally
|
|
636
|
-
if (anchor.target && anchor.target !== '_self') return;
|
|
637
|
-
if (anchor.hasAttribute('download')) return;
|
|
638
|
-
|
|
639
|
-
const href = anchor.getAttribute('href');
|
|
640
|
-
if (!href) return;
|
|
641
|
-
|
|
642
|
-
// Prevent default navigation
|
|
643
|
-
event.preventDefault();
|
|
644
|
-
|
|
645
|
-
// Call onNavigate if registered on this anchor (via LinkNavigateInterceptor).
|
|
646
|
-
// If the handler calls preventDefault(), skip the default SPA navigation —
|
|
647
|
-
// the caller is responsible for navigating (e.g. via router.push()).
|
|
648
|
-
const onNavigate = anchor[ON_NAVIGATE_KEY];
|
|
649
|
-
if (onNavigate) {
|
|
650
|
-
let prevented = false;
|
|
651
|
-
onNavigate({
|
|
652
|
-
preventDefault: () => {
|
|
653
|
-
prevented = true;
|
|
654
|
-
},
|
|
655
|
-
});
|
|
656
|
-
if (prevented) return;
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// Check scroll preference from data attribute
|
|
660
|
-
const scroll = anchor.getAttribute('data-timber-scroll') !== 'false';
|
|
661
|
-
|
|
662
|
-
// Trigger SPA navigation
|
|
663
|
-
void router.navigate(href, { scroll });
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// ─── Prefetch on Hover ───────────────────────────────────────────
|
|
667
|
-
|
|
668
|
-
/**
|
|
669
|
-
* Handle mouseenter events on prefetch-enabled links. When the user
|
|
670
|
-
* hovers over <a data-timber-prefetch>, the RSC payload is fetched
|
|
671
|
-
* and cached for near-instant navigation.
|
|
672
|
-
*
|
|
673
|
-
* See design/19-client-navigation.md §"Prefetch Cache"
|
|
674
|
-
*/
|
|
675
|
-
function handleLinkHover(event: MouseEvent, router: RouterInstance): void {
|
|
676
|
-
const anchor = (event.target as Element).closest?.(
|
|
677
|
-
'a[data-timber-prefetch]'
|
|
678
|
-
) as HTMLAnchorElement | null;
|
|
679
|
-
if (!anchor) return;
|
|
680
|
-
|
|
681
|
-
const href = anchor.getAttribute('href');
|
|
682
|
-
if (!href) return;
|
|
683
|
-
|
|
684
|
-
router.prefetch(href);
|
|
685
|
-
}
|
|
686
536
|
|
|
687
537
|
bootstrap(config);
|
|
688
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
|
@@ -44,6 +44,10 @@ export type { UseActionStateFn, UseActionStateReturn, FormErrorsResult } from '.
|
|
|
44
44
|
// Params
|
|
45
45
|
export { useParams, setCurrentParams } from './use-params';
|
|
46
46
|
|
|
47
|
+
// Navigation context (framework-internal, used by browser-entry for atomic updates)
|
|
48
|
+
export { NavigationProvider, getNavigationState, setNavigationState } from './navigation-context';
|
|
49
|
+
export type { NavigationState } from './navigation-context';
|
|
50
|
+
|
|
47
51
|
// Query states (URL-synced search params)
|
|
48
52
|
export { useQueryStates, bindUseQueryStates } from './use-query-states';
|
|
49
53
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NavigationContext — React context for navigation state.
|
|
3
|
+
*
|
|
4
|
+
* Holds the current route params and pathname, updated atomically
|
|
5
|
+
* with the RSC tree on each navigation. This replaces the previous
|
|
6
|
+
* useSyncExternalStore approach for useParams() and usePathname(),
|
|
7
|
+
* which suffered from a timing gap: the new tree could commit before
|
|
8
|
+
* the external store re-renders fired, causing a frame where both
|
|
9
|
+
* old and new active states were visible simultaneously.
|
|
10
|
+
*
|
|
11
|
+
* By wrapping the RSC payload element in NavigationProvider inside
|
|
12
|
+
* renderRoot(), the context value and the element tree are passed to
|
|
13
|
+
* reactRoot.render() in the same call — atomic by construction.
|
|
14
|
+
* All consumers (useParams, usePathname) see the new values in the
|
|
15
|
+
* same render pass as the new tree.
|
|
16
|
+
*
|
|
17
|
+
* During SSR, no NavigationProvider is mounted. Hooks fall back to
|
|
18
|
+
* the ALS-backed getSsrData() for per-request isolation.
|
|
19
|
+
*
|
|
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"
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import React, { createElement, type ReactNode } from 'react';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Context type
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
export interface NavigationState {
|
|
36
|
+
params: Record<string, string | string[]>;
|
|
37
|
+
pathname: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Lazy context initialization
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/**
|
|
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.
|
|
48
|
+
*/
|
|
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
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read the navigation context. Returns null during SSR (no provider)
|
|
62
|
+
* or in the RSC environment (no context available).
|
|
63
|
+
* Internal — used by useParams() and usePathname().
|
|
64
|
+
*/
|
|
65
|
+
export function useNavigationContext(): NavigationState | null {
|
|
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);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Provider component
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
export interface NavigationProviderProps {
|
|
78
|
+
value: NavigationState;
|
|
79
|
+
children?: ReactNode;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Wraps children with NavigationContext.Provider.
|
|
84
|
+
*
|
|
85
|
+
* Used in browser-entry.ts renderRoot to wrap the RSC payload element
|
|
86
|
+
* so that navigation state updates atomically with the tree render.
|
|
87
|
+
*/
|
|
88
|
+
export function NavigationProvider({ value, children }: NavigationProviderProps): React.ReactElement {
|
|
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);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Module-level state for renderRoot to read
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Module-level navigation state. Updated by the router before calling
|
|
103
|
+
* renderRoot(). The renderRoot callback reads this to create the
|
|
104
|
+
* NavigationProvider with the correct values.
|
|
105
|
+
*
|
|
106
|
+
* This is NOT used by hooks directly — hooks read from React context.
|
|
107
|
+
* This exists only as a communication channel between the router
|
|
108
|
+
* (which knows the new nav state) and renderRoot (which wraps the element).
|
|
109
|
+
*/
|
|
110
|
+
let _currentNavState: NavigationState = { params: {}, pathname: '/' };
|
|
111
|
+
|
|
112
|
+
export function setNavigationState(state: NavigationState): void {
|
|
113
|
+
_currentNavState = state;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function getNavigationState(): NavigationState {
|
|
117
|
+
return _currentNavState;
|
|
118
|
+
}
|