@timber-js/app 0.1.55 → 0.1.57

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.
@@ -1,56 +1,37 @@
1
1
  /**
2
- * timber-chunks — Vite sub-plugin for intelligent client chunk splitting.
2
+ * timber-chunks — Vite sub-plugin for client chunk configuration.
3
3
  *
4
- * Splits client bundles into cache tiers based on update frequency:
4
+ * Previously implemented a 5-tier cache strategy (vendor-react, vendor-timber,
5
+ * vendor-app, shared-app, shared-client) via manualChunks. This was removed
6
+ * in LOCAL-337 because the cache-tier separation added HTTP requests,
7
+ * complicated the architecture, and caused module duplication across chunks
8
+ * that required globalThis + Symbol.for workarounds.
5
9
  *
6
- * Tier 1: vendor-react — react, react-dom, scheduler (changes rarely)
7
- * Tier 2: vendor-timber — timber runtime, RSC runtime (changes per framework update)
8
- * Tier 3: vendor-app — user node_modules (changes on dependency updates)
9
- * Tier 4: shared-app — small shared app utilities/components (< 5KB source)
10
- * Tier 5: [route]-* — per-route page/layout chunks (default Rollup splitting)
11
- *
12
- * The shared-app tier prevents tiny utility modules (constants, helpers,
13
- * small UI components) from becoming individual chunks when shared across
14
- * routes. Without this, Rolldown creates per-module chunks for any code
15
- * shared between two or more entry points, producing many sub-1KB chunks.
16
- *
17
- * Server environments (RSC, SSR) are left to Vite's default chunking since
18
- * Cloudflare Workers load all code from a single deployment bundle with no
19
- * benefit from cache-tier separation.
10
+ * Current strategy: let Rolldown handle natural code splitting.
11
+ * Output is typically:
12
+ * - 1 main bundle (React + timber runtime + shared app code + user vendors)
13
+ * - Per-route chunks (page/layout components, only when route-specific)
14
+ * - rolldown-runtime (Rolldown's own output, unavoidable)
20
15
  *
21
16
  * Design docs: 27-chunking-strategy.md
22
17
  */
23
18
  import type { Plugin } from 'vite';
24
19
  /**
25
- * Categorize a module ID into a cache tier chunk name.
26
- *
27
- * Returns a chunk name for vendor modules and small shared app code,
28
- * or undefined to let Rollup's default splitting handle route code.
29
- */
30
- export declare function assignChunk(id: string): string | undefined;
31
- /**
32
- * Group timber's internal 'use client' modules into the vendor-timber chunk.
20
+ * Check if a module is part of the timber framework runtime.
33
21
  *
34
- * The RSC plugin creates separate entry points for each 'use client' module,
35
- * which manualChunks can't merge. This function is passed as the RSC plugin's
36
- * `clientChunks` callback to group timber internals into a single chunk.
22
+ * Matches both monorepo paths (packages/timber-app/...) and consumer
23
+ * project paths (node_modules/@timber-js/app/...) so identification
24
+ * is consistent regardless of how timber is installed.
37
25
  *
38
- * User client components that are small (< 5KB) are grouped into shared-client
39
- * to prevent thin facade wrappers from becoming individual chunks. This handles
40
- * the RSC client reference facade problem where each 'use client' module gets
41
- * a ~100-300 byte re-export wrapper chunk.
26
+ * Exported for use in tests (singleton-audit, etc.).
42
27
  */
43
- export declare function assignClientChunk(meta: {
44
- id: string;
45
- normalizedId: string;
46
- serverChunk: string;
47
- }): string | undefined;
28
+ export declare function isTimberRuntime(id: string): boolean;
48
29
  /**
49
30
  * Create the timber-chunks Vite plugin.
50
31
  *
51
- * Uses Vite's per-environment config to apply manualChunks only to
52
- * the client build. The config hook runs before environments are
53
- * created, so we use `environments.client` to target the client.
32
+ * Currently a no-op — Rolldown's default splitting produces the desired
33
+ * output (one main bundle + per-route chunks). The plugin is retained as
34
+ * a hook point for future chunking adjustments if needed.
54
35
  */
