@timber-js/app 0.1.29 → 0.1.31

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.
@@ -11,65 +11,105 @@
11
11
  * a transition update. React keeps the old committed tree visible while
12
12
  * any new Suspense boundaries in the transition resolve.
13
13
  *
14
- * This is the client-side equivalent of deferSuspenseFor on the server:
15
- * the old content stays visible until the new content is ready, avoiding
16
- * flash-of-fallback during fast navigations.
14
+ * Also manages `pendingUrl` via `useOptimistic`. During a navigation
15
+ * transition, the optimistic value (the target URL) shows immediately
16
+ * while the transition is pending, and automatically reverts to null
17
+ * when the transition commits. This ensures useLinkStatus and
18
+ * useNavigationPending show the pending state immediately and clear
19
+ * atomically with the new tree — same pattern Next.js uses with
20
+ * useOptimistic per Link instance, adapted for timber's server-component
21
+ * Link with global click delegation.
17
22
  *
18
23
  * See design/05-streaming.md §"deferSuspenseFor"
24
+ * See design/19-client-navigation.md §"NavigationContext"
19
25
  */
20
26
 
21
- import { useState, startTransition, type ReactNode } from 'react';
27
+ import {
28
+ useState,
29
+ useOptimistic,
30
+ startTransition,
31
+ createElement,
32
+ type ReactNode,
33
+ } from 'react';
34
+ import { PendingNavigationProvider } from './pending-navigation-context.js';
22
35
 
23
- // ─── Module-level render function ────────────────────────────────
36
+ // ─── Module-level functions ──────────────────────────────────────
24
37
 
25
38
  /**
26
39
  * Module-level reference to the state setter wrapped in startTransition.
27
- * Set during TransitionRoot's render. This is safe because there is
28
- * exactly one TransitionRoot per application (the document root).
40
+ * Used for non-navigation renders (applyRevalidation, popstate replay).
29
41
  */
30
42
  let _transitionRender: ((element: ReactNode) => void) | null = null;
31
43
 
44
+ /**
45
+ * Module-level reference to the navigation transition function.
46
+ * Wraps a full navigation (fetch + render) in a single startTransition
47
+ * with useOptimistic for the pending URL.
48
+ */
49
+ let _navigateTransition: ((
50
+ pendingUrl: string,
51
+ perform: () => Promise<ReactNode>,
52
+ ) => Promise<void>) | null = null;
53
+
32
54
  // ─── Component ───────────────────────────────────────────────────
33
55
 
34
56
  /**
35
57
  * Root wrapper component that enables transition-based rendering.
36
58
  *
37
- * Renders no DOM elements returns the current element directly.
38
- * This means the DOM tree matches the server-rendered HTML during
39
- * hydration (TransitionRoot is invisible to the DOM).
59
+ * Renders PendingNavigationProvider around children for the pending URL
60
+ * context. The DOM tree matches the server-rendered HTML during hydration
61
+ * (the provider renders no extra DOM elements).
40
62
  *
41
63
  * Usage in browser-entry.ts:
42
64
  * const rootEl = createElement(TransitionRoot, { initial: wrapped });
43
65
  * reactRoot = hydrateRoot(document, rootEl);
44
66
  *
45
67
  * Subsequent navigations:
68
+ * navigateTransition(url, async () => { fetch; return wrappedElement; });
69
+ *
70
+ * Non-navigation renders:
46
71
  * transitionRender(newWrappedElement);
47
72
  */
