@timber-js/app 0.1.54 → 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"}
@@ -39,5 +39,5 @@ export declare function parseClientStateTree(req: Request): Set<string> | null;
39
39
  * @param isLeaf - Whether this is the leaf segment (page segment)
40
40
  * @param clientSegments - Set of paths from X-Timber-State-Tree, or null
41
41
  */
42
- export declare function shouldSkipSegment(urlPath: string, layoutComponent: ((...args: unknown[]) => unknown) | undefined, isLeaf: boolean, clientSegments: Set<string> | null): boolean;
42
+ export declare function shouldSkipSegment(_urlPath: string, _layoutComponent: ((...args: unknown[]) => unknown) | undefined, _isLeaf: boolean, _clientSegments: Set<string> | null): boolean;
43
43
  //# sourceMappingURL=state-tree-diff.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"state-tree-diff.d.ts","sourceRoot":"","sources":["../../src/server/state-tree-diff.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAarE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,eAAe,EAAE,CAAC,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,GAAG,SAAS,EAC9D,MAAM,EAAE,OAAO,EACf,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GACjC,OAAO,CAeT"}
1
+ {"version":3,"file":"state-tree-diff.d.ts","sourceRoot":"","sources":["../../src/server/state-tree-diff.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAarE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,gBAAgB,EAAE,CAAC,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,GAAG,SAAS,EAC/D,OAAO,EAAE,OAAO,EAChB,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GAClC,OAAO,CAeT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.1.54",
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",
@@ -341,20 +341,24 @@ export function generateNitroEntry(
341
341
  return `// Generated by @timber-js/app/adapters/nitro
342
342
  // Do not edit — this file is regenerated on each build.
343
343
 
344
- ${manifestImport}import { defineEventHandler } from 'nitro/h3'
345
- import handler, { runWithEarlyHintsSender${waitUntilImport} } from '${serverEntryRelative}'
344
+ ${manifestImport}import handler, { runWithEarlyHintsSender${waitUntilImport} } from '${serverEntryRelative}'
346
345
  import { compressResponse } from './_compress.mjs'
347
346
 
348
347
  // Set TIMBER_RUNTIME for instrumentation.ts conditional SDK initialization.
349
348
  // See design/25-production-deployments.md §"TIMBER_RUNTIME".
350
349
  process.env.TIMBER_RUNTIME = '${runtimeName}'
351
350
 
352
- export default defineEventHandler(async (event) => {
351
+ // Nitro's index.mjs wraps this import with defineLazyEventHandler, which
352
+ // already handles the event handler protocol. We export a plain async
353
+ // function — no defineEventHandler wrapper needed.
354
+ // Nitro bundles h3 into its internal _libs/ directory but doesn't make
355
+ // it available as a bare specifier in the output's node_modules.
356
+ export default async function timberHandler(event) {
353
357
  // h3 v2: event.req is the Web Request
354
358
  const webRequest = event.req
355
359
  ${handlerCall}
356
360
  return compressResponse(webRequest, webResponse)
357
- })
361
+ }
358
362
  `;
359
363
  }
360
364
 
@@ -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 [
@@ -55,23 +55,23 @@ export function parseClientStateTree(req: Request): Set<string> | null {
55
55
  * @param clientSegments - Set of paths from X-Timber-State-Tree, or null
56
56
  */
57
57
  export function shouldSkipSegment(
58
- urlPath: string,
59
- layoutComponent: ((...args: unknown[]) => unknown) | undefined,
60
- isLeaf: boolean,
61
- clientSegments: Set<string> | null
58
+ _urlPath: string,
59
+ _layoutComponent: ((...args: unknown[]) => unknown) | undefined,
60
+ _isLeaf: boolean,
61
+ _clientSegments: Set<string> | null
62
62
  ): boolean {
63
- // No state tree → full render (initial load, refresh, etc.)
64
- if (!clientSegments) return false;
65
-
66
- // Leaf segments (pages) are never skipped
67
- if (isLeaf) return false;
68
-
69
- // No layout → nothing to skip
70
- if (!layoutComponent) return false;
71
-
72
- // Async layouts always re-render (they may depend on request context)
73
- if (layoutComponent.constructor?.name === 'AsyncFunction') return false;
74
-
75
- // Skip if the client already has this segment cached
76
- return clientSegments.has(urlPath);
63
+ // DISABLED: Client-side segment merging via React element tree walking
64
+ // is too fragile for production. The merger's replaceInnerSegment relies
65
+ // on walking RSC-decoded element trees to find SegmentProvider boundaries,
66
+ // but production trees contain structures the walker can't handle:
67
+ // Suspense wrappers, error boundaries, AccessGate components, React lazy
68
+ // refs, and client component module references.
69
+ //
70
+ // The merger infrastructure (segment-merger.ts, element cache, state tree
71
+ // filtering) is in place and tested. Re-enable when the merger can handle
72
+ // real RSC element trees — likely requires a SegmentOutlet client component
73
+ // approach instead of post-hoc element tree walking.
74
+ //
75
+ // See design/19-client-navigation.md §"X-Timber-State-Tree Header"
76
+ return false;
77
77
  }