@timber-js/app 0.2.0-alpha.1 → 0.2.0-alpha.2

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.
@@ -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 if needed.
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;;;;;;GAMG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAIrC"}
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"}
@@ -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 | "false" | "true") | "inherit" | "plaintext-only" | undefined;
54
+ contentEditable?: (boolean | "true" | "false") | "inherit" | "plaintext-only" | undefined;
55
55
  contextMenu?: string | undefined;
56
56
  dir?: string | undefined;
57
- draggable?: (boolean | "false" | "true") | undefined;
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 | "false" | "true") | undefined;
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 | "false" | "true") | undefined;
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 | "false" | "true") | undefined;
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 | "false" | "true") | undefined;
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 | "false" | "true") | undefined;
120
+ "aria-expanded"?: (boolean | "true" | "false") | undefined;
121
121
  "aria-flowto"?: string | undefined;
122
- "aria-grabbed"?: (boolean | "false" | "true") | undefined;
122
+ "aria-grabbed"?: (boolean | "true" | "false") | undefined;
123
123
  "aria-haspopup"?: boolean | "false" | "true" | "menu" | "listbox" | "tree" | "grid" | "dialog" | undefined;
124
- "aria-hidden"?: (boolean | "false" | "true") | undefined;
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 | "false" | "true") | undefined;
132
- "aria-multiline"?: (boolean | "false" | "true") | undefined;
133
- "aria-multiselectable"?: (boolean | "false" | "true") | undefined;
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 | "false" | "true") | undefined;
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 | "false" | "true") | undefined;
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 | "false" | "true") | undefined;
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.1",
3
+ "version": "0.2.0-alpha.2",
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
- * - Intercepts clicks on <a data-timber-link> for SPA navigation
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
- import { handleLinkClick, handleLinkHover } from './browser-links.js';
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
- // Delegate click events on <a data-timber-link> for SPA navigation.
597
- // Uses event delegation on document for efficiency — no per-link listeners.
598
- document.addEventListener('click', (event: MouseEvent) => {
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"
@@ -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-navigate-interceptor';
9
+ export type { OnNavigateHandler, OnNavigateEvent } from './link';
10
10
  export { createRouter } from './router';
11
11
  export type {
12
12
  RouterInstance,
@@ -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 client runtime intercepts clicks on links
8
- // marked with data-timber-link, fetches RSC payloads, and reconciles the DOM.
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
- 'href': string;
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' | 'prefetch' | 'scroll'> & {
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
- const output: LinkOutputProps = { href: resolvedHref };
259
- const internal = isInternalHref(resolvedHref);
265
+ // ─── Click Handler ───────────────────────────────────────────────
260
266
 
261
- if (internal) {
262
- output['data-timber-link'] = true;
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
- if (props.prefetch) {
265
- output['data-timber-prefetch'] = true;
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
- if (props.scroll === false) {
269
- output['data-timber-scroll'] = 'false';
270
- }
271
- }
290
+ if (!isInternalHref(resolvedHref)) return false;
272
291
 
273
- return output;
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, it intercepts clicks on links marked with
283
- * `data-timber-link` to perform RSC-based client navigation.
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 linkProps = buildLinkProps({ href, prefetch, scroll, params, searchParams });
321
+ const { href: resolvedHref } = buildLinkProps({ href, params, searchParams });
322
+ const internal = isInternalHref(resolvedHref);
300
323
 
301
- const inner = <LinkStatusProvider href={linkProps.href}>{children}</LinkStatusProvider>;
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} {...linkProps}>
305
- {onNavigate ? (
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
- * Singleton guarantee: With the simplified chunking strategy (LOCAL-337),
29
- * this module lives in exactly one client chunk. The previous globalThis +
30
- * Symbol.for workaround for cross-chunk duplication is no longer needed.
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
- let _navCtx: React.Context<NavigationState | null> | undefined;
58
- let _pendingNavCtx: React.Context<string | null> | undefined;
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
- if (_navCtx !== undefined) return _navCtx;
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
- _navCtx = React.createContext<NavigationState | null>(null);
65
- return _navCtx;
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
- let _navState: NavigationState = { params: {}, pathname: '/' };
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
- _navState = state;
158
+ _getNavStateStore().current = state;
127
159
  }
128
160
 
129
161
  export function getNavigationState(): NavigationState {
130
- return _navState;
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
- if (_pendingNavCtx !== undefined) return _pendingNavCtx;
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
- _pendingNavCtx = React.createContext<string | null>(null);
147
- return _pendingNavCtx;
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
  }
@@ -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 if needed.
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"}