@timber-js/app 0.1.56 → 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.
@@ -1,56 +1,37 @@
1
1
  /**
2
- * timber-chunks — Vite sub-plugin for intelligent client chunk splitting.
2
+ * timber-chunks — Vite sub-plugin for client chunk configuration.
3
3
  *
4
- * Splits client bundles into cache tiers based on update frequency:
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
- * 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.
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
- * Categorize a module ID into a cache tier chunk name.
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
- * The RSC plugin creates separate entry points for each 'use client' module,
35
- * which manualChunks can't merge. This function is passed as the RSC plugin's
36
- * `clientChunks` callback to group timber internals into a single chunk.
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
- * User client components that are small (< 5KB) are grouped into shared-client
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 assignClientChunk(meta: {
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
- * Uses Vite's per-environment config to apply manualChunks only to
52
- * the client build. The config hook runs before environments are
53
- * created, so we use `environments.client` to target the client.
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;;;;;;;;;;;;;;;;;;;;;GAqBG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAwGnC;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CA4B1D;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,EAAE,EAAE,MAAM,CAAC;IACX,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;CACrB,GAAG,MAAM,GAAG,SAAS,CAerB;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAoBrC"}
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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.1.56",
3
+ "version": "0.1.57",
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",
@@ -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
- // Symbol keys for globalThis storage — prevents collisions with user code
64
- const NAV_CTX_KEY = Symbol.for('__timber_nav_ctx');
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
- const existing = (globalThis as Record<symbol, unknown>)[NAV_CTX_KEY] as
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
- const ctx = React.createContext<NavigationState | null>(null);
75
- (globalThis as Record<symbol, unknown>)[NAV_CTX_KEY] = ctx;
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
- const NAV_STATE_KEY = Symbol.for('__timber_nav_state');
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
- _getNavStateStore().current = state;
126
+ _navState = state;
150
127
  }
151
128
 
152
129
  export function getNavigationState(): NavigationState {
153
- return _getNavStateStore().current;
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
- const existing = (globalThis as Record<symbol, unknown>)[PENDING_CTX_KEY] as
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
- const ctx = React.createContext<string | null>(null);
177
- (globalThis as Record<symbol, unknown>)[PENDING_CTX_KEY] = ctx;
178
- return ctx;
146
+ _pendingNavCtx = React.createContext<string | null>(null);
147
+ return _pendingNavCtx;
179
148
  }
180
149
  return undefined;
181
150
  }
@@ -13,8 +13,13 @@
13
13
  * width over ~30s using ease-out timing. When navigation completes, the bar
14
14
  * snaps to 100% and fades out over 200ms. No JS animation loops (RAF, setInterval).
15
15
  *
16
- * The bar respects a configurable delay (default 150ms) — if navigation
17
- * resolves faster than the delay, the bar never appears.
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.
18
23
  *
19
24
  * See design/19-client-navigation.md §"useNavigationPending()"
20
25
  * See LOCAL-336 for design decisions.
@@ -22,7 +27,7 @@
22
27
 
23
28
  'use client';
24
29
 
25
- import { useState, useEffect, useRef, createElement } from 'react';
30
+ import { useState, createElement } from 'react';
26
31
  import { usePendingNavigationUrl } from './navigation-context.js';
27
32
 
28
33
  // ─── Types ───────────────────────────────────────────────────────
@@ -36,7 +41,7 @@ export interface TopLoaderConfig {
36
41
  height?: number;
37
42
  /** Show subtle glow/shadow effect. Default: true. */
38
43
  shadow?: boolean;
39
- /** Delay in ms before showing the bar. Default: 150. */
44
+ /** Delay in ms before showing the bar. Default: 0. */
40
45
  delay?: number;
41
46
  /** CSS z-index. Default: 1600. */
42
47
  zIndex?: number;
