@timber-js/app 0.1.28 → 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +9 -3
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -9
- package/dist/cli.js.map +1 -1
- package/dist/client/index.js +139 -158
- package/dist/client/index.js.map +1 -1
- package/dist/client/link-status-provider.d.ts +10 -4
- package/dist/client/link-status-provider.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts +8 -0
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/use-navigation-pending.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +122 -8
- package/dist/index.js.map +1 -1
- package/dist/plugins/chunks.d.ts +17 -6
- package/dist/plugins/chunks.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +13 -6
- package/src/client/browser-entry.ts +3 -0
- package/src/client/link-status-provider.tsx +18 -24
- package/src/client/navigation-context.ts +9 -1
- package/src/client/router.ts +23 -4
- package/src/client/use-navigation-pending.ts +10 -17
- package/src/index.ts +24 -1
- package/src/plugins/chunks.ts +145 -17
package/dist/plugins/chunks.d.ts
CHANGED
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Splits client bundles into cache tiers based on update frequency:
|
|
5
5
|
*
|
|
6
|
-
* Tier 1: vendor-react
|
|
7
|
-
* Tier 2: vendor-timber
|
|
8
|
-
* Tier 3:
|
|
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.
|
|
9
16
|
*
|
|
10
17
|
* Server environments (RSC, SSR) are left to Vite's default chunking since
|
|
11
18
|
* Cloudflare Workers load all code from a single deployment bundle with no
|
|
@@ -17,8 +24,8 @@ import type { Plugin } from 'vite';
|
|
|
17
24
|
/**
|
|
18
25
|
* Categorize a module ID into a cache tier chunk name.
|
|
19
26
|
*
|
|
20
|
-
* Returns a chunk name for vendor modules
|
|
21
|
-
* Rollup's default splitting handle
|
|
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.
|
|
22
29
|
*/
|
|
23
30
|
export declare function assignChunk(id: string): string | undefined;
|
|
24
31
|
/**
|
|
@@ -27,7 +34,11 @@ export declare function assignChunk(id: string): string | undefined;
|
|
|
27
34
|
* The RSC plugin creates separate entry points for each 'use client' module,
|
|
28
35
|
* which manualChunks can't merge. This function is passed as the RSC plugin's
|
|
29
36
|
* `clientChunks` callback to group timber internals into a single chunk.
|
|
30
|
-
*
|
|
37
|
+
*
|
|
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.
|
|
31
42
|
*/
|
|
32
43
|
export declare function assignClientChunk(meta: {
|
|
33
44
|
id: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chunks.d.ts","sourceRoot":"","sources":["../../src/plugins/chunks.ts"],"names":[],"mappings":"AAAA
|
|
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;AAmGnC;;;;;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,CAarB;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAoBrC"}
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -54,12 +54,19 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
54
54
|
|
|
55
55
|
// ─── Command Implementations ─────────────────────────────────────────────────
|
|
56
56
|
|
|
57
|
+
/** @internal Dependency injection for testing. */
|
|
58
|
+
export interface ViteDeps {
|
|
59
|
+
createServer?: typeof import('vite').createServer;
|
|
60
|
+
createBuilder?: typeof import('vite').createBuilder;
|
|
61
|
+
preview?: typeof import('vite').preview;
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
/**
|
|
58
65
|
* Start the Vite dev server.
|
|
59
66
|
* Middleware re-runs on file change via HMR wiring in timber-routing.
|
|
60
67
|
*/
|
|
61
|
-
export async function runDev(options: CommandOptions): Promise<void> {
|
|
62
|
-
const
|
|
68
|
+
export async function runDev(options: CommandOptions, _deps?: ViteDeps): Promise<void> {
|
|
69
|
+
const createServer = _deps?.createServer ?? (await import('vite')).createServer;
|
|
63
70
|
const server = await createServer({
|
|
64
71
|
configFile: options.config,
|
|
65
72
|
});
|
|
@@ -72,8 +79,8 @@ export async function runDev(options: CommandOptions): Promise<void> {
|
|
|
72
79
|
* Direct build() calls do NOT trigger the RSC plugin's multi-environment
|
|
73
80
|
* pipeline — createBuilder/buildApp is required.
|
|
74
81
|
*/
|
|
75
|
-
export async function runBuild(options: CommandOptions): Promise<void> {
|
|
76
|
-
const
|
|
82
|
+
export async function runBuild(options: CommandOptions, _deps?: ViteDeps): Promise<void> {
|
|
83
|
+
const createBuilder = _deps?.createBuilder ?? (await import('vite')).createBuilder;
|
|
77
84
|
const builder = await createBuilder({
|
|
78
85
|
configFile: options.config,
|
|
79
86
|
});
|
|
@@ -123,7 +130,7 @@ async function loadTimberConfig(
|
|
|
123
130
|
* If the adapter provides a preview() method, it takes priority.
|
|
124
131
|
* Otherwise falls back to Vite's built-in preview server.
|
|
125
132
|
*/
|
|
126
|
-
export async function runPreview(options: CommandOptions): Promise<void> {
|
|
133
|
+
export async function runPreview(options: CommandOptions, _deps?: ViteDeps): Promise<void> {
|
|
127
134
|
const { join } = await import('node:path');
|
|
128
135
|
|
|
129
136
|
// Try to load timber config for adapter-specific preview
|
|
@@ -139,7 +146,7 @@ export async function runPreview(options: CommandOptions): Promise<void> {
|
|
|
139
146
|
}
|
|
140
147
|
|
|
141
148
|
// Fallback: Vite's built-in preview server
|
|
142
|
-
const
|
|
149
|
+
const preview = _deps?.preview ?? (await import('vite')).preview;
|
|
143
150
|
const server = await preview({
|
|
144
151
|
configFile: options.config,
|
|
145
152
|
});
|
|
@@ -289,12 +289,14 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
289
289
|
setNavigationState({
|
|
290
290
|
params: earlyParams as Record<string, string | string[]>,
|
|
291
291
|
pathname: window.location.pathname,
|
|
292
|
+
pendingUrl: null,
|
|
292
293
|
});
|
|
293
294
|
delete (self as unknown as Record<string, unknown>).__timber_params;
|
|
294
295
|
} else {
|
|
295
296
|
setNavigationState({
|
|
296
297
|
params: {},
|
|
297
298
|
pathname: window.location.pathname,
|
|
299
|
+
pendingUrl: null,
|
|
298
300
|
});
|
|
299
301
|
}
|
|
300
302
|
|
|
@@ -439,6 +441,7 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
439
441
|
setNavigationState({
|
|
440
442
|
params: lateTimberParams as Record<string, string | string[]>,
|
|
441
443
|
pathname: window.location.pathname,
|
|
444
|
+
pendingUrl: null,
|
|
442
445
|
});
|
|
443
446
|
delete (self as unknown as Record<string, unknown>).__timber_params;
|
|
444
447
|
}
|
|
@@ -2,39 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
// LinkStatusProvider — client component that provides per-link pending status
|
|
4
4
|
// via React context. Used inside <Link> to power useLinkStatus().
|
|
5
|
+
//
|
|
6
|
+
// Reads pendingUrl from NavigationContext so the pending status updates
|
|
7
|
+
// atomically with params/pathname in the same React commit. This prevents
|
|
8
|
+
// the gap where the spinner disappears before the active state updates.
|
|
5
9
|
|
|
6
|
-
import {
|
|
10
|
+
import type { ReactNode } from 'react';
|
|
7
11
|
import { LinkStatusContext, type LinkStatus } from './use-link-status.js';
|
|
8
|
-
import {
|
|
12
|
+
import { useNavigationContext } from './navigation-context.js';
|
|
9
13
|
|
|
10
14
|
const NOT_PENDING: LinkStatus = { pending: false };
|
|
11
15
|
const IS_PENDING: LinkStatus = { pending: true };
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
|
-
* Client component that
|
|
15
|
-
* a scoped LinkStatusContext to children. Renders no extra DOM —
|
|
16
|
-
* context provider around children.
|
|
18
|
+
* Client component that reads the pending URL from NavigationContext and
|
|
19
|
+
* provides a scoped LinkStatusContext to children. Renders no extra DOM —
|
|
20
|
+
* just a context provider around children.
|
|
21
|
+
*
|
|
22
|
+
* Because pendingUrl lives in NavigationContext alongside params and pathname,
|
|
23
|
+
* all three update in the same React commit via renderRoot(). This eliminates
|
|
24
|
+
* the two-commit timing gap that existed when pendingUrl was read via
|
|
25
|
+
* useSyncExternalStore (external module-level state) while params came from
|
|
26
|
+
* NavigationContext (React context).
|
|
17
27
|
*/
|
|
18
28
|
export function LinkStatusProvider({ href, children }: { href: string; children: ReactNode }) {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return getRouter().onPendingChange(callback);
|
|
23
|
-
} catch {
|
|
24
|
-
return () => {};
|
|
25
|
-
}
|
|
26
|
-
},
|
|
27
|
-
() => {
|
|
28
|
-
try {
|
|
29
|
-
const pendingUrl = getRouter().getPendingUrl();
|
|
30
|
-
if (pendingUrl === href) return IS_PENDING;
|
|
31
|
-
return NOT_PENDING;
|
|
32
|
-
} catch {
|
|
33
|
-
return NOT_PENDING;
|
|
34
|
-
}
|
|
35
|
-
},
|
|
36
|
-
() => NOT_PENDING
|
|
37
|
-
);
|
|
29
|
+
const navState = useNavigationContext();
|
|
30
|
+
// During SSR or outside NavigationProvider, never pending
|
|
31
|
+
const status = navState?.pendingUrl === href ? IS_PENDING : NOT_PENDING;
|
|
38
32
|
|
|
39
33
|
return <LinkStatusContext.Provider value={status}>{children}</LinkStatusContext.Provider>;
|
|
40
34
|
}
|
|
@@ -35,6 +35,14 @@ import React, { createElement, type ReactNode } from 'react';
|
|
|
35
35
|
export interface NavigationState {
|
|
36
36
|
params: Record<string, string | string[]>;
|
|
37
37
|
pathname: string;
|
|
38
|
+
/**
|
|
39
|
+
* The URL currently being navigated to, or null if idle.
|
|
40
|
+
* Used by LinkStatusProvider to determine pending status atomically
|
|
41
|
+
* with params/pathname — all three update in the same React commit
|
|
42
|
+
* via NavigationProvider, preventing the gap where the spinner
|
|
43
|
+
* disappears before the active state updates.
|
|
44
|
+
*/
|
|
45
|
+
pendingUrl: string | null;
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
// ---------------------------------------------------------------------------
|
|
@@ -107,7 +115,7 @@ export function NavigationProvider({ value, children }: NavigationProviderProps)
|
|
|
107
115
|
* This exists only as a communication channel between the router
|
|
108
116
|
* (which knows the new nav state) and renderRoot (which wraps the element).
|
|
109
117
|
*/
|
|
110
|
-
let _currentNavState: NavigationState = { params: {}, pathname: '/' };
|
|
118
|
+
let _currentNavState: NavigationState = { params: {}, pathname: '/', pendingUrl: null };
|
|
111
119
|
|
|
112
120
|
export function setNavigationState(state: NavigationState): void {
|
|
113
121
|
_currentNavState = state;
|
package/src/client/router.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { SegmentInfo } from './segment-cache';
|
|
|
6
6
|
import { HistoryStack } from './history';
|
|
7
7
|
import type { HeadElement } from './head';
|
|
8
8
|
import { setCurrentParams } from './use-params.js';
|
|
9
|
-
import { setNavigationState } from './navigation-context.js';
|
|
9
|
+
import { getNavigationState, setNavigationState } from './navigation-context.js';
|
|
10
10
|
|
|
11
11
|
// ─── Types ───────────────────────────────────────────────────────
|
|
12
12
|
|
|
@@ -298,15 +298,27 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
298
298
|
let pending = false;
|
|
299
299
|
let pendingUrl: string | null = null;
|
|
300
300
|
const pendingListeners = new Set<(pending: boolean) => void>();
|
|
301
|
+
/** Last rendered payload — used to re-render at navigation start with pendingUrl set. */
|
|
302
|
+
let lastRenderedPayload: unknown = null;
|
|
301
303
|
|
|
302
304
|
function setPending(value: boolean, url?: string): void {
|
|
303
305
|
const newPendingUrl = value && url ? url : null;
|
|
304
306
|
if (pending === value && pendingUrl === newPendingUrl) return;
|
|
305
307
|
pending = value;
|
|
306
308
|
pendingUrl = newPendingUrl;
|
|
309
|
+
// Notify external store listeners (useNavigationPending, etc.)
|
|
307
310
|
for (const listener of pendingListeners) {
|
|
308
311
|
listener(value);
|
|
309
312
|
}
|
|
313
|
+
// When navigation starts, re-render the current tree with pendingUrl
|
|
314
|
+
// set in NavigationContext. This makes the pending state visible to
|
|
315
|
+
// LinkStatusProvider atomically via React context, avoiding the
|
|
316
|
+
// two-commit gap between useSyncExternalStore and context updates.
|
|
317
|
+
if (value && lastRenderedPayload !== null) {
|
|
318
|
+
const currentState = getNavigationState();
|
|
319
|
+
setNavigationState({ ...currentState, pendingUrl: newPendingUrl });
|
|
320
|
+
renderPayload(lastRenderedPayload);
|
|
321
|
+
}
|
|
310
322
|
}
|
|
311
323
|
|
|
312
324
|
/** Update the segment cache from server-provided segment metadata. */
|
|
@@ -320,22 +332,29 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
320
332
|
|
|
321
333
|
/** Render a decoded RSC payload into the DOM if a renderer is available. */
|
|
322
334
|
function renderPayload(payload: unknown): void {
|
|
335
|
+
lastRenderedPayload = payload;
|
|
323
336
|
if (deps.renderRoot) {
|
|
324
337
|
deps.renderRoot(payload);
|
|
325
338
|
}
|
|
326
339
|
}
|
|
327
340
|
|
|
328
341
|
/**
|
|
329
|
-
* Update navigation state (params + pathname) for the next render.
|
|
342
|
+
* Update navigation state (params + pathname + pendingUrl) for the next render.
|
|
330
343
|
*
|
|
331
344
|
* Sets both the module-level fallback (for tests and SSR) and the
|
|
332
345
|
* navigation context state (read by renderRoot to wrap the element
|
|
333
346
|
* in NavigationProvider). The context update is atomic with the tree
|
|
334
347
|
* render — both are passed to reactRoot.render() in the same call.
|
|
348
|
+
*
|
|
349
|
+
* pendingUrl is included so that LinkStatusProvider (which reads from
|
|
350
|
+
* NavigationContext) sees the pending state change in the same React
|
|
351
|
+
* commit as params/pathname — preventing the gap where the spinner
|
|
352
|
+
* disappears before the active state updates.
|
|
335
353
|
*/
|
|
336
354
|
function updateNavigationState(
|
|
337
355
|
params: Record<string, string | string[]> | null | undefined,
|
|
338
|
-
url: string
|
|
356
|
+
url: string,
|
|
357
|
+
navPendingUrl: string | null = null
|
|
339
358
|
): void {
|
|
340
359
|
const resolvedParams = params ?? {};
|
|
341
360
|
// Module-level fallback for tests (no NavigationProvider) and SSR
|
|
@@ -344,7 +363,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
344
363
|
const pathname = url.startsWith('http')
|
|
345
364
|
? new URL(url).pathname
|
|
346
365
|
: url.split('?')[0] || '/';
|
|
347
|
-
setNavigationState({ params: resolvedParams, pathname });
|
|
366
|
+
setNavigationState({ params: resolvedParams, pathname, pendingUrl: navPendingUrl });
|
|
348
367
|
}
|
|
349
368
|
|
|
350
369
|
/** Apply head elements (title, meta tags) to the DOM if available. */
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
// useNavigationPending — returns true while an RSC navigation is in flight.
|
|
2
2
|
// See design/19-client-navigation.md §"useNavigationPending()"
|
|
3
|
+
//
|
|
4
|
+
// Reads from NavigationContext so the pending state updates atomically
|
|
5
|
+
// with params/pathname in the same React commit. Falls back to the
|
|
6
|
+
// router's external store when no NavigationProvider is mounted (SSR,
|
|
7
|
+
// tests without a React tree).
|
|
3
8
|
|
|
4
|
-
import {
|
|
5
|
-
import { getRouter } from './router-ref.js';
|
|
9
|
+
import { useNavigationContext } from './navigation-context.js';
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
12
|
* Returns true while an RSC navigation is in flight.
|
|
@@ -29,19 +33,8 @@ import { getRouter } from './router-ref.js';
|
|
|
29
33
|
* ```
|
|
30
34
|
*/
|
|
31
35
|
export function useNavigationPending(): boolean {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
},
|
|
37
|
-
() => {
|
|
38
|
-
try {
|
|
39
|
-
return getRouter().isPending();
|
|
40
|
-
} catch {
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
},
|
|
44
|
-
// Server snapshot — always false during SSR
|
|
45
|
-
() => false
|
|
46
|
-
);
|
|
36
|
+
const navState = useNavigationContext();
|
|
37
|
+
// During SSR or outside NavigationProvider, no navigation is pending
|
|
38
|
+
if (!navState) return false;
|
|
39
|
+
return navState.pendingUrl !== null;
|
|
47
40
|
}
|
package/src/index.ts
CHANGED
|
@@ -303,7 +303,7 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
|
|
|
303
303
|
// Also loads timber.config.ts and merges it into ctx.config (inline config wins).
|
|
304
304
|
const rootSync: Plugin = {
|
|
305
305
|
name: 'timber-root-sync',
|
|
306
|
-
async config(userConfig) {
|
|
306
|
+
async config(userConfig, { command }) {
|
|
307
307
|
// Load timber.config.ts early — before configResolved/buildStart — so
|
|
308
308
|
// all plugins (including timber-mdx) see the merged config in their
|
|
309
309
|
// buildStart hooks. The config hook runs once and supports async.
|
|
@@ -315,6 +315,29 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
|
|
|
315
315
|
ctx.clientJavascript = resolveClientJavascript(ctx.config);
|
|
316
316
|
}
|
|
317
317
|
ctx.timer.end('config-load');
|
|
318
|
+
|
|
319
|
+
// Force production JSX transform for builds.
|
|
320
|
+
//
|
|
321
|
+
// Vite determines dev vs prod JSX via `isProduction`, which checks
|
|
322
|
+
// `process.env.NODE_ENV === 'production'`. If the shell has
|
|
323
|
+
// NODE_ENV=development (common in dev toolchains), `vite build`
|
|
324
|
+
// respects that and emits jsxDEV calls with fileName/lineNumber
|
|
325
|
+
// args. This causes runtime crashes because the production React
|
|
326
|
+
// jsx-runtime doesn't export jsxDEV, and also leaks file paths
|
|
327
|
+
// into production bundles (security concern).
|
|
328
|
+
//
|
|
329
|
+
// We explicitly set `oxc.jsx.development: false` for builds so
|
|
330
|
+
// the client bundle always uses jsx/jsxs from react/jsx-runtime,
|
|
331
|
+
// regardless of the ambient NODE_ENV value.
|
|
332
|
+
if (command === 'build') {
|
|
333
|
+
return {
|
|
334
|
+
oxc: {
|
|
335
|
+
jsx: {
|
|
336
|
+
development: false,
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
318
341
|
},
|
|
319
342
|
configResolved(resolved) {
|
|
320
343
|
ctx.root = resolved.root;
|
package/src/plugins/chunks.ts
CHANGED
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Splits client bundles into cache tiers based on update frequency:
|
|
5
5
|
*
|
|
6
|
-
* Tier 1: vendor-react
|
|
7
|
-
* Tier 2: vendor-timber
|
|
8
|
-
* Tier 3:
|
|
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.
|
|
9
16
|
*
|
|
10
17
|
* Server environments (RSC, SSR) are left to Vite's default chunking since
|
|
11
18
|
* Cloudflare Workers load all code from a single deployment bundle with no
|
|
@@ -14,34 +21,140 @@
|
|
|
14
21
|
* Design docs: 27-chunking-strategy.md
|
|
15
22
|
*/
|
|
16
23
|
|
|
24
|
+
import { statSync } from 'node:fs';
|
|
17
25
|
import type { Plugin } from 'vite';
|
|
18
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Source file size threshold for the shared-app chunk.
|
|
29
|
+
* Modules under this size that aren't route files get merged into shared-app
|
|
30
|
+
* instead of getting their own tiny chunks.
|
|
31
|
+
*/
|
|
32
|
+
const SMALL_MODULE_THRESHOLD = 5 * 1024; // 5KB
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Route convention file basenames (without extension).
|
|
36
|
+
* These files define route segments and must stay in per-route chunks
|
|
37
|
+
* to preserve route-based code splitting.
|
|
38
|
+
*/
|
|
39
|
+
const ROUTE_FILE_BASENAMES = new Set([
|
|
40
|
+
'page',
|
|
41
|
+
'layout',
|
|
42
|
+
'loading',
|
|
43
|
+
'error',
|
|
44
|
+
'not-found',
|
|
45
|
+
'template',
|
|
46
|
+
'access',
|
|
47
|
+
'middleware',
|
|
48
|
+
'default',
|
|
49
|
+
'route',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Cache for source file sizes to avoid repeated statSync calls.
|
|
54
|
+
* Populated lazily during the build.
|
|
55
|
+
*/
|
|
56
|
+
const sizeCache = new Map<string, number>();
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get the source file size, with caching.
|
|
60
|
+
* Returns Infinity for virtual modules or files that can't be stat'd.
|
|
61
|
+
*/
|
|
62
|
+
function getSourceSize(id: string): number {
|
|
63
|
+
const cached = sizeCache.get(id);
|
|
64
|
+
if (cached !== undefined) return cached;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const size = statSync(id).size;
|
|
68
|
+
sizeCache.set(id, size);
|
|
69
|
+
return size;
|
|
70
|
+
} catch {
|
|
71
|
+
sizeCache.set(id, Infinity);
|
|
72
|
+
return Infinity;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Extract the basename without extension from a module ID.
|
|
78
|
+
* e.g. '/project/app/dashboard/page.tsx' → 'page'
|
|
79
|
+
*/
|
|
80
|
+
function getBasename(id: string): string {
|
|
81
|
+
const lastSlash = id.lastIndexOf('/');
|
|
82
|
+
const filename = lastSlash >= 0 ? id.substring(lastSlash + 1) : id;
|
|
83
|
+
const dotIndex = filename.indexOf('.');
|
|
84
|
+
return dotIndex >= 0 ? filename.substring(0, dotIndex) : filename;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check if a module is a React ecosystem package (tier 1).
|
|
89
|
+
*/
|
|
90
|
+
function isReactVendor(id: string): boolean {
|
|
91
|
+
return (
|
|
92
|
+
id.includes('node_modules/react-dom') ||
|
|
93
|
+
id.includes('node_modules/react/') ||
|
|
94
|
+
id.includes('node_modules/scheduler')
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check if a module is part of the timber framework runtime (tier 2).
|
|
100
|
+
*/
|
|
101
|
+
function isTimberRuntime(id: string): boolean {
|
|
102
|
+
return (
|
|
103
|
+
id.includes('/timber-app/') ||
|
|
104
|
+
id.includes('react-server-dom') ||
|
|
105
|
+
id.includes('@vitejs/plugin-rsc')
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check if a module is a user-installed node_modules dependency (tier 3).
|
|
111
|
+
* Excludes React ecosystem and timber runtime packages which have their own tiers.
|
|
112
|
+
*/
|
|
113
|
+
function isUserVendor(id: string): boolean {
|
|
114
|
+
return id.includes('node_modules/') && !isReactVendor(id) && !isTimberRuntime(id);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if a module is a route convention file that should stay per-route.
|
|
119
|
+
*/
|
|
120
|
+
function isRouteFile(id: string): boolean {
|
|
121
|
+
return ROUTE_FILE_BASENAMES.has(getBasename(id));
|
|
122
|
+
}
|
|
123
|
+
|
|
19
124
|
/**
|
|
20
125
|
* Categorize a module ID into a cache tier chunk name.
|
|
21
126
|
*
|
|
22
|
-
* Returns a chunk name for vendor modules
|
|
23
|
-
* Rollup's default splitting handle
|
|
127
|
+
* Returns a chunk name for vendor modules and small shared app code,
|
|
128
|
+
* or undefined to let Rollup's default splitting handle route code.
|
|
24
129
|
*/
|
|
25
130
|
export function assignChunk(id: string): string | undefined {
|
|
26
131
|
// Tier 1: React ecosystem — changes on version bumps only
|
|
27
|
-
if (
|
|
28
|
-
id.includes('node_modules/react-dom') ||
|
|
29
|
-
id.includes('node_modules/react/') ||
|
|
30
|
-
id.includes('node_modules/scheduler')
|
|
31
|
-
) {
|
|
132
|
+
if (isReactVendor(id)) {
|
|
32
133
|
return 'vendor-react';
|
|
33
134
|
}
|
|
34
135
|
|
|
35
136
|
// Tier 2: timber framework runtime — changes on framework updates
|
|
36
|
-
if (
|
|
37
|
-
id.includes('/timber-app/') ||
|
|
38
|
-
id.includes('react-server-dom') ||
|
|
39
|
-
id.includes('@vitejs/plugin-rsc')
|
|
40
|
-
) {
|
|
137
|
+
if (isTimberRuntime(id)) {
|
|
41
138
|
return 'vendor-timber';
|
|
42
139
|
}
|
|
43
140
|
|
|
44
|
-
//
|
|
141
|
+
// Tier 3: User vendor libraries — changes on dependency updates
|
|
142
|
+
if (isUserVendor(id)) {
|
|
143
|
+
return 'vendor-app';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Tier 4: Small shared app modules — prevents tiny per-module chunks
|
|
147
|
+
// Skip route files (page, layout, etc.) to preserve route-based splitting.
|
|
148
|
+
// Skip virtual modules (contain \0 or don't start with /) as they have no
|
|
149
|
+
// meaningful source size.
|
|
150
|
+
if (!id.includes('\0') && id.startsWith('/') && !isRouteFile(id)) {
|
|
151
|
+
const size = getSourceSize(id);
|
|
152
|
+
if (size < SMALL_MODULE_THRESHOLD) {
|
|
153
|
+
return 'shared-app';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Tier 5: Rollup's default splitting (per-route page/layout chunks, large shared modules)
|
|
45
158
|
}
|
|
46
159
|
|
|
47
160
|
/**
|
|
@@ -50,14 +163,29 @@ export function assignChunk(id: string): string | undefined {
|
|
|
50
163
|
* The RSC plugin creates separate entry points for each 'use client' module,
|
|
51
164
|
* which manualChunks can't merge. This function is passed as the RSC plugin's
|
|
52
165
|
* `clientChunks` callback to group timber internals into a single chunk.
|
|
53
|
-
*
|
|
166
|
+
*
|
|
167
|
+
* User client components that are small (< 5KB) are grouped into shared-client
|
|
168
|
+
* to prevent thin facade wrappers from becoming individual chunks. This handles
|
|
169
|
+
* the RSC client reference facade problem where each 'use client' module gets
|
|
170
|
+
* a ~100-300 byte re-export wrapper chunk.
|
|
54
171
|
*/
|
|
55
172
|
export function assignClientChunk(meta: {
|
|
56
173
|
id: string;
|
|
57
174
|
normalizedId: string;
|
|
58
175
|
serverChunk: string;
|
|
59
176
|
}): string | undefined {
|
|
177
|
+
// Timber framework client modules → vendor-timber
|
|
60
178
|
if (meta.id.includes('/timber-app/')) return 'vendor-timber';
|
|
179
|
+
|
|
180
|
+
// Small user client components → shared-client (prevents facade micro-chunks)
|
|
181
|
+
if (!meta.id.includes('\0') && meta.id.startsWith('/')) {
|
|
182
|
+
const size = getSourceSize(meta.id);
|
|
183
|
+
if (size < SMALL_MODULE_THRESHOLD) {
|
|
184
|
+
return 'shared-client';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Large user/third-party client components → default per-route splitting
|
|
61
189
|
}
|
|
62
190
|
|
|
63
191
|
/**
|