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

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.
Files changed (142) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/{als-registry-k-AtAQ9R.js → als-registry-B7DbZ2hS.js} +1 -1
  3. package/dist/_chunks/{als-registry-k-AtAQ9R.js.map → als-registry-B7DbZ2hS.js.map} +1 -1
  4. package/dist/_chunks/debug-gwlJkDuf.js +108 -0
  5. package/dist/_chunks/debug-gwlJkDuf.js.map +1 -0
  6. package/dist/_chunks/{format-DNt20Kt8.js → format-DviM89f0.js} +3 -2
  7. package/dist/_chunks/format-DviM89f0.js.map +1 -0
  8. package/dist/_chunks/{interception-DGDIjDbR.js → interception-BOoWmLUA.js} +2 -2
  9. package/dist/_chunks/{interception-DGDIjDbR.js.map → interception-BOoWmLUA.js.map} +1 -1
  10. package/dist/_chunks/{metadata-routes-CQCnF4VK.js → metadata-routes-Cjmvi3rQ.js} +1 -1
  11. package/dist/_chunks/{metadata-routes-CQCnF4VK.js.map → metadata-routes-Cjmvi3rQ.js.map} +1 -1
  12. package/dist/_chunks/{request-context-CRj2Zh1E.js → request-context-DIkVh_jG.js} +5 -4
  13. package/dist/_chunks/request-context-DIkVh_jG.js.map +1 -0
  14. package/dist/_chunks/{ssr-data-DLnbYpj1.js → ssr-data-MjmprTmO.js} +1 -1
  15. package/dist/_chunks/{ssr-data-DLnbYpj1.js.map → ssr-data-MjmprTmO.js.map} +1 -1
  16. package/dist/_chunks/{tracing-DF0G3FB7.js → tracing-CemImE6h.js} +17 -3
  17. package/dist/_chunks/{tracing-DF0G3FB7.js.map → tracing-CemImE6h.js.map} +1 -1
  18. package/dist/_chunks/{use-cookie-dDbpCTx-.js → use-cookie-DX-l1_5E.js} +2 -2
  19. package/dist/_chunks/{use-cookie-dDbpCTx-.js.map → use-cookie-DX-l1_5E.js.map} +1 -1
  20. package/dist/_chunks/{use-query-states-DAhgj8Gx.js → use-query-states-D5KaffOK.js} +1 -1
  21. package/dist/_chunks/{use-query-states-DAhgj8Gx.js.map → use-query-states-D5KaffOK.js.map} +1 -1
  22. package/dist/adapters/nitro.d.ts.map +1 -1
  23. package/dist/adapters/nitro.js.map +1 -1
  24. package/dist/cache/fast-hash.d.ts +22 -0
  25. package/dist/cache/fast-hash.d.ts.map +1 -0
  26. package/dist/cache/index.js +52 -10
  27. package/dist/cache/index.js.map +1 -1
  28. package/dist/cache/register-cached-function.d.ts.map +1 -1
  29. package/dist/cache/timber-cache.d.ts.map +1 -1
  30. package/dist/client/browser-entry.d.ts +1 -2
  31. package/dist/client/browser-entry.d.ts.map +1 -1
  32. package/dist/client/error-boundary.js +1 -1
  33. package/dist/client/index.d.ts +1 -1
  34. package/dist/client/index.d.ts.map +1 -1
  35. package/dist/client/index.js +136 -94
  36. package/dist/client/index.js.map +1 -1
  37. package/dist/client/link.d.ts +12 -9
  38. package/dist/client/link.d.ts.map +1 -1
  39. package/dist/client/navigation-context.d.ts +9 -3
  40. package/dist/client/navigation-context.d.ts.map +1 -1
  41. package/dist/client/router.d.ts.map +1 -1
  42. package/dist/client/segment-context.d.ts +1 -1
  43. package/dist/client/segment-context.d.ts.map +1 -1
  44. package/dist/client/segment-merger.d.ts.map +1 -1
  45. package/dist/client/stale-reload.d.ts.map +1 -1
  46. package/dist/client/top-loader.d.ts.map +1 -1
  47. package/dist/client/transition-root.d.ts +1 -1
  48. package/dist/client/transition-root.d.ts.map +1 -1
  49. package/dist/cookies/index.js +4 -4
  50. package/dist/fonts/local.d.ts +4 -2
  51. package/dist/fonts/local.d.ts.map +1 -1
  52. package/dist/index.d.ts +38 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +255 -21
  55. package/dist/index.js.map +1 -1
  56. package/dist/plugins/build-report.d.ts +11 -1
  57. package/dist/plugins/build-report.d.ts.map +1 -1
  58. package/dist/plugins/chunks.d.ts +9 -1
  59. package/dist/plugins/chunks.d.ts.map +1 -1
  60. package/dist/plugins/entries.d.ts +7 -0
  61. package/dist/plugins/entries.d.ts.map +1 -1
  62. package/dist/plugins/fonts.d.ts +9 -1
  63. package/dist/plugins/fonts.d.ts.map +1 -1
  64. package/dist/plugins/mdx.d.ts +6 -0
  65. package/dist/plugins/mdx.d.ts.map +1 -1
  66. package/dist/plugins/server-bundle.d.ts.map +1 -1
  67. package/dist/routing/index.js +1 -1
  68. package/dist/search-params/index.js +1 -1
  69. package/dist/server/access-gate.d.ts.map +1 -1
  70. package/dist/server/action-client.d.ts.map +1 -1
  71. package/dist/server/debug.d.ts +82 -0
  72. package/dist/server/debug.d.ts.map +1 -0
  73. package/dist/server/deny-renderer.d.ts.map +1 -1
  74. package/dist/server/dev-warnings.d.ts.map +1 -1
  75. package/dist/server/index.js +22 -13
  76. package/dist/server/index.js.map +1 -1
  77. package/dist/server/logger.d.ts.map +1 -1
  78. package/dist/server/pipeline.d.ts.map +1 -1
  79. package/dist/server/primitives.d.ts.map +1 -1
  80. package/dist/server/request-context.d.ts.map +1 -1
  81. package/dist/server/response-cache.d.ts +54 -0
  82. package/dist/server/response-cache.d.ts.map +1 -0
  83. package/dist/server/route-element-builder.d.ts.map +1 -1
  84. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  85. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  86. package/dist/server/rsc-entry/rsc-stream.d.ts +6 -0
  87. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  88. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  89. package/dist/server/rsc-prop-warnings.d.ts.map +1 -1
  90. package/dist/server/tracing.d.ts +10 -0
  91. package/dist/server/tracing.d.ts.map +1 -1
  92. package/dist/server/waituntil-bridge.d.ts.map +1 -1
  93. package/package.json +6 -7
  94. package/src/adapters/nitro.ts +6 -1
  95. package/src/cache/fast-hash.ts +34 -0
  96. package/src/cache/register-cached-function.ts +7 -3
  97. package/src/cache/timber-cache.ts +17 -10
  98. package/src/cli.ts +0 -0
  99. package/src/client/browser-entry.ts +16 -24
  100. package/src/client/index.ts +1 -1
  101. package/src/client/link.tsx +97 -34
  102. package/src/client/navigation-context.ts +54 -14
  103. package/src/client/router.ts +4 -6
  104. package/src/client/segment-context.ts +6 -1
  105. package/src/client/segment-merger.ts +2 -8
  106. package/src/client/stale-reload.ts +5 -7
  107. package/src/client/top-loader.tsx +23 -19
  108. package/src/client/transition-root.tsx +7 -1
  109. package/src/fonts/local.ts +7 -3
  110. package/src/index.ts +40 -0
  111. package/src/plugins/build-report.ts +23 -3
  112. package/src/plugins/chunks.ts +9 -1
  113. package/src/plugins/entries.ts +9 -4
  114. package/src/plugins/fonts.ts +171 -19
  115. package/src/plugins/mdx.ts +9 -5
  116. package/src/plugins/server-bundle.ts +4 -0
  117. package/src/server/access-gate.tsx +3 -2
  118. package/src/server/action-client.ts +15 -5
  119. package/src/server/debug.ts +137 -0
  120. package/src/server/deny-renderer.ts +3 -2
  121. package/src/server/dev-warnings.ts +2 -1
  122. package/src/server/logger.ts +4 -3
  123. package/src/server/pipeline.ts +7 -0
  124. package/src/server/primitives.ts +2 -1
  125. package/src/server/request-context.ts +3 -2
  126. package/src/server/response-cache.ts +410 -0
  127. package/src/server/route-element-builder.ts +1 -6
  128. package/src/server/rsc-entry/index.ts +61 -14
  129. package/src/server/rsc-entry/rsc-payload.ts +42 -7
  130. package/src/server/rsc-entry/rsc-stream.ts +10 -5
  131. package/src/server/rsc-entry/ssr-renderer.ts +12 -5
  132. package/src/server/rsc-prop-warnings.ts +3 -1
  133. package/src/server/tracing.ts +23 -0
  134. package/src/server/waituntil-bridge.ts +4 -1
  135. package/dist/_chunks/format-DNt20Kt8.js.map +0 -1
  136. package/dist/_chunks/request-context-CRj2Zh1E.js.map +0 -1
  137. package/dist/client/browser-links.d.ts +0 -32
  138. package/dist/client/browser-links.d.ts.map +0 -1
  139. package/dist/client/link-navigate-interceptor.d.ts +0 -28
  140. package/dist/client/link-navigate-interceptor.d.ts.map +0 -1
  141. package/src/client/browser-links.ts +0 -90
  142. package/src/client/link-navigate-interceptor.tsx +0 -62
