@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.
- package/dist/client/index.js +19 -42
- package/dist/client/index.js.map +1 -1
- package/dist/client/navigation-context.d.ts +5 -0
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/top-loader.d.ts +58 -0
- package/dist/client/top-loader.d.ts.map +1 -0
- package/dist/client/transition-root.d.ts +3 -1
- package/dist/client/transition-root.d.ts.map +1 -1
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -150
- package/dist/index.js.map +1 -1
- package/dist/plugins/chunks.d.ts +20 -39
- package/dist/plugins/chunks.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/browser-entry.ts +1 -1
- package/src/client/navigation-context.ts +16 -47
- package/src/client/top-loader.tsx +196 -0
- package/src/client/transition-root.tsx +11 -3
- package/src/index.ts +27 -2
- package/src/plugins/chunks.ts +20 -195
- package/src/plugins/entries.ts +1 -0
package/dist/plugins/chunks.d.ts
CHANGED
|
@@ -1,56 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* timber-chunks — Vite sub-plugin for
|
|
2
|
+
* timber-chunks — Vite sub-plugin for client chunk configuration.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
|
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;
|
|
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
|
@@ -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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
+
_navState = state;
|
|
150
127
|
}
|
|
151
128
|
|
|
152
129
|
export function getNavigationState(): NavigationState {
|
|
153
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
);
|