@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.
- package/LICENSE +8 -0
- package/dist/_chunks/{als-registry-k-AtAQ9R.js → als-registry-B7DbZ2hS.js} +1 -1
- package/dist/_chunks/{als-registry-k-AtAQ9R.js.map → als-registry-B7DbZ2hS.js.map} +1 -1
- package/dist/_chunks/debug-gwlJkDuf.js +108 -0
- package/dist/_chunks/debug-gwlJkDuf.js.map +1 -0
- package/dist/_chunks/{format-DNt20Kt8.js → format-DviM89f0.js} +3 -2
- package/dist/_chunks/format-DviM89f0.js.map +1 -0
- package/dist/_chunks/{interception-DGDIjDbR.js → interception-BOoWmLUA.js} +2 -2
- package/dist/_chunks/{interception-DGDIjDbR.js.map → interception-BOoWmLUA.js.map} +1 -1
- package/dist/_chunks/{metadata-routes-CQCnF4VK.js → metadata-routes-Cjmvi3rQ.js} +1 -1
- package/dist/_chunks/{metadata-routes-CQCnF4VK.js.map → metadata-routes-Cjmvi3rQ.js.map} +1 -1
- package/dist/_chunks/{request-context-CRj2Zh1E.js → request-context-DIkVh_jG.js} +5 -4
- package/dist/_chunks/request-context-DIkVh_jG.js.map +1 -0
- package/dist/_chunks/{ssr-data-DLnbYpj1.js → ssr-data-MjmprTmO.js} +1 -1
- package/dist/_chunks/{ssr-data-DLnbYpj1.js.map → ssr-data-MjmprTmO.js.map} +1 -1
- package/dist/_chunks/{tracing-DF0G3FB7.js → tracing-CemImE6h.js} +17 -3
- package/dist/_chunks/{tracing-DF0G3FB7.js.map → tracing-CemImE6h.js.map} +1 -1
- package/dist/_chunks/{use-cookie-dDbpCTx-.js → use-cookie-DX-l1_5E.js} +2 -2
- package/dist/_chunks/{use-cookie-dDbpCTx-.js.map → use-cookie-DX-l1_5E.js.map} +1 -1
- package/dist/_chunks/{use-query-states-DAhgj8Gx.js → use-query-states-D5KaffOK.js} +1 -1
- package/dist/_chunks/{use-query-states-DAhgj8Gx.js.map → use-query-states-D5KaffOK.js.map} +1 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/fast-hash.d.ts +22 -0
- package/dist/cache/fast-hash.d.ts.map +1 -0
- package/dist/cache/index.js +52 -10
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/register-cached-function.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/client/browser-entry.d.ts +1 -2
- package/dist/client/browser-entry.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +136 -94
- 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/router.d.ts.map +1 -1
- package/dist/client/segment-context.d.ts +1 -1
- package/dist/client/segment-context.d.ts.map +1 -1
- package/dist/client/segment-merger.d.ts.map +1 -1
- package/dist/client/stale-reload.d.ts.map +1 -1
- package/dist/client/top-loader.d.ts.map +1 -1
- package/dist/client/transition-root.d.ts +1 -1
- package/dist/client/transition-root.d.ts.map +1 -1
- package/dist/cookies/index.js +4 -4
- package/dist/fonts/local.d.ts +4 -2
- package/dist/fonts/local.d.ts.map +1 -1
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +255 -21
- package/dist/index.js.map +1 -1
- package/dist/plugins/build-report.d.ts +11 -1
- package/dist/plugins/build-report.d.ts.map +1 -1
- package/dist/plugins/chunks.d.ts +9 -1
- package/dist/plugins/chunks.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts +7 -0
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts +9 -1
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/mdx.d.ts +6 -0
- package/dist/plugins/mdx.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/search-params/index.js +1 -1
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/debug.d.ts +82 -0
- package/dist/server/debug.d.ts.map +1 -0
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/dev-warnings.d.ts.map +1 -1
- package/dist/server/index.js +22 -13
- package/dist/server/index.js.map +1 -1
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/response-cache.d.ts +54 -0
- package/dist/server/response-cache.d.ts.map +1 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +6 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/rsc-prop-warnings.d.ts.map +1 -1
- package/dist/server/tracing.d.ts +10 -0
- package/dist/server/tracing.d.ts.map +1 -1
- package/dist/server/waituntil-bridge.d.ts.map +1 -1
- package/package.json +6 -7
- package/src/adapters/nitro.ts +6 -1
- package/src/cache/fast-hash.ts +34 -0
- package/src/cache/register-cached-function.ts +7 -3
- package/src/cache/timber-cache.ts +17 -10
- package/src/cli.ts +0 -0
- package/src/client/browser-entry.ts +16 -24
- package/src/client/index.ts +1 -1
- package/src/client/link.tsx +97 -34
- package/src/client/navigation-context.ts +54 -14
- package/src/client/router.ts +4 -6
- package/src/client/segment-context.ts +6 -1
- package/src/client/segment-merger.ts +2 -8
- package/src/client/stale-reload.ts +5 -7
- package/src/client/top-loader.tsx +23 -19
- package/src/client/transition-root.tsx +7 -1
- package/src/fonts/local.ts +7 -3
- package/src/index.ts +40 -0
- package/src/plugins/build-report.ts +23 -3
- package/src/plugins/chunks.ts +9 -1
- package/src/plugins/entries.ts +9 -4
- package/src/plugins/fonts.ts +171 -19
- package/src/plugins/mdx.ts +9 -5
- package/src/plugins/server-bundle.ts +4 -0
- package/src/server/access-gate.tsx +3 -2
- package/src/server/action-client.ts +15 -5
- package/src/server/debug.ts +137 -0
- package/src/server/deny-renderer.ts +3 -2
- package/src/server/dev-warnings.ts +2 -1
- package/src/server/logger.ts +4 -3
- package/src/server/pipeline.ts +7 -0
- package/src/server/primitives.ts +2 -1
- package/src/server/request-context.ts +3 -2
- package/src/server/response-cache.ts +410 -0
- package/src/server/route-element-builder.ts +1 -6
- package/src/server/rsc-entry/index.ts +61 -14
- package/src/server/rsc-entry/rsc-payload.ts +42 -7
- package/src/server/rsc-entry/rsc-stream.ts +10 -5
- package/src/server/rsc-entry/ssr-renderer.ts +12 -5
- package/src/server/rsc-prop-warnings.ts +3 -1
- package/src/server/tracing.ts +23 -0
- package/src/server/waituntil-bridge.ts +4 -1
- package/dist/_chunks/format-DNt20Kt8.js.map +0 -1
- package/dist/_chunks/request-context-CRj2Zh1E.js.map +0 -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/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,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
|
|
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({
|
|
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} {
|
|
305
|
-
{
|
|
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
|
-
*
|
|
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
|
}
|
package/src/client/router.ts
CHANGED
|
@@ -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(
|
|
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({
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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:
|
|
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,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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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,
|
|
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({
|
|
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();
|
package/src/fonts/local.ts
CHANGED
|
@@ -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
|
|
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('${
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 ────────────────────────────────────────────────────
|
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 {
|