@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.
@@ -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;AAyHhD;;;;;;;;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"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.1.55",
3
+ "version": "0.1.56",
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",
@@ -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
- return createElement(PendingNavigationProvider, { value: pendingUrl }, element);
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
  /**
@@ -109,6 +109,7 @@ function generateConfigModule(ctx: PluginContext): string {
109
109
  slowPhaseMs: ctx.config.dev?.slowPhaseMs ?? 200,
110
110
  slowRequestMs: ctx.config.slowRequestMs ?? 3000,
111
111
  cookieSecrets,
112
+ topLoader: ctx.config.topLoader,
112
113
  };
113
114
 
114
115
  return [