48
73
  export function TransitionRoot({ initial }: { initial: ReactNode }): ReactNode {
49
74
  const [element, setElement] = useState<ReactNode>(initial);
75
+ const [optimisticPendingUrl, setOptimisticPendingUrl] = useOptimistic<string | null>(null);
50
76
 
51
- // Update the module-level ref on every render so it always points
52
- // to the current component instance's setState.
77
+ // Non-navigation render (revalidation, popstate cached replay).
53
78
  _transitionRender = (newElement: ReactNode) => {
54
79
  startTransition(() => {
55
80
  setElement(newElement);
56
81
  });
57
82
  };
58
83
 
59
- return element;
84
+ // Full navigation transition. The entire navigation (fetch + state updates)
85
+ // runs inside startTransition. useOptimistic shows the pending URL immediately
86
+ // (urgent) and reverts to null when the transition commits (atomic with new tree).
87
+ _navigateTransition = (pendingUrl: string, perform: () => Promise<ReactNode>) => {
88
+ return new Promise<void>((resolve, reject) => {
89
+ startTransition(async () => {
90
+ try {
91
+ setOptimisticPendingUrl(pendingUrl);
92
+ const newElement = await perform();
93
+ setElement(newElement);
94
+ resolve();
95
+ } catch (err) {
96
+ reject(err);
97
+ }
98
+ });
99
+ });
100
+ };
101
+
102
+ return createElement(PendingNavigationProvider, { value: optimisticPendingUrl }, element);
60
103
  }
61
104
 
62
105
  // ─── Public API ──────────────────────────────────────────────────
63
106
 
64
107
  /**
65
- * Trigger a transition render. React keeps the old committed tree
66
- * visible while any new Suspense boundaries in the update resolve.
67
- *
68
- * This is the function called by the router's renderRoot callback
69
- * instead of reactRoot.render() directly.
108
+ * Trigger a transition render for non-navigation updates.
109
+ * React keeps the old committed tree visible while any new Suspense
110
+ * boundaries in the update resolve.
70
111
  *
71
- * Falls back to no-op if TransitionRoot hasn't mounted yet (shouldn't
72
- * happen in practice — TransitionRoot mounts during hydration).
112
+ * Used for: applyRevalidation, popstate replay with cached payload.
73
113
  */
74
114
  export function transitionRender(element: ReactNode): void {
75
115
  if (_transitionRender) {
@@ -77,6 +117,30 @@ export function transitionRender(element: ReactNode): void {
77
117
  }
78
118
  }
79
119
 
120
+ /**
121
+ * Run a full navigation inside a React transition with optimistic pending URL.
122
+ *
123
+ * The `perform` callback runs inside `startTransition` — it should fetch the
124
+ * RSC payload, update router state, and return the wrapped React element.
125
+ * The pending URL shows immediately (useOptimistic urgent update) and reverts
126
+ * to null when the transition commits (atomic with the new tree).
127
+ *
128
+ * Returns a Promise that resolves when the async work completes (note: the
129
+ * React transition may not have committed yet, but all state updates are done).
130
+ *
131
+ * Used for: navigate(), refresh(), popstate with fetch.
132
+ */
133
+ export function navigateTransition(
134
+ pendingUrl: string,
135
+ perform: () => Promise<ReactNode>,
136
+ ): Promise<void> {
137
+ if (_navigateTransition) {
138
+ return _navigateTransition(pendingUrl, perform);
139
+ }
140
+ // Fallback: no TransitionRoot mounted (shouldn't happen in production)
141
+ return perform().then(() => {});
142
+ }
143
+
80
144
  /**
81
145
  * Check if the TransitionRoot is mounted and ready for renders.
82
146
  * Used by browser-entry.ts to guard against renders before hydration.
@@ -1,8 +1,11 @@
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 PendingNavigationContext (provided by TransitionRoot) so the
5
+ // pending state shows immediately (urgent update) and clears atomically
6
+ // with the new tree (same startTransition commit).
3
7
 
4
- import { useSyncExternalStore } from 'react';
5
- import { getRouter } from './router-ref.js';
8
+ import { usePendingNavigationUrl } from './pending-navigation-context.js';
6
9
 
7
10
  /**
8
11
  * Returns true while an RSC navigation is in flight.
@@ -29,19 +32,7 @@ import { getRouter } from './router-ref.js';
29
32
  * ```
30
33
  */
31
34
  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
- );
35
+ const pendingUrl = usePendingNavigationUrl();
36
+ // During SSR or outside PendingNavigationProvider, no navigation is pending
37
+ return pendingUrl !== null;
47
38
  }
@@ -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
  /**