@@ -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,63 @@ 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({
339
+ preventDefault: () => {
340
+ prevented = true;
341
+ },
342
+ });
343
+ if (prevented) {
344
+ event.preventDefault();
345
+ return;
346
+ }
347
+ }
348
+
349
+ const router = getRouterOrNull();
350
+ if (!router) return; // SSR or pre-hydration — fall through to browser nav
351
+
352
+ event.preventDefault();
353
+ const shouldScroll = scroll !== false;
354
+ void router.navigate(resolvedHref, { scroll: shouldScroll });
355
+ }
356
+ : userOnClick; // External links — just pass through user's onClick
357
+
358
+ // ─── Hover prefetch ──────────────────────────────────────────
359
+ const handleMouseEnter =
360
+ internal && prefetch
361
+ ? (event: ReactMouseEvent<HTMLAnchorElement>) => {
362
+ userOnMouseEnter?.(event);
363
+ const router = getRouterOrNull();
364
+ if (router) {
365
+ router.prefetch(resolvedHref);
366
+ }
367
+ }
368
+ : userOnMouseEnter;
302
369
 
303
370
  return (
304
- <a {...rest} {...linkProps}>
305
- {onNavigate ? (
306
- <LinkNavigateInterceptor onNavigate={onNavigate}>{inner}</LinkNavigateInterceptor>
307
- ) : (
308
- inner
309
- )}
371
+ <a {...rest} href={resolvedHref} onClick={handleClick} onMouseEnter={handleMouseEnter}>
372
+ <LinkStatusProvider href={resolvedHref}>{children}</LinkStatusProvider>
310
373
  </a>
311
374
  );