@@ -47,20 +52,22 @@ export interface TopLoaderConfig {
47
52
  const DEFAULT_COLOR = '#2299DD';
48
53
  const DEFAULT_HEIGHT = 3;
49
54
  const DEFAULT_SHADOW = true;
50
- const DEFAULT_DELAY = 150;
55
+ const DEFAULT_DELAY = 0;
51
56
  const DEFAULT_Z_INDEX = 1600;
52
57
 
53
- // ─── Keyframes ID ────────────────────────────────────────────────
58
+ // ─── Keyframes ───────────────────────────────────────────────────
54
59
 
55
60
  // Unique keyframes name to avoid collisions with user styles.
56
- const KEYFRAMES_NAME = '__timber_top_loader_crawl';
61
+ const CRAWL_KEYFRAMES = '__timber_top_loader_crawl';
62
+ const APPEAR_KEYFRAMES = '__timber_top_loader_appear';
57
63
 
58
- // Track whether the @keyframes rule has been injected into the document.
64
+ // Track whether the @keyframes rules have been injected into the document.
59
65
  let keyframesInjected = false;
60
66
 
61
67
  /**
62
- * Inject the @keyframes rule into the document head once.
63
- * Uses a <style> tag so the animation is available for inline-styled elements.
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.
64
71
  */
65
72
  function ensureKeyframes(): void {
66
73
  if (keyframesInjected) return;
@@ -68,10 +75,14 @@ function ensureKeyframes(): void {
68
75
 
69
76
  const style = document.createElement('style');
70
77
  style.textContent = `
71
- @keyframes ${KEYFRAMES_NAME} {
78
+ @keyframes ${CRAWL_KEYFRAMES} {
72
79
  0% { width: 0%; }
73
80
  100% { width: 90%; }
74
81
  }
82
+ @keyframes ${APPEAR_KEYFRAMES} {
83
+ from { opacity: 0; }
84
+ to { opacity: 1; }
85
+ }
75
86
  `;
76
87
  document.head.appendChild(style);
77
88
  keyframesInjected = true;
@@ -83,8 +94,15 @@ function ensureKeyframes(): void {
83
94
  * Internal top-loader component. Injected by TransitionRoot.
84
95
  *
85
96
  * Reads pending navigation state from PendingNavigationContext.
86
- * Shows nothing when no navigation is pending or when the navigation
87
- * resolves before the delay threshold.
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.
88
106
  */
89
107
  export function TopLoader({ config }: { config?: TopLoaderConfig }): React.ReactElement | null {
90
108
  const pendingUrl = usePendingNavigationUrl();
@@ -96,75 +114,23 @@ export function TopLoader({ config }: { config?: TopLoaderConfig }): React.React
96
114
  const delay = config?.delay ?? DEFAULT_DELAY;
97
115
  const zIndex = config?.zIndex ?? DEFAULT_Z_INDEX;
98
116
 
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
117
  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
118
 
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
- }
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.
139
122
 
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
- }
123
+ if (isPending && (phase === 'hidden' || phase === 'finishing')) {
124
+ setPhase('crawling');
125
+ }
126
+ if (!isPending && phase === 'crawling') {
127
+ setPhase('finishing');
128
+ }
150
129
 
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
- }, []);
130
+ // Inject keyframes on first visible render (idempotent)
131
+ if (phase !== 'hidden') {
132
+ ensureKeyframes();
133
+ }
168
134
 
169
135
  if (phase === 'hidden') return null;
170
136
 
@@ -185,11 +151,17 @@ export function TopLoader({ config }: { config?: TopLoaderConfig }): React.React
185
151
  backgroundColor: color,
186
152
  ...(phase === 'crawling'
187
153
  ? {
188
- // Crawling: animate from 0% to 90% over 30s
189
- animation: `${KEYFRAMES_NAME} 30s ease-out forwards`,
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(', '),
190
162
  }
191
163
  : {
192
- // Finishing: snap to 100% and fade out
164
+ // Finishing: snap to 100% and fade out over 200ms.
193
165
  width: '100%',
194
166
  opacity: 0,
195
167
  transition: 'width 200ms ease, opacity 200ms ease',
@@ -201,6 +173,17 @@ export function TopLoader({ config }: { config?: TopLoaderConfig }): React.React
201
173
  : {}),
202
174
  };
203
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
+
204
187
  return createElement(
205
188
  'div',
206
189
  {
@@ -208,6 +191,6 @@ export function TopLoader({ config }: { config?: TopLoaderConfig }): React.React
208
191
  'aria-hidden': 'true',
209
192
  'data-timber-top-loader': '',
210
193
  },
211
- createElement('div', { style: barStyle })
194
+ createElement('div', { style: barStyle, onTransitionEnd: handleTransitionEnd })
212
195
  );
213
196
  }
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, assignClientChunk } from './plugins/chunks';
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';
@@ -129,7 +129,7 @@ export interface TimberUserConfig {
129
129
  height?: number;
130
130
  /** Show subtle glow/shadow effect. Default: true. */
131
131
  shadow?: boolean;
132
- /** Delay in ms before showing the bar (avoids flash on fast navs). Default: 150. */
132
+ /** Delay in ms before showing the bar (avoids flash on fast navs). Default: 0. */
133
133
  delay?: number;
134
134
  /** CSS z-index. Default: 1600. */
135
135
  zIndex?: number;
@@ -400,7 +400,8 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
400
400
  ssr: 'virtual:timber-ssr-entry',
401
401
  client: 'virtual:timber-browser-entry',
402
402
  },
403
- clientChunks: assignClientChunk,
403
+ // No custom clientChunks — Rolldown handles natural code splitting.
404
+ // See design/27-chunking-strategy.md and LOCAL-337.
404
405
  });
405
406
  }
406
407
  );