55
36
  export declare function timberChunks(): Plugin;
56
37
  //# sourceMappingURL=chunks.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"chunks.d.ts","sourceRoot":"","sources":["../../src/plugins/chunks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAwGnC;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CA4B1D;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,EAAE,EAAE,MAAM,CAAC;IACX,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;CACrB,GAAG,MAAM,GAAG,SAAS,CAerB;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAoBrC"}
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 +1 @@
1
- {"version":3,"file":"entries.d.ts","sourceRoot":"","sources":["../../src/plugins/entries.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAInC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAyHhD;;;;;;;;GAQG;AACH,wBAAgB,6BAA6B,CAAC,mBAAmB,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAwBxF;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAwExD"}
1
+ {"version":3,"file":"entries.d.ts","sourceRoot":"","sources":["../../src/plugins/entries.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAInC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AA0HhD;;;;;;;;GAQG;AACH,wBAAgB,6BAA6B,CAAC,mBAAmB,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAwBxF;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAwExD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
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",
@@ -381,7 +381,7 @@ function bootstrap(runtimeConfig: typeof config): void {
381
381
  element as React.ReactNode
382
382
  );
383
383
  const wrapped = createElement(TimberNuqsAdapter, null, withNav);
384
- const rootElement = createElement(TransitionRoot, { initial: wrapped });
384
+ const rootElement = createElement(TransitionRoot, { initial: wrapped, topLoaderConfig: _config.topLoader });
385
385
  _reactRoot = hydrateRoot(document, rootElement, {
386
386
  // Suppress recoverable hydration errors from deny/error signals
387
387
  // inside Suspense boundaries. The server already handled these
@@ -25,6 +25,11 @@
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.
31
+ * See design/27-chunking-strategy.md.
32
+ *
28
33
  * See design/19-client-navigation.md §"NavigationContext"
29
34
  */
30
35
 
@@ -47,33 +52,17 @@ export interface NavigationState {
47
52
  * The context is created lazily to avoid calling createContext at module
48
53
  * level. In the RSC environment, React.createContext doesn't exist —
49
54
  * calling it at import time would crash the server.
50
- *
51
- * IMPORTANT: Context instances are stored on globalThis, NOT in module-
52
- * level variables. The RSC client bundler duplicates this module across
53
- * the browser-entry chunk (index) and client-reference chunk (shared-app)
54
- * because both entry graphs import it. Module-level variables would create
55
- * separate singleton instances per chunk — the provider in TransitionRoot
56
- * (index chunk) would use context A while the consumer in LinkStatusProvider
57
- * (shared-app chunk) reads from context B. globalThis guarantees a single
58
- * instance regardless of how many times the module is duplicated.
59
- *
60
- * See design/19-client-navigation.md §"Singleton Context Guarantee"
61
55
  */
62
56
 
63
- // Symbol keys for globalThis storage — prevents collisions with user code
64
- const NAV_CTX_KEY = Symbol.for('__timber_nav_ctx');
65
- const PENDING_CTX_KEY = Symbol.for('__timber_pending_nav_ctx');
57
+ let _navCtx: React.Context<NavigationState | null> | undefined;
58
+ let _pendingNavCtx: React.Context<string | null> | undefined;
66
59
 
67
60
  function getOrCreateContext(): React.Context<NavigationState | null> | undefined {
68
- const existing = (globalThis as Record<symbol, unknown>)[NAV_CTX_KEY] as
69
- | React.Context<NavigationState | null>
70
- | undefined;
71
- if (existing !== undefined) return existing;
61
+ if (_navCtx !== undefined) return _navCtx;
72
62
  // createContext may not exist in the RSC environment
73
63
  if (typeof React.createContext === 'function') {
74
- const ctx = React.createContext<NavigationState | null>(null);
75
- (globalThis as Record<symbol, unknown>)[NAV_CTX_KEY] = ctx;
76
- return ctx;
64
+ _navCtx = React.createContext<NavigationState | null>(null);
65
+ return _navCtx;
77
66
  }
78
67
  return undefined;
79
68
  }
@@ -130,27 +119,15 @@ export function NavigationProvider({
130
119
  * NavigationProvider with the correct params/pathname.
131
120
  *
132
121
  * This is NOT used by hooks directly — hooks read from React context.
133
- *
134
- * Stored on globalThis (like the context instances above) because the
135
- * router lives in the shared-app chunk while renderRoot lives in the
136
- * index chunk. Module-level variables would be separate per chunk.
137
122
  */
138
- const NAV_STATE_KEY = Symbol.for('__timber_nav_state');
139
-
140
- function _getNavStateStore(): { current: NavigationState } {
141
- const g = globalThis as Record<symbol, unknown>;
142
- if (!g[NAV_STATE_KEY]) {
143
- g[NAV_STATE_KEY] = { current: { params: {}, pathname: '/' } };
144
- }
145
- return g[NAV_STATE_KEY] as { current: NavigationState };
146
- }
123
+ let _navState: NavigationState = { params: {}, pathname: '/' };
147
124
 
148
125
  export function setNavigationState(state: NavigationState): void {
149
- _getNavStateStore().current = state;
126
+ _navState = state;
150
127
  }
151
128
 
152
129
  export function getNavigationState(): NavigationState {
153
- return _getNavStateStore().current;
130
+ return _navState;
154
131
  }
155
132
 
156
133
  // ---------------------------------------------------------------------------
@@ -161,21 +138,13 @@ export function getNavigationState(): NavigationState {
161
138
  * Separate context for the in-flight navigation URL. Provided by
162
139
  * TransitionRoot (urgent useState), consumed by LinkStatusProvider
163
140
  * and useNavigationPending.
164
- *
165
- * Uses globalThis via Symbol.for for the same reason as NavigationContext
166
- * above — the bundler duplicates this module across chunks, and module-
167
- * level variables would create separate context instances.
168
141
  */
169
142
 
170
143
  function getOrCreatePendingContext(): React.Context<string | null> | undefined {
171
- const existing = (globalThis as Record<symbol, unknown>)[PENDING_CTX_KEY] as
172
- | React.Context<string | null>
173
- | undefined;
174
- if (existing !== undefined) return existing;
144
+ if (_pendingNavCtx !== undefined) return _pendingNavCtx;
175
145
  if (typeof React.createContext === 'function') {
176
- const ctx = React.createContext<string | null>(null);
177
- (globalThis as Record<symbol, unknown>)[PENDING_CTX_KEY] = ctx;
178
- return ctx;
146
+ _pendingNavCtx = React.createContext<string | null>(null);
147
+ return _pendingNavCtx;
179
148
  }
180
149
  return undefined;
181
150
  }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * TopLoader — Built-in progress bar for client navigations.
3
+ *
4
+ * Shows an animated progress bar at the top of the viewport while an RSC
5
+ * navigation is in flight. Injected automatically by the framework into
6
+ * TransitionRoot — users never render this component directly.
7
+ *
8
+ * Configuration is via timber.config.ts `topLoader` key. Enabled by default.
9
+ * Users who want a fully custom progress indicator disable the built-in one
10
+ * (`topLoader: { enabled: false }`) and use `useNavigationPending()` directly.
11
+ *
12
+ * Animation approach: pure CSS @keyframes. The bar crawls from 0% to ~90%
13
+ * width over ~30s using ease-out timing. When navigation completes, the bar
14
+ * snaps to 100% and fades out over 200ms. No JS animation loops (RAF, setInterval).
15
+ *
16
+ * Phase transitions are derived synchronously during render (React's
17
+ * getDerivedStateFromProps pattern) — no useEffect needed for state tracking.
18
+ * The finishing → hidden cleanup uses onTransitionEnd from the CSS transition.
19
+ *
20
+ * When delay > 0, CSS animation-delay + a visibility keyframe ensure the bar
21
+ * stays invisible during the delay period. If navigation finishes before the
22
+ * delay, the bar was never visible so the finish transition is also invisible.
23
+ *
24
+ * See design/19-client-navigation.md §"useNavigationPending()"
25
+ * See LOCAL-336 for design decisions.
26
+ */
27
+
28
+ 'use client';
29
+
30
+ import { useState, createElement } from 'react';
31
+ import { usePendingNavigationUrl } from './navigation-context.js';
32
+
33
+ // ─── Types ───────────────────────────────────────────────────────
34
+
35
+ export interface TopLoaderConfig {
36
+ /** Whether the top-loader is enabled. Default: true. */
37
+ enabled?: boolean;
38
+ /** Bar color. Default: '#2299DD'. */
39
+ color?: string;
40
+ /** Bar height in pixels. Default: 3. */
41
+ height?: number;
42
+ /** Show subtle glow/shadow effect. Default: true. */
43
+ shadow?: boolean;
44
+ /** Delay in ms before showing the bar. Default: 0. */
45
+ delay?: number;
46
+ /** CSS z-index. Default: 1600. */
47
+ zIndex?: number;
48
+ }
49
+
50
+ // ─── Defaults ────────────────────────────────────────────────────
51
+
52
+ const DEFAULT_COLOR = '#2299DD';
53
+ const DEFAULT_HEIGHT = 3;
54
+ const DEFAULT_SHADOW = true;
55
+ const DEFAULT_DELAY = 0;
56
+ const DEFAULT_Z_INDEX = 1600;
57
+
58
+ // ─── Keyframes ───────────────────────────────────────────────────
59
+
60
+ // Unique keyframes name to avoid collisions with user styles.
61
+ const CRAWL_KEYFRAMES = '__timber_top_loader_crawl';
62
+ const APPEAR_KEYFRAMES = '__timber_top_loader_appear';
63
+
64
+ // Track whether the @keyframes rules have been injected into the document.
65
+ let keyframesInjected = false;
66
+
67
+ /**
68
+ * Inject the @keyframes rules into the document head once.
69
+ * Called during render (idempotent). Uses a <style> tag so the
70
+ * animations are available for inline-styled elements.
71
+ */
72
+ function ensureKeyframes(): void {
73
+ if (keyframesInjected) return;
74
+ if (typeof document === 'undefined') return;
75
+
76
+ const style = document.createElement('style');
77
+ style.textContent = `
78
+ @keyframes ${CRAWL_KEYFRAMES} {
79
+ 0% { width: 0%; }
80
+ 100% { width: 90%; }
81
+ }
82
+ @keyframes ${APPEAR_KEYFRAMES} {
83
+ from { opacity: 0; }
84
+ to { opacity: 1; }
85
+ }
86
+ `;
87
+ document.head.appendChild(style);
88
+ keyframesInjected = true;
89
+ }
90
+
91
+ // ─── Component ───────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Internal top-loader component. Injected by TransitionRoot.
95
+ *
96
+ * Reads pending navigation state from PendingNavigationContext.
97
+ * Phase transitions are derived synchronously during render:
98
+ *
99
+ * hidden → crawling: when isPending becomes true
100
+ * crawling → finishing: when isPending becomes false
101
+ * finishing → hidden: when CSS transition ends (onTransitionEnd)
102
+ * finishing → crawling: when isPending becomes true again
103
+ *
104
+ * No useEffect — all state changes are either derived during render
105
+ * (getDerivedStateFromProps pattern) or triggered by DOM events.
106
+ */
107
+ export function TopLoader({ config }: { config?: TopLoaderConfig }): React.ReactElement | null {
108
+ const pendingUrl = usePendingNavigationUrl();
109
+ const isPending = pendingUrl !== null;
110
+
111
+ const color = config?.color ?? DEFAULT_COLOR;
112
+ const height = config?.height ?? DEFAULT_HEIGHT;
113
+ const shadow = config?.shadow ?? DEFAULT_SHADOW;
114
+ const delay = config?.delay ?? DEFAULT_DELAY;
115
+ const zIndex = config?.zIndex ?? DEFAULT_Z_INDEX;
116
+
117
+ const [phase, setPhase] = useState<'hidden' | 'crawling' | 'finishing'>('hidden');
118
+
119
+ // ─── Synchronous phase derivation (getDerivedStateFromProps) ──
120
+ // React allows setState during render if the value changes — it
121
+ // immediately re-renders with the updated state before committing.
122
+
123
+ if (isPending && (phase === 'hidden' || phase === 'finishing')) {
124
+ setPhase('crawling');
125
+ }
126
+ if (!isPending && phase === 'crawling') {
127
+ setPhase('finishing');
128
+ }
129
+
130
+ // Inject keyframes on first visible render (idempotent)
131
+ if (phase !== 'hidden') {
132
+ ensureKeyframes();
133
+ }
134
+
135
+ if (phase === 'hidden') return null;
136
+
137
+ // ─── Styles ──────────────────────────────────────────────────
138
+
139
+ const containerStyle: React.CSSProperties = {
140
+ position: 'fixed',
141
+ top: 0,
142
+ left: 0,
143
+ width: '100%',
144
+ height: `${height}px`,
145
+ zIndex,
146
+ pointerEvents: 'none',
147
+ };
148
+
149
+ const barStyle: React.CSSProperties = {
150
+ height: '100%',
151
+ backgroundColor: color,
152
+ ...(phase === 'crawling'
153
+ ? {
154
+ // Crawl from 0% to 90% over 30s. When delay > 0, both the crawl
155
+ // and a visibility animation are delayed — the bar stays at width 0%
156
+ // and opacity 0 during the delay, then appears and starts crawling.
157
+ // With delay 0, the appear animation is instant (0s duration, no delay).
158
+ animation: [
159
+ `${CRAWL_KEYFRAMES} 30s ease-out ${delay}ms forwards`,
160
+ `${APPEAR_KEYFRAMES} 0s ${delay}ms both`,
161
+ ].join(', '),
162
+ }
163
+ : {
164
+ // Finishing: snap to 100% and fade out over 200ms.
165
+ width: '100%',
166
+ opacity: 0,
167
+ transition: 'width 200ms ease, opacity 200ms ease',
168
+ }),
169
+ ...(shadow
170
+ ? {
171
+ boxShadow: `0 0 10px ${color}, 0 0 5px ${color}`,
172
+ }
173
+ : {}),
174
+ };
175
+
176
+ // Clean up the finishing phase when the CSS transition completes.
177
+ // onTransitionEnd fires once per transitioned property — we act on
178
+ // the first one (opacity) and ignore subsequent (width).
179
+ const handleTransitionEnd = phase === 'finishing'
180
+ ? (e: React.TransitionEvent) => {
181
+ if (e.propertyName === 'opacity') {
182
+ setPhase('hidden');
183
+ }
184
+ }
185
+ : undefined;
186
+
187
+ return createElement(
188
+ 'div',
189
+ {
190
+ style: containerStyle,
191
+ 'aria-hidden': 'true',
192
+ 'data-timber-top-loader': '',
193
+ },
194
+ createElement('div', { style: barStyle, onTransitionEnd: handleTransitionEnd })
195
+ );
196
+ }
@@ -22,8 +22,9 @@
22
22
  * See design/19-client-navigation.md §"NavigationContext"
23
23
  */
24
24
 
25
- import { useState, useTransition, createElement, type ReactNode } from 'react';
25
+ import { useState, useTransition, createElement, Fragment, type ReactNode } from 'react';
26
26
  import { PendingNavigationProvider } from './navigation-context.js';
27
+ import { TopLoader, type TopLoaderConfig } from './top-loader.js';
27
28
 
28
29
  // ─── Module-level functions ──────────────────────────────────────
29
30
 
@@ -61,7 +62,7 @@ let _navigateTransition:
61
62
  * Non-navigation renders:
62
63
  * transitionRender(newWrappedElement);
63
64
  */
64
- export function TransitionRoot({ initial }: { initial: ReactNode }): ReactNode {
65
+ export function TransitionRoot({ initial, topLoaderConfig }: { initial: ReactNode; topLoaderConfig?: TopLoaderConfig }): ReactNode {
65
66
  const [element, setElement] = useState<ReactNode>(initial);
66
67
  const [pendingUrl, setPendingUrl] = useState<string | null>(null);
67
68
  const [, startTransition] = useTransition();
@@ -101,7 +102,14 @@ export function TransitionRoot({ initial }: { initial: ReactNode }): ReactNode {
101
102
  });
102
103
  };
103
104
 
104
- return createElement(PendingNavigationProvider, { value: pendingUrl }, element);
105
+ // Inject TopLoader alongside the element tree inside PendingNavigationProvider.
106
+ // The TopLoader reads pendingUrl from context to show/hide the progress bar.
107
+ // It is rendered only when not explicitly disabled via config.
108
+ const showTopLoader = topLoaderConfig?.enabled !== false;
109
+ const children = showTopLoader
110
+ ? createElement(Fragment, null, createElement(TopLoader, { config: topLoaderConfig }), element)
111
+ : element;
112
+ return createElement(PendingNavigationProvider, { value: pendingUrl }, children);
105
113
  }
106
114
 
107
115
  // ─── Public API ──────────────────────────────────────────────────
package/src/index.ts CHANGED
@@ -18,7 +18,7 @@ import { timberServerActionExports } from './plugins/server-action-exports';
18
18
  import { timberBuildManifest } from './plugins/build-manifest';
19
19
  import { timberDevLogs } from './plugins/dev-logs';
20
20
  import { timberReactProd } from './plugins/react-prod';
21
- import { timberChunks, assignClientChunk } from './plugins/chunks';
21
+ import { timberChunks } from './plugins/chunks';
22
22
  import { timberServerBundle } from './plugins/server-bundle';
23
23
  import { timberAdapterBuild } from './plugins/adapter-build';
24
24
  import { timberBuildReport } from './plugins/build-report';
@@ -110,6 +110,30 @@ export interface TimberUserConfig {
110
110
  recmaPlugins?: unknown[];
111
111
  remarkRehypeOptions?: Record<string, unknown>;
112
112
  };
113
+ /**
114
+ * Built-in top-loader progress bar for client navigations.
115
+ * Shows an animated bar at the top of the viewport during RSC navigations.
116
+ * Enabled by default — set `enabled: false` to opt out.
117
+ *
118
+ * Users who want a fully custom progress indicator should disable this
119
+ * and use `useNavigationPending()` directly.
120
+ *
121
+ * See LOCAL-336 for design decisions.
122
+ */
123
+ topLoader?: {
124
+ /** Whether the top-loader is enabled. Default: true. */
125
+ enabled?: boolean;
126
+ /** Bar color. Default: '#2299DD'. */
127
+ color?: string;
128
+ /** Bar height in pixels. Default: 3. */
129
+ height?: number;
130
+ /** Show subtle glow/shadow effect. Default: true. */
131
+ shadow?: boolean;
132
+ /** Delay in ms before showing the bar (avoids flash on fast navs). Default: 0. */
133
+ delay?: number;
134
+ /** CSS z-index. Default: 1600. */
135
+ zIndex?: number;
136
+ };
113
137
  }
114
138
 
115
139
  /**
@@ -376,7 +400,8 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
376
400
  ssr: 'virtual:timber-ssr-entry',
377
401
  client: 'virtual:timber-browser-entry',
378
402
  },
379
- clientChunks: assignClientChunk,
403
+ // No custom clientChunks — Rolldown handles natural code splitting.
404
+ // See design/27-chunking-strategy.md and LOCAL-337.
380
405
  });
381
406
  }
382
407
  );