@timber-js/app 0.1.29 → 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.
@@ -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 — react, react-dom, scheduler (changes rarely)
7
- * Tier 2: vendor-timber — timber runtime, RSC runtime (changes per framework update)
8
- * Tier 3: [route]-* per-route app code (changes per deploy, handled by Vite defaults)
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, or undefined to let
21
- * Rollup's default splitting handle app/route code.
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
- * User and third-party client components are left to default per-route splitting.
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;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAEnC;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAoB1D;AAED;;;;;;;GAOG;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;;;;;;;;;;;;;;;;;;;;;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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
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",
@@ -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 { useSyncExternalStore, type ReactNode } from 'react';
10
+ import type { ReactNode } from 'react';
7
11
  import { LinkStatusContext, type LinkStatus } from './use-link-status.js';
8
- import { getRouter } from './router-ref.js';
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 subscribes to the router's pending URL and provides
15
- * a scoped LinkStatusContext to children. Renders no extra DOM — just a
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 status = useSyncExternalStore(
20
- (callback) => {
21
- try {
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;
@@ -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 { useSyncExternalStore } from 'react';
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
- return useSyncExternalStore(
33
- (callback) => {
34
- const router = getRouter();
35
- return router.onPendingChange(callback);
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
  }
@@ -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 — react, react-dom, scheduler (changes rarely)
7
- * Tier 2: vendor-timber — timber runtime, RSC runtime (changes per framework update)
8
- * Tier 3: [route]-* per-route app code (changes per deploy, handled by Vite defaults)
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, or undefined to let
23
- * Rollup's default splitting handle app/route code.
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
- // Everything else: Rollup's default splitting (per-route chunks)
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
- * User and third-party client components are left to default per-route splitting.
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
  /**