312
375
  }
@@ -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
  }
@@ -7,11 +7,7 @@ import { HistoryStack } from './history';
7
7
  import type { HeadElement } from './head';
8
8
  import { setCurrentParams } from './use-params.js';
9
9
  import { setNavigationState } from './navigation-context.js';
10
- import {
11
- SegmentElementCache,
12
- cacheSegmentElements,
13
- mergeSegmentTree,
14
- } from './segment-merger.js';
10
+ import { SegmentElementCache, cacheSegmentElements, mergeSegmentTree } from './segment-merger.js';
15
11
  import { fetchRscPayload, RedirectError } from './rsc-fetch.js';
16
12
  import type { FetchResult } from './rsc-fetch.js';
17
13
 
@@ -421,7 +417,9 @@ export function createRouter(deps: RouterDeps): RouterInstance {
421
417
  setPending(true, url);
422
418
  try {
423
419
  const headElements = await renderViaTransition(url, async () => {
424
- const stateTree = segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths());
420
+ const stateTree = segmentCache.serializeStateTree(
421
+ segmentElementCache.getMergeablePaths()
422
+ );
425
423
  const result = await fetchRscPayload(url, deps, stateTree);
426
424
  updateSegmentCache(result.segmentInfo);
427
425
  updateNavigationState(result.params, url);
@@ -52,7 +52,12 @@ interface SegmentProviderProps {
52
52
  * Wraps each layout to provide segment position context.
53
53
  * Injected by rsc-entry.ts during element tree construction.
54
54
  */
55
- export function SegmentProvider({ segments, segmentId: _segmentId, parallelRouteKeys, children }: SegmentProviderProps) {
55
+ export function SegmentProvider({
56
+ segments,
57
+ segmentId: _segmentId,
58
+ parallelRouteKeys,
59
+ children,
60
+ }: SegmentProviderProps) {
56
61
  const value = useMemo(
57
62
  () => ({ segments, parallelRouteKeys }),
58
63
  // segments and parallelRouteKeys are static per layout — they don't change
@@ -186,10 +186,7 @@ function walkChildren(children: ReactNode, out: CachedSegmentEntry[]): void {
186
186
  * Cache all segment subtrees from a fully-rendered RSC element tree.
187
187
  * Call this after every full RSC payload render (navigate, refresh, hydration).
188
188
  */
189
- export function cacheSegmentElements(
190
- element: unknown,
191
- cache: SegmentElementCache
192
- ): void {
189
+ export function cacheSegmentElements(element: unknown, cache: SegmentElementCache): void {
193
190
  const segments = extractSegments(element);
194
191
  for (const entry of segments) {
195
192
  cache.set(entry.segmentPath, entry);
@@ -208,10 +205,7 @@ export function cacheSegmentElements(
208
205
  */
209
206
  type TreePath = Array<{ element: ReactElement; childIndex: number }>;
210
207
 
211
- function findSegmentProviderPath(
212
- node: ReactElement,
213
- targetPath?: string
214
- ): TreePath | null {
208
+ function findSegmentProviderPath(node: ReactElement, targetPath?: string): TreePath | null {
215
209
  const children = (node.props as { children?: ReactNode }).children;
216
210
  if (children == null) return null;
217
211
 
@@ -29,7 +29,7 @@ const RELOAD_FLAG_KEY = '__timber_stale_reload';
29
29
  export function isStaleClientReference(error: unknown): boolean {
30
30
  if (!(error instanceof Error)) return false;
31
31
  const msg = error.message;
32
- return msg.includes('Could not find the module');
32
+ return msg.includes('Could not find the module') || msg.includes('client reference not found');
33
33
  }
34
34
 
35
35
  /**
@@ -48,8 +48,8 @@ export function triggerStaleReload(): boolean {
48
48
  if (sessionStorage.getItem(RELOAD_FLAG_KEY)) {
49
49
  console.warn(
50
50
  '[timber] Stale client reference detected again after reload. ' +
51
- 'Not reloading to prevent infinite loop. ' +
52
- 'This may indicate a deployment issue — try a hard refresh.'
51
+ 'Not reloading to prevent infinite loop. ' +
52
+ 'This may indicate a deployment issue — try a hard refresh.'
53
53
  );
54
54
  return false;
55
55
  }
@@ -59,7 +59,7 @@ export function triggerStaleReload(): boolean {
59
59
 
60
60
  console.warn(
61
61
  '[timber] Stale client reference detected — the server has been ' +
62
- 'redeployed with new bundles. Reloading to pick up the new version.'
62
+ 'redeployed with new bundles. Reloading to pick up the new version.'
63
63
  );
64
64
 
65
65
  window.location.reload();
@@ -67,9 +67,7 @@ export function triggerStaleReload(): boolean {
67
67
  } catch {
68
68
  // sessionStorage may be unavailable (private browsing, storage full, etc.)
69
69
  // Fall back to reloading without loop protection
70
- console.warn(
71
- '[timber] Stale client reference detected. Reloading page.'
72
- );
70
+ console.warn('[timber] Stale client reference detected. Reloading page.');
73
71
  window.location.reload();
74
72
  return true;
75
73
  }
@@ -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: snap to 100% width (200ms), THEN fade out (200ms).
165
- // The opacity transition is delayed so the user sees the bar
166
- // reach 100% before it disappears. Without the delay, both
167
- // transitions run simultaneously and the bar fades before the
168
- // fill animation is visible.
169
- width: '100%',
170
- opacity: 0,
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,24 +182,23 @@ export function TopLoader({ config }: { config?: TopLoaderConfig }): React.React
177
182
  : {}),
178
183
  };
179
184
 
180
- // Clean up the finishing phase when the CSS transition completes.
181
- // onTransitionEnd fires once per transitioned property — we act on
182
- // the first one (opacity) and ignore subsequent (width).
183
- const handleTransitionEnd = phase === 'finishing'
184
- ? (e: React.TransitionEvent) => {
185
- if (e.propertyName === 'opacity') {
186
- setPhase('hidden');
185
+ // Clean up the finishing phase when the finish animation completes.
186
+ const handleAnimationEnd =
187
+ phase === 'finishing'
188
+ ? (e: React.AnimationEvent) => {
189
+ if (e.animationName === FINISH_KEYFRAMES) {
190
+ setPhase('hidden');
191
+ }
187
192
  }
188
- }
189
- : undefined;
193
+ : undefined;
190
194
 
191
195
  return createElement(
192
196
  'div',
193
197
  {
194
- style: containerStyle,
198
+ 'style': containerStyle,
195
199
  'aria-hidden': 'true',
196
200
  'data-timber-top-loader': '',
197
201
  },
198
- createElement('div', { style: barStyle, onTransitionEnd: handleTransitionEnd })
202
+ createElement('div', { style: barStyle, onAnimationEnd: handleAnimationEnd })
199
203
  );
200
204
  }
@@ -62,7 +62,13 @@ let _navigateTransition:
62
62
  * Non-navigation renders:
63
63
  * transitionRender(newWrappedElement);
64
64
  */
65
- export function TransitionRoot({ initial, topLoaderConfig }: { initial: ReactNode; topLoaderConfig?: TopLoaderConfig }): ReactNode {
65
+ export function TransitionRoot({
66
+ initial,
67
+ topLoaderConfig,
68
+ }: {
69
+ initial: ReactNode;
70
+ topLoaderConfig?: TopLoaderConfig;
71
+ }): ReactNode {
66
72
  const [element, setElement] = useState<ReactNode>(initial);
67
73
  const [pendingUrl, setPendingUrl] = useState<string | null>(null);
68
74
  const [, startTransition] = useTransition();
@@ -100,18 +100,22 @@ export function generateFamilyName(sources: LocalFontSrc[]): string {
100
100
  * Generate @font-face descriptors for local font sources.
101
101
  *
102
102
  * Each source entry produces one @font-face rule. The `src` descriptor
103
- * uses a `url()` pointing to the resolved file path with the inferred format.
103
+ * uses a `url()` pointing to the served path under `/_timber/fonts/`.
104
+ * The `urlPrefix` defaults to `/_timber/fonts` — the path used by both
105
+ * the dev server middleware and the production build output.
104
106
  */
105
107
  export function generateLocalFontFaces(
106
108
  family: string,
107
109
  sources: LocalFontSrc[],
108
- display: string
110
+ display: string,
111
+ urlPrefix = '/_timber/fonts'
109
112
  ): FontFaceDescriptor[] {
110
113
  return sources.map((entry) => {
111
114
  const format = inferFontFormat(entry.path);
115
+ const basename = entry.path.split('/').pop() ?? entry.path;
112
116
  return {
113
117
  family,
114
- src: `url('${entry.path}') format('${format}')`,
118
+ src: `url('${urlPrefix}/${basename}') format('${format}')`,
115
119
  weight: entry.weight,
116
120
  style: entry.style,
117
121
  display,
package/src/index.ts CHANGED
@@ -46,6 +46,18 @@ export interface ResolvedClientJavascript {
46
46
 
47
47
  export interface TimberUserConfig {
48
48
  output?: 'server' | 'static';
49
+ /**
50
+ * Enable timber debug logging in production builds.
51
+ *
52
+ * When `true`, timber's own diagnostics (dev warnings, verbose logging)
53
+ * are active even in production mode. React stays in production mode —
54
+ * only timber's logs are affected.
55
+ *
56
+ * Can also be enabled at runtime via the `TIMBER_DEBUG` environment variable.
57
+ *
58
+ * Default: `false`.
59
+ */
60
+ debug?: boolean;
49
61
  /**
50
62
  * Control client-side JavaScript output.
51
63
  *
@@ -134,6 +146,34 @@ export interface TimberUserConfig {
134
146
  /** CSS z-index. Default: 1600. */
135
147
  zIndex?: number;
136
148
  };
149
+ /**
150
+ * Response-level caching and deduplication.
151
+ *
152
+ * When enabled, concurrent requests to the same URL share a single render
153
+ * (singleflight), and recently rendered responses are reused from a short-TTL
154
+ * LRU cache without re-executing the RSC-to-SSR pipeline.
155
+ *
156
+ * Set to `false` to disable entirely. Default: enabled with sensible defaults.
157
+ *
158
+ * See design/31-benchmarking.md for performance context.
159
+ */
160
+ responseCache?:
161
+ | false
162
+ | {
163
+ /** Maximum number of entries in the LRU cache. Default: 150. */
164
+ maxSize?: number;
165
+ /** TTL for cached entries in milliseconds. Default: 5000 (5s). */
166
+ ttlMs?: number;
167
+ /**
168
+ * When true (default), requests with Cookie or Authorization headers
169
+ * bypass the cache entirely. This prevents sharing user-specific
170
+ * responses across requests.
171
+ *
172
+ * Set to false to cache all responses regardless of auth state.
173
+ * Only do this if your pages are truly public and don't vary by user.
174
+ */
175
+ publicOnly?: boolean;
176
+ };
137
177
  }
138
178
 
139
179
  /**
@@ -80,7 +80,17 @@ interface RouteInfo {
80
80
  entryFilePath: string | null;
81
81
  }
82
82
 
83
- /** Walk the route tree and collect all leaf routes (pages + API endpoints). */
83
+ /**
84
+ * Walk the route tree and collect all leaf routes (pages + API endpoints).
85
+ *
86
+ * Parallel slots (`@artists`, `@shows`, etc.) are intentionally skipped —
87
+ * they render alongside the parent page at the same URL and are not
88
+ * separately URL-addressable. Their JS is captured in shared/layout chunks.
89
+ *
90
+ * After collection, entries are deduplicated by URL path so that overlapping
91
+ * route groups (e.g. `(browse)` and `(marketing)` both producing `/`) only
92
+ * appear once. The entry with the largest route-specific size wins.
93
+ */
84
94
  export function collectRoutes(tree: RouteTree): RouteInfo[] {
85
95
  const routes: RouteInfo[] = [];
86
96
 
@@ -95,12 +105,22 @@ export function collectRoutes(tree: RouteTree): RouteInfo[] {
95
105
  routes.push({ path, segments: currentChain, entryFilePath: node.route.filePath });
96
106
  }
97
107
 
108
+ // Recurse into child segments only — skip parallel slots (node.slots)
98
109
  for (const child of node.children) walk(child, currentChain);
99
- for (const slot of node.slots.values()) walk(slot, currentChain);
100
110
  }
101
111
 
102
112
  walk(tree.root, []);
103
- return routes;
113
+
114
+ // Deduplicate entries with the same URL path (e.g. from overlapping route groups).
115
+ // Keep the entry with the longest segment chain (most specific match).
116
+ const seen = new Map<string, RouteInfo>();
117
+ for (const route of routes) {
118
+ const existing = seen.get(route.path);
119
+ if (!existing || route.segments.length > existing.segments.length) {
120
+ seen.set(route.path, route);
121
+ }
122
+ }
123
+ return Array.from(seen.values());
104
124
  }
105
125
 
106
126
  // ─── Report formatting ────────────────────────────────────────────────────
@@ -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 {