@timber-js/app 0.1.55 → 0.1.56
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/top-loader.d.ts +46 -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 +2 -1
- package/dist/index.js.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/top-loader.tsx +213 -0
- package/src/client/transition-root.tsx +11 -3
- package/src/index.ts +24 -0
- package/src/plugins/entries.ts +1 -0
|
@@ -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
|
|
@@ -0,0 +1,213 @@
|
|
|
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
|
+
* The bar respects a configurable delay (default 150ms) — if navigation
|
|
17
|
+
* resolves faster than the delay, the bar never appears.
|
|
18
|
+
*
|
|
19
|
+
* See design/19-client-navigation.md §"useNavigationPending()"
|
|
20
|
+
* See LOCAL-336 for design decisions.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use client';
|
|
24
|
+
|
|
25
|
+
import { useState, useEffect, useRef, createElement } from 'react';
|
|
26
|
+
import { usePendingNavigationUrl } from './navigation-context.js';
|
|
27
|
+
|
|
28
|
+
// ─── Types ───────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export interface TopLoaderConfig {
|
|
31
|
+
/** Whether the top-loader is enabled. Default: true. */
|
|
32
|
+
enabled?: boolean;
|
|
33
|
+
/** Bar color. Default: '#2299DD'. */
|
|
34
|
+
color?: string;
|
|
35
|
+
/** Bar height in pixels. Default: 3. */
|
|
36
|
+
height?: number;
|
|
37
|
+
/** Show subtle glow/shadow effect. Default: true. */
|
|
38
|
+
shadow?: boolean;
|
|
39
|
+
/** Delay in ms before showing the bar. Default: 150. */
|
|
40
|
+
delay?: number;
|
|
41
|
+
/** CSS z-index. Default: 1600. */
|
|
42
|
+
zIndex?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Defaults ────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const DEFAULT_COLOR = '#2299DD';
|
|
48
|
+
const DEFAULT_HEIGHT = 3;
|
|
49
|
+
const DEFAULT_SHADOW = true;
|
|
50
|
+
const DEFAULT_DELAY = 150;
|
|
51
|
+
const DEFAULT_Z_INDEX = 1600;
|
|
52
|
+
|
|
53
|
+
// ─── Keyframes ID ────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
// Unique keyframes name to avoid collisions with user styles.
|
|
56
|
+
const KEYFRAMES_NAME = '__timber_top_loader_crawl';
|
|
57
|
+
|
|
58
|
+
// Track whether the @keyframes rule has been injected into the document.
|
|
59
|
+
let keyframesInjected = false;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Inject the @keyframes rule into the document head once.
|
|
63
|
+
* Uses a <style> tag so the animation is available for inline-styled elements.
|
|
64
|
+
*/
|
|
65
|
+
function ensureKeyframes(): void {
|
|
66
|
+
if (keyframesInjected) return;
|
|
67
|
+
if (typeof document === 'undefined') return;
|
|
68
|
+
|
|
69
|
+
const style = document.createElement('style');
|
|
70
|
+
style.textContent = `
|
|
71
|
+
@keyframes ${KEYFRAMES_NAME} {
|
|
72
|
+
0% { width: 0%; }
|
|
73
|
+
100% { width: 90%; }
|
|
74
|
+
}
|
|
75
|
+
`;
|
|
76
|
+
document.head.appendChild(style);
|
|
77
|
+
keyframesInjected = true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Component ───────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Internal top-loader component. Injected by TransitionRoot.
|
|
84
|
+
*
|
|
85
|
+
* Reads pending navigation state from PendingNavigationContext.
|
|
86
|
+
* Shows nothing when no navigation is pending or when the navigation
|
|
87
|
+
* resolves before the delay threshold.
|
|
88
|
+
*/
|
|
89
|
+
export function TopLoader({ config }: { config?: TopLoaderConfig }): React.ReactElement | null {
|
|
90
|
+
const pendingUrl = usePendingNavigationUrl();
|
|
91
|
+
const isPending = pendingUrl !== null;
|
|
92
|
+
|
|
93
|
+
const color = config?.color ?? DEFAULT_COLOR;
|
|
94
|
+
const height = config?.height ?? DEFAULT_HEIGHT;
|
|
95
|
+
const shadow = config?.shadow ?? DEFAULT_SHADOW;
|
|
96
|
+
const delay = config?.delay ?? DEFAULT_DELAY;
|
|
97
|
+
const zIndex = config?.zIndex ?? DEFAULT_Z_INDEX;
|
|
98
|
+
|
|
99
|
+
// Track visibility states:
|
|
100
|
+
// - 'hidden': no bar shown
|
|
101
|
+
// - 'crawling': bar is animating from 0% to 90%
|
|
102
|
+
// - 'finishing': bar is snapping to 100% and fading out
|
|
103
|
+
const [phase, setPhase] = useState<'hidden' | 'crawling' | 'finishing'>('hidden');
|
|
104
|
+
const delayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
105
|
+
const finishTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
106
|
+
|
|
107
|
+
// Inject keyframes on mount
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
ensureKeyframes();
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
// Handle pending state changes
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (isPending) {
|
|
115
|
+
// Navigation started — wait for delay before showing the bar
|
|
116
|
+
// Clear any finish timer from a previous navigation
|
|
117
|
+
if (finishTimerRef.current !== null) {
|
|
118
|
+
clearTimeout(finishTimerRef.current);
|
|
119
|
+
finishTimerRef.current = null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (delay > 0) {
|
|
123
|
+
delayTimerRef.current = setTimeout(() => {
|
|
124
|
+
delayTimerRef.current = null;
|
|
125
|
+
setPhase('crawling');
|
|
126
|
+
}, delay);
|
|
127
|
+
} else {
|
|
128
|
+
setPhase('crawling');
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
// Navigation ended (or was never pending)
|
|
132
|
+
// Clear the delay timer — if it hasn't fired, the bar never shows
|
|
133
|
+
if (delayTimerRef.current !== null) {
|
|
134
|
+
clearTimeout(delayTimerRef.current);
|
|
135
|
+
delayTimerRef.current = null;
|
|
136
|
+
// Bar was never shown — stay hidden
|
|
137
|
+
if (phase === 'hidden') return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (phase === 'crawling') {
|
|
141
|
+
// Bar was visible — snap to 100% and fade out
|
|
142
|
+
setPhase('finishing');
|
|
143
|
+
finishTimerRef.current = setTimeout(() => {
|
|
144
|
+
finishTimerRef.current = null;
|
|
145
|
+
setPhase('hidden');
|
|
146
|
+
}, 200);
|
|
147
|
+
}
|
|
148
|
+
// If already 'finishing' or 'hidden', do nothing
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return () => {
|
|
152
|
+
if (delayTimerRef.current !== null) {
|
|
153
|
+
clearTimeout(delayTimerRef.current);
|
|
154
|
+
delayTimerRef.current = null;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- phase is read but intentionally not a dependency
|
|
158
|
+
}, [isPending, delay]);
|
|
159
|
+
|
|
160
|
+
// Cleanup finish timer on unmount
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
return () => {
|
|
163
|
+
if (finishTimerRef.current !== null) {
|
|
164
|
+
clearTimeout(finishTimerRef.current);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
if (phase === 'hidden') return null;
|
|
170
|
+
|
|
171
|
+
// ─── Styles ──────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
const containerStyle: React.CSSProperties = {
|
|
174
|
+
position: 'fixed',
|
|
175
|
+
top: 0,
|
|
176
|
+
left: 0,
|
|
177
|
+
width: '100%',
|
|
178
|
+
height: `${height}px`,
|
|
179
|
+
zIndex,
|
|
180
|
+
pointerEvents: 'none',
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const barStyle: React.CSSProperties = {
|
|
184
|
+
height: '100%',
|
|
185
|
+
backgroundColor: color,
|
|
186
|
+
...(phase === 'crawling'
|
|
187
|
+
? {
|
|
188
|
+
// Crawling: animate from 0% to 90% over 30s
|
|
189
|
+
animation: `${KEYFRAMES_NAME} 30s ease-out forwards`,
|
|
190
|
+
}
|
|
191
|
+
: {
|
|
192
|
+
// Finishing: snap to 100% and fade out
|
|
193
|
+
width: '100%',
|
|
194
|
+
opacity: 0,
|
|
195
|
+
transition: 'width 200ms ease, opacity 200ms ease',
|
|
196
|
+
}),
|
|
197
|
+
...(shadow
|
|
198
|
+
? {
|
|
199
|
+
boxShadow: `0 0 10px ${color}, 0 0 5px ${color}`,
|
|
200
|
+
}
|
|
201
|
+
: {}),
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return createElement(
|
|
205
|
+
'div',
|
|
206
|
+
{
|
|
207
|
+
style: containerStyle,
|
|
208
|
+
'aria-hidden': 'true',
|
|
209
|
+
'data-timber-top-loader': '',
|
|
210
|
+
},
|
|
211
|
+
createElement('div', { style: barStyle })
|
|
212
|
+
);
|
|
213
|
+
}
|
|
@@ -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
|
@@ -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: 150. */
|
|
133
|
+
delay?: number;
|
|
134
|
+
/** CSS z-index. Default: 1600. */
|
|
135
|
+
zIndex?: number;
|
|
136
|
+
};
|
|
113
137
|
}
|
|
114
138
|
|
|
115
139
|
/**
|
package/src/plugins/entries.ts
CHANGED