@timber-js/app 0.2.0-alpha.1 → 0.2.0-alpha.3
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/client/browser-entry.d.ts +1 -2
- package/dist/client/browser-entry.d.ts.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +133 -91
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts +12 -9
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts +9 -3
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/top-loader.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/chunks.d.ts +9 -1
- package/dist/plugins/chunks.d.ts.map +1 -1
- package/dist/shims/image.d.ts +15 -15
- package/package.json +1 -1
- package/src/client/browser-entry.ts +6 -18
- package/src/client/index.ts +1 -1
- package/src/client/link.tsx +92 -34
- package/src/client/navigation-context.ts +54 -14
- package/src/client/top-loader.tsx +18 -15
- package/src/plugins/chunks.ts +9 -1
- package/dist/client/browser-links.d.ts +0 -32
- package/dist/client/browser-links.d.ts.map +0 -1
- package/dist/client/link-navigate-interceptor.d.ts +0 -28
- package/dist/client/link-navigate-interceptor.d.ts.map +0 -1
- package/src/client/browser-links.ts +0 -90
- package/src/client/link-navigate-interceptor.tsx +0 -62
package/dist/plugins/chunks.d.ts
CHANGED
|
@@ -31,7 +31,15 @@ export declare function isTimberRuntime(id: string): boolean;
|
|
|
31
31
|
*
|
|
32
32
|
* Currently a no-op — Rolldown's default splitting produces the desired
|
|
33
33
|
* output (one main bundle + per-route chunks). The plugin is retained as
|
|
34
|
-
* a hook point for future chunking adjustments
|
|
34
|
+
* a hook point for future chunking adjustments.
|
|
35
|
+
*
|
|
36
|
+
* NOTE: Ideally we'd use Rolldown's `format: 'app'` (module registry format)
|
|
37
|
+
* which guarantees single-copy module semantics like webpack/Turbopack.
|
|
38
|
+
* However, `format: 'app'` is not yet implemented in Rolldown rc.10.
|
|
39
|
+
* Until it ships, singleton state in navigation-context.ts uses
|
|
40
|
+
* globalThis + Symbol.for as a cross-chunk safety net.
|
|
41
|
+
*
|
|
42
|
+
* Design docs: 27-chunking-strategy.md
|
|
35
43
|
*/
|
|
36
44
|
export declare function timberChunks(): Plugin;
|
|
37
45
|
//# sourceMappingURL=chunks.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chunks.d.ts","sourceRoot":"","sources":["../../src/plugins/chunks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAEnC;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAOnD;AAED
|
|
1
|
+
{"version":3,"file":"chunks.d.ts","sourceRoot":"","sources":["../../src/plugins/chunks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAEnC;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAOnD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAIrC"}
|
package/dist/shims/image.d.ts
CHANGED
|
@@ -51,17 +51,17 @@ export declare function Image({ priority: _priority, quality: _quality, fill: _f
|
|
|
51
51
|
autoCapitalize?: "off" | "none" | "on" | "sentences" | "words" | "characters" | undefined | (string & {});
|
|
52
52
|
autoFocus?: boolean | undefined;
|
|
53
53
|
className?: string | undefined;
|
|
54
|
-
contentEditable?: (boolean | "
|
|
54
|
+
contentEditable?: (boolean | "true" | "false") | "inherit" | "plaintext-only" | undefined;
|
|
55
55
|
contextMenu?: string | undefined;
|
|
56
56
|
dir?: string | undefined;
|
|
57
|
-
draggable?: (boolean | "
|
|
57
|
+
draggable?: (boolean | "true" | "false") | undefined;
|
|
58
58
|
enterKeyHint?: "enter" | "done" | "go" | "next" | "previous" | "search" | "send" | undefined;
|
|
59
59
|
hidden?: boolean | undefined;
|
|
60
60
|
id?: string | undefined;
|
|
61
61
|
lang?: string | undefined;
|
|
62
62
|
nonce?: string | undefined;
|
|
63
63
|
slot?: string | undefined;
|
|
64
|
-
spellCheck?: (boolean | "
|
|
64
|
+
spellCheck?: (boolean | "true" | "false") | undefined;
|
|
65
65
|
style?: import("react").CSSProperties | undefined;
|
|
66
66
|
tabIndex?: number | undefined;
|
|
67
67
|
title?: string | undefined;
|
|
@@ -99,11 +99,11 @@ export declare function Image({ priority: _priority, quality: _quality, fill: _f
|
|
|
99
99
|
exportparts?: string | undefined;
|
|
100
100
|
part?: string | undefined;
|
|
101
101
|
"aria-activedescendant"?: string | undefined;
|
|
102
|
-
"aria-atomic"?: (boolean | "
|
|
102
|
+
"aria-atomic"?: (boolean | "true" | "false") | undefined;
|
|
103
103
|
"aria-autocomplete"?: "none" | "inline" | "list" | "both" | undefined;
|
|
104
104
|
"aria-braillelabel"?: string | undefined;
|
|
105
105
|
"aria-brailleroledescription"?: string | undefined;
|
|
106
|
-
"aria-busy"?: (boolean | "
|
|
106
|
+
"aria-busy"?: (boolean | "true" | "false") | undefined;
|
|
107
107
|
"aria-checked"?: boolean | "false" | "mixed" | "true" | undefined;
|
|
108
108
|
"aria-colcount"?: number | undefined;
|
|
109
109
|
"aria-colindex"?: number | undefined;
|
|
@@ -114,37 +114,37 @@ export declare function Image({ priority: _priority, quality: _quality, fill: _f
|
|
|
114
114
|
"aria-describedby"?: string | undefined;
|
|
115
115
|
"aria-description"?: string | undefined;
|
|
116
116
|
"aria-details"?: string | undefined;
|
|
117
|
-
"aria-disabled"?: (boolean | "
|
|
117
|
+
"aria-disabled"?: (boolean | "true" | "false") | undefined;
|
|
118
118
|
"aria-dropeffect"?: "none" | "copy" | "execute" | "link" | "move" | "popup" | undefined;
|
|
119
119
|
"aria-errormessage"?: string | undefined;
|
|
120
|
-
"aria-expanded"?: (boolean | "
|
|
120
|
+
"aria-expanded"?: (boolean | "true" | "false") | undefined;
|
|
121
121
|
"aria-flowto"?: string | undefined;
|
|
122
|
-
"aria-grabbed"?: (boolean | "
|
|
122
|
+
"aria-grabbed"?: (boolean | "true" | "false") | undefined;
|
|
123
123
|
"aria-haspopup"?: boolean | "false" | "true" | "menu" | "listbox" | "tree" | "grid" | "dialog" | undefined;
|
|
124
|
-
"aria-hidden"?: (boolean | "
|
|
124
|
+
"aria-hidden"?: (boolean | "true" | "false") | undefined;
|
|
125
125
|
"aria-invalid"?: boolean | "false" | "true" | "grammar" | "spelling" | undefined;
|
|
126
126
|
"aria-keyshortcuts"?: string | undefined;
|
|
127
127
|
"aria-label"?: string | undefined;
|
|
128
128
|
"aria-labelledby"?: string | undefined;
|
|
129
129
|
"aria-level"?: number | undefined;
|
|
130
130
|
"aria-live"?: "off" | "assertive" | "polite" | undefined;
|
|
131
|
-
"aria-modal"?: (boolean | "
|
|
132
|
-
"aria-multiline"?: (boolean | "
|
|
133
|
-
"aria-multiselectable"?: (boolean | "
|
|
131
|
+
"aria-modal"?: (boolean | "true" | "false") | undefined;
|
|
132
|
+
"aria-multiline"?: (boolean | "true" | "false") | undefined;
|
|
133
|
+
"aria-multiselectable"?: (boolean | "true" | "false") | undefined;
|
|
134
134
|
"aria-orientation"?: "horizontal" | "vertical" | undefined;
|
|
135
135
|
"aria-owns"?: string | undefined;
|
|
136
136
|
"aria-placeholder"?: string | undefined;
|
|
137
137
|
"aria-posinset"?: number | undefined;
|
|
138
138
|
"aria-pressed"?: boolean | "false" | "mixed" | "true" | undefined;
|
|
139
|
-
"aria-readonly"?: (boolean | "
|
|
139
|
+
"aria-readonly"?: (boolean | "true" | "false") | undefined;
|
|
140
140
|
"aria-relevant"?: "additions" | "additions removals" | "additions text" | "all" | "removals" | "removals additions" | "removals text" | "text" | "text additions" | "text removals" | undefined;
|
|
141
|
-
"aria-required"?: (boolean | "
|
|
141
|
+
"aria-required"?: (boolean | "true" | "false") | undefined;
|
|
142
142
|
"aria-roledescription"?: string | undefined;
|
|
143
143
|
"aria-rowcount"?: number | undefined;
|
|
144
144
|
"aria-rowindex"?: number | undefined;
|
|
145
145
|
"aria-rowindextext"?: string | undefined;
|
|
146
146
|
"aria-rowspan"?: number | undefined;
|
|
147
|
-
"aria-selected"?: (boolean | "
|
|
147
|
+
"aria-selected"?: (boolean | "true" | "false") | undefined;
|
|
148
148
|
"aria-setsize"?: number | undefined;
|
|
149
149
|
"aria-sort"?: "none" | "ascending" | "descending" | "other" | undefined;
|
|
150
150
|
"aria-valuemax"?: number | undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.3",
|
|
4
4
|
"description": "Vite-native React framework for Cloudflare Workers — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
* 3. Setting up client-side navigation for subsequent page transitions
|
|
13
13
|
*
|
|
14
14
|
* After hydration, the browser entry:
|
|
15
|
-
* -
|
|
16
|
-
* - Listens for mouseenter on <a data-timber-prefetch> for hover prefetch
|
|
15
|
+
* - Link click handling is per-component (Link's onClick), not global delegation
|
|
17
16
|
* - Listens for popstate events for back/forward navigation
|
|
18
17
|
*
|
|
19
18
|
* Design docs: 18-build-system.md §"Entry Files", 19-client-navigation.md
|
|
@@ -50,7 +49,8 @@ import {
|
|
|
50
49
|
setNavigationState,
|
|
51
50
|
} from './navigation-context.js';
|
|
52
51
|
import { setupServerLogReplay, setupClientErrorForwarding } from './browser-dev.js';
|
|
53
|
-
|
|
52
|
+
// browser-links.ts removed — Link components own their click/hover handlers directly.
|
|
53
|
+
// See LOCAL-340.
|
|
54
54
|
import { TransitionRoot, transitionRender, navigateTransition } from './transition-root.js';
|
|
55
55
|
import { isStaleClientReference, triggerStaleReload, clearStaleReloadFlag } from './stale-reload.js';
|
|
56
56
|
|
|
@@ -593,21 +593,9 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
593
593
|
}
|
|
594
594
|
window.addEventListener('scroll', saveScrollPosition, { passive: true });
|
|
595
595
|
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
|
|
599
|
-
handleLinkClick(event, router);
|
|
600
|
-
});
|
|
601
|
-
|
|
602
|
-
// Delegate mouseenter events on <a data-timber-prefetch> for hover prefetch.
|
|
603
|
-
// Uses capture phase to detect mouseenter on nested elements.
|
|
604
|
-
document.addEventListener(
|
|
605
|
-
'mouseenter',
|
|
606
|
-
(event: MouseEvent) => {
|
|
607
|
-
handleLinkHover(event, router);
|
|
608
|
-
},
|
|
609
|
-
true // capture phase — mouseenter doesn't bubble
|
|
610
|
-
);
|
|
596
|
+
// Link click and hover prefetch are handled per-component by Link's
|
|
597
|
+
// onClick and onMouseEnter handlers. No global delegation needed.
|
|
598
|
+
// See LOCAL-340.
|
|
611
599
|
|
|
612
600
|
// Dev-only: Listen for RSC module invalidation events from @vitejs/plugin-rsc.
|
|
613
601
|
// When a server component is edited, the RSC plugin sends an "rsc:update"
|
package/src/client/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ export type { JsonSerializable, RenderErrorDigest } from './types';
|
|
|
6
6
|
// Navigation
|
|
7
7
|
export { Link, interpolateParams, resolveHref, validateLinkHref, buildLinkProps } from './link';
|
|
8
8
|
export type { LinkProps, LinkPropsWithHref, LinkPropsWithParams } from './link';
|
|
9
|
-
export type { OnNavigateHandler, OnNavigateEvent } from './link
|
|
9
|
+
export type { OnNavigateHandler, OnNavigateEvent } from './link';
|
|
10
10
|
export { createRouter } from './router';
|
|
11
11
|
export type {
|
|
12
12
|
RouterInstance,
|
package/src/client/link.tsx
CHANGED
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
// See design/19-client-navigation.md § Progressive Enhancement
|
|
5
5
|
//
|
|
6
6
|
// Without JavaScript, <Link> renders as a plain <a> tag — standard browser
|
|
7
|
-
// navigation. With JavaScript, the
|
|
8
|
-
//
|
|
7
|
+
// navigation. With JavaScript, the Link component's onClick handler triggers
|
|
8
|
+
// RSC-based client navigation via the router.
|
|
9
|
+
//
|
|
10
|
+
// Each Link owns its own click handler — no global event delegation.
|
|
11
|
+
// This keeps navigation within React's component tree, ensuring pending
|
|
12
|
+
// state (useLinkStatus) updates atomically with the navigation.
|
|
9
13
|
//
|
|
10
14
|
// Typed Link: design/09-typescript.md §"Typed Link"
|
|
11
15
|
// - href validated against known routes (via codegen overloads, not runtime)
|
|
@@ -14,14 +18,19 @@
|
|
|
14
18
|
// - params and fully-resolved string href are mutually exclusive
|
|
15
19
|
// - searchParams and inline query string are mutually exclusive
|
|
16
20
|
|
|
17
|
-
import type { AnchorHTMLAttributes, ReactNode } from 'react';
|
|
21
|
+
import type { AnchorHTMLAttributes, ReactNode, MouseEvent as ReactMouseEvent } from 'react';
|
|
18
22
|
import type { SearchParamsDefinition } from '#/search-params/create.js';
|
|
19
|
-
import type { OnNavigateHandler } from './link-navigate-interceptor.js';
|
|
20
|
-
import { LinkNavigateInterceptor } from './link-navigate-interceptor.js';
|
|
21
23
|
import { LinkStatusProvider } from './link-status-provider.js';
|
|
24
|
+
import { getRouterOrNull } from './router-ref.js';
|
|
22
25
|
|
|
23
26
|
// ─── Types ───────────────────────────────────────────────────────
|
|
24
27
|
|
|
28
|
+
export type OnNavigateEvent = {
|
|
29
|
+
preventDefault: () => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type OnNavigateHandler = (e: OnNavigateEvent) => void;
|
|
33
|
+
|
|
25
34
|
/**
|
|
26
35
|
* Base props shared by all Link variants.
|
|
27
36
|
*/
|
|
@@ -108,7 +117,7 @@ export function validateLinkHref(href: string): void {
|
|
|
108
117
|
// ─── Internal Link Detection ─────────────────────────────────────
|
|
109
118
|
|
|
110
119
|
/** Returns true if the href is an internal path (not an external URL) */
|
|
111
|
-
function isInternalHref(href: string): boolean {
|
|
120
|
+
export function isInternalHref(href: string): boolean {
|
|
112
121
|
// Relative paths, root-relative paths, and hash links are internal
|
|
113
122
|
if (href.startsWith('/') || href.startsWith('#') || href.startsWith('?')) {
|
|
114
123
|
return true;
|
|
@@ -232,10 +241,7 @@ export function resolveHref(
|
|
|
232
241
|
// ─── Build Props ─────────────────────────────────────────────────
|
|
233
242
|
|
|
234
243
|
interface LinkOutputProps {
|
|
235
|
-
|
|
236
|
-
'data-timber-link'?: boolean;
|
|
237
|
-
'data-timber-prefetch'?: boolean;
|
|
238
|
-
'data-timber-scroll'?: string;
|
|
244
|
+
href: string;
|
|
239
245
|
}
|
|
240
246
|
|
|
241
247
|
/**
|
|
@@ -243,7 +249,7 @@ interface LinkOutputProps {
|
|
|
243
249
|
* for testability — the component just spreads these onto an <a>.
|
|
244
250
|
*/
|
|
245
251
|
export function buildLinkProps(
|
|
246
|
-
props: Pick<LinkPropsWithHref, 'href'
|
|
252
|
+
props: Pick<LinkPropsWithHref, 'href'> & {
|
|
247
253
|
params?: Record<string, string | number | string[]>;
|
|
248
254
|
searchParams?: {
|
|
249
255
|
definition: SearchParamsDefinition<Record<string, unknown>>;
|
|
@@ -252,25 +258,38 @@ export function buildLinkProps(
|
|
|
252
258
|
}
|
|
253
259
|
): LinkOutputProps {
|
|
254
260
|
const resolvedHref = resolveHref(props.href, props.params, props.searchParams);
|
|
255
|
-
|
|
256
261
|
validateLinkHref(resolvedHref);
|
|
262
|
+
return { href: resolvedHref };
|
|
263
|
+
}
|
|
257
264
|
|
|
258
|
-
|
|
259
|
-
const internal = isInternalHref(resolvedHref);
|
|
265
|
+
// ─── Click Handler ───────────────────────────────────────────────
|
|
260
266
|
|
|
261
|
-
|
|
262
|
-
|
|
267
|
+
/**
|
|
268
|
+
* Should this click be intercepted for SPA navigation?
|
|
269
|
+
*
|
|
270
|
+
* Returns false (pass through to browser) when:
|
|
271
|
+
* - Modified keys are held (Ctrl, Meta, Shift, Alt) — open in new tab
|
|
272
|
+
* - The click is not the primary button
|
|
273
|
+
* - The event was already prevented by a parent handler
|
|
274
|
+
* - The link has target="_blank" or similar
|
|
275
|
+
* - The link has a download attribute
|
|
276
|
+
* - The href is external
|
|
277
|
+
*/
|
|
278
|
+
function shouldInterceptClick(
|
|
279
|
+
event: ReactMouseEvent<HTMLAnchorElement>,
|
|
280
|
+
resolvedHref: string
|
|
281
|
+
): boolean {
|
|
282
|
+
if (event.button !== 0) return false;
|
|
283
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false;
|
|
284
|
+
if (event.defaultPrevented) return false;
|
|
263
285
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
286
|
+
const anchor = event.currentTarget;
|
|
287
|
+
if (anchor.target && anchor.target !== '_self') return false;
|
|
288
|
+
if (anchor.hasAttribute('download')) return false;
|
|
267
289
|
|
|
268
|
-
|
|
269
|
-
output['data-timber-scroll'] = 'false';
|
|
270
|
-
}
|
|
271
|
-
}
|
|
290
|
+
if (!isInternalHref(resolvedHref)) return false;
|
|
272
291
|
|
|
273
|
-
return
|
|
292
|
+
return true;
|
|
274
293
|
}
|
|
275
294
|
|
|
276
295
|
// ─── Link Component ──────────────────────────────────────────────
|
|
@@ -279,8 +298,9 @@ export function buildLinkProps(
|
|
|
279
298
|
* Navigation link with progressive enhancement.
|
|
280
299
|
*
|
|
281
300
|
* Renders as a plain `<a>` tag — works without JavaScript. When the client
|
|
282
|
-
* runtime is active,
|
|
283
|
-
*
|
|
301
|
+
* runtime is active, the Link's onClick handler triggers RSC-based client
|
|
302
|
+
* navigation via the router. No global event delegation — each Link owns
|
|
303
|
+
* its own click handling.
|
|
284
304
|
*
|
|
285
305
|
* Supports typed routes via codegen overloads. At runtime:
|
|
286
306
|
* - `params` prop interpolates dynamic segments in the href pattern
|
|
@@ -293,20 +313,58 @@ export function Link({
|
|
|
293
313
|
params,
|
|
294
314
|
searchParams,
|
|
295
315
|
onNavigate,
|
|
316
|
+
onClick: userOnClick,
|
|
317
|
+
onMouseEnter: userOnMouseEnter,
|
|
296
318
|
children,
|
|
297
319
|
...rest
|
|
298
320
|
}: LinkProps) {
|
|
299
|
-
const
|
|
321
|
+
const { href: resolvedHref } = buildLinkProps({ href, params, searchParams });
|
|
322
|
+
const internal = isInternalHref(resolvedHref);
|
|
300
323
|
|
|
301
|
-
|
|
324
|
+
// ─── Click handler ───────────────────────────────────────────
|
|
325
|
+
// Each Link component owns its click handling. The router is
|
|
326
|
+
// accessed via the singleton ref — during SSR, getRouterOrNull()
|
|
327
|
+
// returns null and onClick is a no-op (the <a> works as a plain link).
|
|
328
|
+
const handleClick = internal
|
|
329
|
+
? (event: ReactMouseEvent<HTMLAnchorElement>) => {
|
|
330
|
+
// Call user's onClick first (e.g., analytics)
|
|
331
|
+
userOnClick?.(event);
|
|
332
|
+
|
|
333
|
+
if (!shouldInterceptClick(event, resolvedHref)) return;
|
|
334
|
+
|
|
335
|
+
// Call onNavigate if provided — allows caller to cancel
|
|
336
|
+
if (onNavigate) {
|
|
337
|
+
let prevented = false;
|
|
338
|
+
onNavigate({ preventDefault: () => { prevented = true; } });
|
|
339
|
+
if (prevented) {
|
|
340
|
+
event.preventDefault();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const router = getRouterOrNull();
|
|
346
|
+
if (!router) return; // SSR or pre-hydration — fall through to browser nav
|
|
347
|
+
|
|
348
|
+
event.preventDefault();
|
|
349
|
+
const shouldScroll = scroll !== false;
|
|
350
|
+
void router.navigate(resolvedHref, { scroll: shouldScroll });
|
|
351
|
+
}
|
|
352
|
+
: userOnClick; // External links — just pass through user's onClick
|
|
353
|
+
|
|
354
|
+
// ─── Hover prefetch ──────────────────────────────────────────
|
|
355
|
+
const handleMouseEnter = internal && prefetch
|
|
356
|
+
? (event: ReactMouseEvent<HTMLAnchorElement>) => {
|
|
357
|
+
userOnMouseEnter?.(event);
|
|
358
|
+
const router = getRouterOrNull();
|
|
359
|
+
if (router) {
|
|
360
|
+
router.prefetch(resolvedHref);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
: userOnMouseEnter;
|
|
302
364
|
|
|
303
365
|
return (
|
|
304
|
-
<a {...rest} {
|
|
305
|
-
{
|
|
306
|
-
<LinkNavigateInterceptor onNavigate={onNavigate}>{inner}</LinkNavigateInterceptor>
|
|
307
|
-
) : (
|
|
308
|
-
inner
|
|
309
|
-
)}
|
|
366
|
+
<a {...rest} href={resolvedHref} onClick={handleClick} onMouseEnter={handleMouseEnter}>
|
|
367
|
+
<LinkStatusProvider href={resolvedHref}>{children}</LinkStatusProvider>
|
|
310
368
|
</a>
|
|
311
369
|
);
|
|
312
370
|
}
|
|
@@ -25,9 +25,15 @@
|
|
|
25
25
|
* that depend on these APIs are safe to call from any environment —
|
|
26
26
|
* they return null or no-op when the APIs aren't available.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* SINGLETON GUARANTEE: All shared mutable state uses globalThis via
|
|
29
|
+
* Symbol.for keys. The RSC client bundler can duplicate this module
|
|
30
|
+
* across chunks (browser-entry graph + client-reference graph). With
|
|
31
|
+
* ESM output, each chunk gets its own module scope — module-level
|
|
32
|
+
* variables would create separate singleton instances per chunk.
|
|
33
|
+
* globalThis guarantees a single instance regardless of duplication.
|
|
34
|
+
*
|
|
35
|
+
* This workaround will be removed when Rolldown ships `format: 'app'`
|
|
36
|
+
* (module registry format that deduplicates like webpack/Turbopack).
|
|
31
37
|
* See design/27-chunking-strategy.md.
|
|
32
38
|
*
|
|
33
39
|
* See design/19-client-navigation.md §"NavigationContext"
|
|
@@ -52,17 +58,31 @@ export interface NavigationState {
|
|
|
52
58
|
* The context is created lazily to avoid calling createContext at module
|
|
53
59
|
* level. In the RSC environment, React.createContext doesn't exist —
|
|
54
60
|
* calling it at import time would crash the server.
|
|
61
|
+
*
|
|
62
|
+
* Context instances are stored on globalThis (NOT in module-level
|
|
63
|
+
* variables) because the ESM bundler can duplicate this module across
|
|
64
|
+
* chunks. Module-level variables would create separate instances per
|
|
65
|
+
* chunk — the provider in TransitionRoot (index chunk) would use
|
|
66
|
+
* context A while the consumer in LinkStatusProvider (shared chunk)
|
|
67
|
+
* reads from context B. globalThis guarantees a single instance.
|
|
68
|
+
*
|
|
69
|
+
* See design/27-chunking-strategy.md §"Singleton Safety"
|
|
55
70
|
*/
|
|
56
71
|
|
|
57
|
-
|
|
58
|
-
|
|
72
|
+
// Symbol keys for globalThis storage — prevents collisions with user code
|
|
73
|
+
const NAV_CTX_KEY = Symbol.for('__timber_nav_ctx');
|
|
74
|
+
const PENDING_CTX_KEY = Symbol.for('__timber_pending_nav_ctx');
|
|
59
75
|
|
|
60
76
|
function getOrCreateContext(): React.Context<NavigationState | null> | undefined {
|
|
61
|
-
|
|
77
|
+
const existing = (globalThis as Record<symbol, unknown>)[NAV_CTX_KEY] as
|
|
78
|
+
| React.Context<NavigationState | null>
|
|
79
|
+
| undefined;
|
|
80
|
+
if (existing !== undefined) return existing;
|
|
62
81
|
// createContext may not exist in the RSC environment
|
|
63
82
|
if (typeof React.createContext === 'function') {
|
|
64
|
-
|
|
65
|
-
|
|
83
|
+
const ctx = React.createContext<NavigationState | null>(null);
|
|
84
|
+
(globalThis as Record<symbol, unknown>)[NAV_CTX_KEY] = ctx;
|
|
85
|
+
return ctx;
|
|
66
86
|
}
|
|
67
87
|
return undefined;
|
|
68
88
|
}
|
|
@@ -119,15 +139,27 @@ export function NavigationProvider({
|
|
|
119
139
|
* NavigationProvider with the correct params/pathname.
|
|
120
140
|
*
|
|
121
141
|
* This is NOT used by hooks directly — hooks read from React context.
|
|
142
|
+
*
|
|
143
|
+
* Stored on globalThis (like the context instances above) because the
|
|
144
|
+
* router lives in one chunk while renderRoot lives in another. Module-
|
|
145
|
+
* level variables would be separate per chunk.
|
|
122
146
|
*/
|
|
123
|
-
|
|
147
|
+
const NAV_STATE_KEY = Symbol.for('__timber_nav_state');
|
|
148
|
+
|
|
149
|
+
function _getNavStateStore(): { current: NavigationState } {
|
|
150
|
+
const g = globalThis as Record<symbol, unknown>;
|
|
151
|
+
if (!g[NAV_STATE_KEY]) {
|
|
152
|
+
g[NAV_STATE_KEY] = { current: { params: {}, pathname: '/' } };
|
|
153
|
+
}
|
|
154
|
+
return g[NAV_STATE_KEY] as { current: NavigationState };
|
|
155
|
+
}
|
|
124
156
|
|
|
125
157
|
export function setNavigationState(state: NavigationState): void {
|
|
126
|
-
|
|
158
|
+
_getNavStateStore().current = state;
|
|
127
159
|
}
|
|
128
160
|
|
|
129
161
|
export function getNavigationState(): NavigationState {
|
|
130
|
-
return
|
|
162
|
+
return _getNavStateStore().current;
|
|
131
163
|
}
|
|
132
164
|
|
|
133
165
|
// ---------------------------------------------------------------------------
|
|
@@ -138,13 +170,21 @@ export function getNavigationState(): NavigationState {
|
|
|
138
170
|
* Separate context for the in-flight navigation URL. Provided by
|
|
139
171
|
* TransitionRoot (urgent useState), consumed by LinkStatusProvider
|
|
140
172
|
* and useNavigationPending.
|
|
173
|
+
*
|
|
174
|
+
* Uses globalThis via Symbol.for for the same reason as NavigationContext
|
|
175
|
+
* above — the bundler may duplicate this module across chunks, and module-
|
|
176
|
+
* level variables would create separate context instances.
|
|
141
177
|
*/
|
|
142
178
|
|
|
143
179
|
function getOrCreatePendingContext(): React.Context<string | null> | undefined {
|
|
144
|
-
|
|
180
|
+
const existing = (globalThis as Record<symbol, unknown>)[PENDING_CTX_KEY] as
|
|
181
|
+
| React.Context<string | null>
|
|
182
|
+
| undefined;
|
|
183
|
+
if (existing !== undefined) return existing;
|
|
145
184
|
if (typeof React.createContext === 'function') {
|
|
146
|
-
|
|
147
|
-
|
|
185
|
+
const ctx = React.createContext<string | null>(null);
|
|
186
|
+
(globalThis as Record<symbol, unknown>)[PENDING_CTX_KEY] = ctx;
|
|
187
|
+
return ctx;
|
|
148
188
|
}
|
|
149
189
|
return undefined;
|
|
150
190
|
}
|
|
@@ -60,6 +60,7 @@ const DEFAULT_Z_INDEX = 1600;
|
|
|
60
60
|
// Unique keyframes name to avoid collisions with user styles.
|
|
61
61
|
const CRAWL_KEYFRAMES = '__timber_top_loader_crawl';
|
|
62
62
|
const APPEAR_KEYFRAMES = '__timber_top_loader_appear';
|
|
63
|
+
const FINISH_KEYFRAMES = '__timber_top_loader_finish';
|
|
63
64
|
|
|
64
65
|
// Track whether the @keyframes rules have been injected into the document.
|
|
65
66
|
let keyframesInjected = false;
|
|
@@ -83,6 +84,11 @@ function ensureKeyframes(): void {
|
|
|
83
84
|
from { opacity: 0; }
|
|
84
85
|
to { opacity: 1; }
|
|
85
86
|
}
|
|
87
|
+
@keyframes ${FINISH_KEYFRAMES} {
|
|
88
|
+
0% { width: 90%; opacity: 1; }
|
|
89
|
+
50% { width: 100%; opacity: 1; }
|
|
90
|
+
100% { width: 100%; opacity: 0; }
|
|
91
|
+
}
|
|
86
92
|
`;
|
|
87
93
|
document.head.appendChild(style);
|
|
88
94
|
keyframesInjected = true;
|
|
@@ -161,14 +167,13 @@ export function TopLoader({ config }: { config?: TopLoaderConfig }): React.React
|
|
|
161
167
|
].join(', '),
|
|
162
168
|
}
|
|
163
169
|
: {
|
|
164
|
-
// Finishing:
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
transition: 'width 200ms ease, opacity 200ms ease 200ms',
|
|
170
|
+
// Finishing: fill to 100% then fade out via a keyframe animation.
|
|
171
|
+
// We use a keyframe instead of a CSS transition because the
|
|
172
|
+
// animation-to-transition handoff is unreliable — the browser
|
|
173
|
+
// may not capture the animated width as the transition's "from"
|
|
174
|
+
// value when both the animation removal and transition are
|
|
175
|
+
// applied in the same render frame.
|
|
176
|
+
animation: `${FINISH_KEYFRAMES} 400ms ease forwards`,
|
|
172
177
|
}),
|
|
173
178
|
...(shadow
|
|
174
179
|
? {
|
|
@@ -177,12 +182,10 @@ export function TopLoader({ config }: { config?: TopLoaderConfig }): React.React
|
|
|
177
182
|
: {}),
|
|
178
183
|
};
|
|
179
184
|
|
|
180
|
-
// Clean up the finishing phase when the
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
? (e: React.TransitionEvent) => {
|
|
185
|
-
if (e.propertyName === 'opacity') {
|
|
185
|
+
// Clean up the finishing phase when the finish animation completes.
|
|
186
|
+
const handleAnimationEnd = phase === 'finishing'
|
|
187
|
+
? (e: React.AnimationEvent) => {
|
|
188
|
+
if (e.animationName === FINISH_KEYFRAMES) {
|
|
186
189
|
setPhase('hidden');
|
|
187
190
|
}
|
|
188
191
|
}
|
|
@@ -195,6 +198,6 @@ export function TopLoader({ config }: { config?: TopLoaderConfig }): React.React
|
|
|
195
198
|
'aria-hidden': 'true',
|
|
196
199
|
'data-timber-top-loader': '',
|
|
197
200
|
},
|
|
198
|
-
createElement('div', { style: barStyle,
|
|
201
|
+
createElement('div', { style: barStyle, onAnimationEnd: handleAnimationEnd })
|
|
199
202
|
);
|
|
200
203
|
}
|
package/src/plugins/chunks.ts
CHANGED
|
@@ -41,7 +41,15 @@ export function isTimberRuntime(id: string): boolean {
|
|
|
41
41
|
*
|
|
42
42
|
* Currently a no-op — Rolldown's default splitting produces the desired
|
|
43
43
|
* output (one main bundle + per-route chunks). The plugin is retained as
|
|
44
|
-
* a hook point for future chunking adjustments
|
|
44
|
+
* a hook point for future chunking adjustments.
|
|
45
|
+
*
|
|
46
|
+
* NOTE: Ideally we'd use Rolldown's `format: 'app'` (module registry format)
|
|
47
|
+
* which guarantees single-copy module semantics like webpack/Turbopack.
|
|
48
|
+
* However, `format: 'app'` is not yet implemented in Rolldown rc.10.
|
|
49
|
+
* Until it ships, singleton state in navigation-context.ts uses
|
|
50
|
+
* globalThis + Symbol.for as a cross-chunk safety net.
|
|
51
|
+
*
|
|
52
|
+
* Design docs: 27-chunking-strategy.md
|
|
45
53
|
*/
|
|
46
54
|
export function timberChunks(): Plugin {
|
|
47
55
|
return {
|
|
@@ -1,32 +0,0 @@
|
|
|
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
|
-
import type { RouterInstance } from '@timber-js/app/client';
|
|
12
|
-
/**
|
|
13
|
-
* Handle click events on timber links. Intercepts clicks on <a> elements
|
|
14
|
-
* marked with data-timber-link and triggers SPA navigation instead of
|
|
15
|
-
* a full page load.
|
|
16
|
-
*
|
|
17
|
-
* Passes through to default browser behavior when:
|
|
18
|
-
* - Modified keys are held (Ctrl, Meta, Shift, Alt) — open in new tab
|
|
19
|
-
* - The click is not the primary button
|
|
20
|
-
* - The link has a target attribute (e.g., target="_blank")
|
|
21
|
-
* - The link has a download attribute
|
|
22
|
-
*/
|
|
23
|
-
export declare function handleLinkClick(event: MouseEvent, router: RouterInstance): void;
|
|
24
|
-
/**
|
|
25
|
-
* Handle mouseenter events on prefetch-enabled links. When the user
|
|
26
|
-
* hovers over <a data-timber-prefetch>, the RSC payload is fetched
|
|
27
|
-
* and cached for near-instant navigation.
|
|
28
|
-
*
|
|
29
|
-
* See design/19-client-navigation.md §"Prefetch Cache"
|
|
30
|
-
*/
|
|
31
|
-
export declare function handleLinkHover(event: MouseEvent, router: RouterInstance): void;
|
|
32
|
-
//# sourceMappingURL=browser-links.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"browser-links.d.ts","sourceRoot":"","sources":["../../src/client/browser-links.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAK5D;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,cAAc,GAAG,IAAI,CAyC/E;AAID;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,cAAc,GAAG,IAAI,CAU/E"}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { type ReactNode } from 'react';
|
|
2
|
-
/** Symbol used to store the onNavigate callback on anchor elements. */
|
|
3
|
-
export declare const ON_NAVIGATE_KEY: "__timberOnNavigate";
|
|
4
|
-
export type OnNavigateEvent = {
|
|
5
|
-
preventDefault: () => void;
|
|
6
|
-
};
|
|
7
|
-
export type OnNavigateHandler = (e: OnNavigateEvent) => void;
|
|
8
|
-
/**
|
|
9
|
-
* Augment HTMLAnchorElement with the optional onNavigate property.
|
|
10
|
-
* Used by browser-entry.ts handleLinkClick to check for the callback.
|
|
11
|
-
*/
|
|
12
|
-
declare global {
|
|
13
|
-
interface HTMLAnchorElement {
|
|
14
|
-
[ON_NAVIGATE_KEY]?: OnNavigateHandler;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* Client component rendered inside <Link> that attaches the onNavigate
|
|
19
|
-
* callback to the closest <a> ancestor via a DOM property. The callback
|
|
20
|
-
* is cleaned up on unmount.
|
|
21
|
-
*
|
|
22
|
-
* Renders no extra DOM — just a transparent wrapper.
|
|
23
|
-
*/
|
|
24
|
-
export declare function LinkNavigateInterceptor({ onNavigate, children, }: {
|
|
25
|
-
onNavigate: OnNavigateHandler;
|
|
26
|
-
children: ReactNode;
|
|
27
|
-
}): import("react/jsx-runtime").JSX.Element;
|
|
28
|
-
//# sourceMappingURL=link-navigate-interceptor.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"link-navigate-interceptor.d.ts","sourceRoot":"","sources":["../../src/client/link-navigate-interceptor.tsx"],"names":[],"mappings":"AAQA,OAAO,EAAqB,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAE1D,uEAAuE;AACvE,eAAO,MAAM,eAAe,EAAG,oBAA6B,CAAC;AAE7D,MAAM,MAAM,eAAe,GAAG;IAC5B,cAAc,EAAE,MAAM,IAAI,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,EAAE,eAAe,KAAK,IAAI,CAAC;AAE7D;;;GAGG;AACH,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,iBAAiB;QACzB,CAAC,eAAe,CAAC,CAAC,EAAE,iBAAiB,CAAC;KACvC;CACF;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,EACtC,UAAU,EACV,QAAQ,GACT,EAAE;IACD,UAAU,EAAE,iBAAiB,CAAC;IAC9B,QAAQ,EAAE,SAAS,CAAC;CACrB,2CAmBA"}
|