@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.
- package/dist/client/index.js +138 -86
- package/dist/client/index.js.map +1 -1
- package/dist/client/link-status-provider.d.ts +4 -4
- package/dist/client/link-status-provider.d.ts.map +1 -1
- package/dist/client/pending-navigation-context.d.ts +32 -0
- package/dist/client/pending-navigation-context.d.ts.map +1 -0
- package/dist/client/router.d.ts +12 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/transition-root.d.ts +33 -13
- package/dist/client/transition-root.d.ts.map +1 -1
- package/dist/client/use-navigation-pending.d.ts.map +1 -1
- package/dist/index.js +120 -7
- 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/client/browser-entry.ts +26 -16
- package/src/client/link-status-provider.tsx +14 -24
- package/src/client/pending-navigation-context.ts +66 -0
- package/src/client/router.ts +127 -75
- package/src/client/transition-root.tsx +84 -20
- package/src/client/use-navigation-pending.ts +8 -17
- package/src/plugins/chunks.ts +145 -17
|
@@ -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
|
-
*
|
|
15
|
-
* the
|
|
16
|
-
*
|
|
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 {
|
|
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
|
|
36
|
+
// ─── Module-level functions ──────────────────────────────────────
|
|
24
37
|
|
|
25
38
|
/**
|
|
26
39
|
* Module-level reference to the state setter wrapped in startTransition.
|
|
27
|
-
*
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
66
|
-
* visible while any new Suspense
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
}
|
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
|
/**
|