@timber-js/app 0.2.0-alpha.7 → 0.2.0-alpha.9

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.
Files changed (67) hide show
  1. package/dist/_chunks/{tracing-Cwn7697K.js → tracing-CemImE6h.js} +16 -2
  2. package/dist/_chunks/{tracing-Cwn7697K.js.map → tracing-CemImE6h.js.map} +1 -1
  3. package/dist/adapters/nitro.d.ts.map +1 -1
  4. package/dist/adapters/nitro.js.map +1 -1
  5. package/dist/cache/fast-hash.d.ts +22 -0
  6. package/dist/cache/fast-hash.d.ts.map +1 -0
  7. package/dist/cache/index.js +51 -9
  8. package/dist/cache/index.js.map +1 -1
  9. package/dist/cache/register-cached-function.d.ts.map +1 -1
  10. package/dist/cache/timber-cache.d.ts.map +1 -1
  11. package/dist/client/index.js.map +1 -1
  12. package/dist/client/link.d.ts.map +1 -1
  13. package/dist/client/router.d.ts.map +1 -1
  14. package/dist/client/segment-context.d.ts +1 -1
  15. package/dist/client/segment-context.d.ts.map +1 -1
  16. package/dist/client/segment-merger.d.ts.map +1 -1
  17. package/dist/client/stale-reload.d.ts.map +1 -1
  18. package/dist/client/top-loader.d.ts.map +1 -1
  19. package/dist/client/transition-root.d.ts +1 -1
  20. package/dist/client/transition-root.d.ts.map +1 -1
  21. package/dist/index.d.ts +0 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +38 -34
  24. package/dist/index.js.map +1 -1
  25. package/dist/plugins/fonts.d.ts +7 -0
  26. package/dist/plugins/fonts.d.ts.map +1 -1
  27. package/dist/server/action-client.d.ts.map +1 -1
  28. package/dist/server/index.js +9 -1
  29. package/dist/server/index.js.map +1 -1
  30. package/dist/server/pipeline.d.ts.map +1 -1
  31. package/dist/server/response-cache.d.ts +5 -4
  32. package/dist/server/response-cache.d.ts.map +1 -1
  33. package/dist/server/route-element-builder.d.ts.map +1 -1
  34. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  35. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  36. package/dist/server/rsc-entry/rsc-stream.d.ts +6 -0
  37. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  38. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  39. package/dist/server/tracing.d.ts +10 -0
  40. package/dist/server/tracing.d.ts.map +1 -1
  41. package/dist/server/waituntil-bridge.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/adapters/nitro.ts +6 -1
  44. package/src/cache/fast-hash.ts +34 -0
  45. package/src/cache/register-cached-function.ts +7 -3
  46. package/src/cache/timber-cache.ts +17 -10
  47. package/src/client/browser-entry.ts +10 -6
  48. package/src/client/link.tsx +14 -9
  49. package/src/client/router.ts +4 -6
  50. package/src/client/segment-context.ts +6 -1
  51. package/src/client/segment-merger.ts +2 -8
  52. package/src/client/stale-reload.ts +4 -6
  53. package/src/client/top-loader.tsx +8 -7
  54. package/src/client/transition-root.tsx +7 -1
  55. package/src/index.ts +1 -2
  56. package/src/plugins/entries.ts +1 -1
  57. package/src/plugins/fonts.ts +54 -43
  58. package/src/server/action-client.ts +7 -1
  59. package/src/server/pipeline.ts +7 -0
  60. package/src/server/response-cache.ts +169 -36
  61. package/src/server/route-element-builder.ts +1 -6
  62. package/src/server/rsc-entry/index.ts +9 -6
  63. package/src/server/rsc-entry/rsc-payload.ts +42 -10
  64. package/src/server/rsc-entry/rsc-stream.ts +9 -5
  65. package/src/server/rsc-entry/ssr-renderer.ts +11 -8
  66. package/src/server/tracing.ts +23 -0
  67. package/src/server/waituntil-bridge.ts +4 -1
@@ -36,8 +36,15 @@ const VIRTUAL_LOCAL = '@timber/fonts/local';
36
36
  const RESOLVED_GOOGLE = '\0@timber/fonts/google';
37
37
  const RESOLVED_LOCAL = '\0@timber/fonts/local';
38
38
 
39
- /** URL path where font CSS is served (dev middleware and prod asset). */
40
- const FONT_CSS_PATH = '/_timber/fonts/fonts.css';
39
+ /**
40
+ * Virtual module that exports the combined font CSS string.
41
+ *
42
+ * The RSC entry imports this module and inlines it as a <style> tag.
43
+ * Unlike a config-based approach, this module is loaded lazily (on first
44
+ * request), so it always has up-to-date font data from the registry.
45
+ */
46
+ const VIRTUAL_FONT_CSS = 'virtual:timber-font-css';
47
+ const RESOLVED_FONT_CSS = '\0virtual:timber-font-css';
41
48
 
42
49
  /**
43
50
  * Registry of fonts extracted during transform.
@@ -247,6 +254,33 @@ function generateLocalVirtualModule(): string {
247
254
  ].join('\n');
248
255
  }
249
256
 
257
+ /**
258
+ * Generate CSS for a single extracted font.
259
+ *
260
+ * Includes @font-face rules (for local fonts), fallback @font-face,
261
+ * and the scoped class rule.
262
+ */
263
+ export function generateFontCss(font: ExtractedFont): string {
264
+ const cssParts: string[] = [];
265
+
266
+ if (font.provider === 'local' && font.localSources) {
267
+ const faces = generateLocalFontFaces(font.family, font.localSources, font.display);
268
+ const faceCss = generateFontFaces(faces);
269
+ if (faceCss) cssParts.push(faceCss);
270
+ }
271
+
272
+ const fallbackCss = generateFallbackCss(font.family);
273
+ if (fallbackCss) cssParts.push(fallbackCss);
274
+
275
+ if (font.variable) {
276
+ cssParts.push(generateVariableClass(font.className, font.variable, font.fontFamily));
277
+ } else {
278
+ cssParts.push(generateFontFamilyClass(font.className, font.fontFamily));
279
+ }
280
+
281
+ return cssParts.join('\n\n');
282
+ }
283
+
250
284
  /**
251
285
  * Generate the CSS output for all extracted fonts.
252
286
  *
@@ -255,27 +289,9 @@ function generateLocalVirtualModule(): string {
255
289
  */
256
290
  export function generateAllFontCss(registry: FontRegistry): string {
257
291
  const cssParts: string[] = [];
258
-
259
292
  for (const font of registry.values()) {
260
- // Generate @font-face rules for local fonts
261
- if (font.provider === 'local' && font.localSources) {
262
- const faces = generateLocalFontFaces(font.family, font.localSources, font.display);
263
- const faceCss = generateFontFaces(faces);
264
- if (faceCss) cssParts.push(faceCss);
265
- }
266
-
267
- // Generate fallback @font-face if metrics are available
268
- const fallbackCss = generateFallbackCss(font.family);
269
- if (fallbackCss) cssParts.push(fallbackCss);
270
-
271
- // Generate scoped class
272
- if (font.variable) {
273
- cssParts.push(generateVariableClass(font.className, font.variable, font.fontFamily));
274
- } else {
275
- cssParts.push(generateFontFamilyClass(font.className, font.fontFamily));
276
- }
293
+ cssParts.push(generateFontCss(font));
277
294
  }
278
-
279
295
  return cssParts.join('\n\n');
280
296
  }
281
297
 
@@ -372,20 +388,32 @@ export function timberFonts(ctx: PluginContext): Plugin {
372
388
  name: 'timber-fonts',
373
389
 
374
390
  /**
375
- * Resolve `@timber/fonts/google` and `@timber/fonts/local` to virtual modules.
391
+ * Resolve `@timber/fonts/google`, `@timber/fonts/local`,
392
+ * and `virtual:timber-font-css` virtual modules.
376
393
  */
377
394
  resolveId(id: string) {
378
395
  if (id === VIRTUAL_GOOGLE) return RESOLVED_GOOGLE;
379
396
  if (id === VIRTUAL_LOCAL) return RESOLVED_LOCAL;
397
+ if (id === VIRTUAL_FONT_CSS) return RESOLVED_FONT_CSS;
380
398
  return null;
381
399
  },
382
400
 
383
401
  /**
384
402
  * Return generated source for font virtual modules.
403
+ *
404
+ * `virtual:timber-font-css` exports the combined @font-face CSS
405
+ * as a string. The RSC entry imports it and inlines a <style> tag.
406
+ * Because this is loaded lazily (on first request), the font
407
+ * registry is always populated by the time it's needed.
385
408
  */
386
409
  load(id: string) {
387
410
  if (id === RESOLVED_GOOGLE) return generateGoogleVirtualModule(registry);
388
411
  if (id === RESOLVED_LOCAL) return generateLocalVirtualModule();
412
+
413
+ if (id === RESOLVED_FONT_CSS) {
414
+ const css = generateAllFontCss(registry);
415
+ return `export default ${JSON.stringify(css)};`;
416
+ }
389
417
  return null;
390
418
  },
391
419
 
@@ -412,14 +440,8 @@ export function timberFonts(ctx: PluginContext): Plugin {
412
440
  return;
413
441
  }
414
442
 
415
- // Serve generated font CSS
416
- if (requestedFilename === 'fonts.css') {
417
- const css = generateAllFontCss(registry);
418
- res.setHeader('Content-Type', 'text/css');
419
- res.setHeader('Cache-Control', 'no-cache');
420
- res.end(css);
421
- return;
422
- }
443
+ // Font CSS is now injected via Vite's CSS pipeline (virtual:timber-font-css modules).
444
+ // This middleware only serves font binary files (woff2, etc.).
423
445
 
424
446
  // Find the matching font file in the registry
425
447
  for (const font of registry.values()) {
@@ -578,10 +600,6 @@ export function timberFonts(ctx: PluginContext): Plugin {
578
600
  }
579
601
 
580
602
  if (transformedCode !== code) {
581
- // Mark that fonts are in use so the RSC entry injects a <link> tag.
582
- if (registry.size > 0 && !ctx.fontCssUrl) {
583
- ctx.fontCssUrl = FONT_CSS_PATH;
584
- }
585
603
  return { code: transformedCode, map: null };
586
604
  }
587
605
 
@@ -627,15 +645,8 @@ export function timberFonts(ctx: PluginContext): Plugin {
627
645
  }
628
646
  }
629
647
 
630
- // Emit the combined font CSS as an asset
631
- const fontCss = generateAllFontCss(registry);
632
- if (fontCss) {
633
- this.emitFile({
634
- type: 'asset',
635
- fileName: '_timber/fonts/fonts.css',
636
- source: fontCss,
637
- });
638
- }
648
+ // Font CSS is emitted by Vite's CSS pipeline via virtual:timber-font-css modules.
649
+ // We only need to emit font binary files and update the build manifest here.
639
650
 
640
651
  if (!ctx.buildManifest) return;
641
652
 
@@ -295,8 +295,14 @@ export function createActionClient<TCtx = Record<string, never>>(
295
295
  // Determine input — either FormData (from useActionState) or direct arg
296
296
  let rawInput: unknown;
297
297
  if (args.length === 2 && args[1] instanceof FormData) {
298
- // Called as (prevState, formData) by React useActionState
298
+ // Called as (prevState, formData) by React useActionState (with-JS path)
299
299
  rawInput = schema ? parseFormData(args[1]) : args[1];
300
+ } else if (args.length === 1 && args[0] instanceof FormData) {
301
+ // No-JS path: React's decodeAction binds FormData as the sole argument.
302
+ // The form POSTs without JavaScript, decodeAction resolves the server
303
+ // reference and binds the FormData, then executeAction calls fn() with
304
+ // no additional args — so the bound FormData arrives as args[0].
305
+ rawInput = schema ? parseFormData(args[0]) : args[0];
300
306
  } else {
301
307
  // Direct call: action(input)
302
308
  rawInput = args[0];
@@ -487,7 +487,14 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
487
487
  return new Response(null, { status: error.status });
488
488
  }
489
489
  // RedirectSignal leaked from render — honour the redirect.
490
+ // For RSC payload requests, return 204 + X-Timber-Redirect so the
491
+ // client router can perform a soft SPA redirect (same as middleware path).
490
492
  if (error instanceof RedirectSignal) {
493
+ const isRsc = (req.headers.get('Accept') ?? '').includes('text/x-component');
494
+ if (isRsc) {
495
+ responseHeaders.set('X-Timber-Redirect', error.location);
496
+ return new Response(null, { status: 204, headers: responseHeaders });
497
+ }
491
498
  responseHeaders.set('Location', error.location);
492
499
  return new Response(null, { status: error.status, headers: responseHeaders });
493
500
  }
@@ -10,10 +10,11 @@
10
10
  * re-executing the RSC-to-SSR pipeline. Entries have a short TTL
11
11
  * (default 5s) and the cache has a bounded size (default 150 entries).
12
12
  *
13
- * Cache keys are compound: method + pathname + isRscPayload. Responses
14
- * with Set-Cookie headers are never cached (they contain user-specific
15
- * state). When `publicOnly` is true (default), requests with Cookie or
16
- * Authorization headers bypass the cache entirely.
13
+ * Cache keys are compound: pathname + search + isRscPayload + Vary'd headers.
14
+ * Only GET requests are cached. Responses with Set-Cookie, Cache-Control:
15
+ * no-store/private, or error/redirect status codes are never cached.
16
+ * When `publicOnly` is true (default), requests with Cookie or Authorization
17
+ * headers bypass the cache entirely.
17
18
  *
18
19
  * See design/02-rendering-pipeline.md, design/31-benchmarking.md.
19
20
  */
@@ -65,16 +66,11 @@ interface CacheEntry {
65
66
  headers: [string, string][];
66
67
  /** Timestamp when this entry was created. */
67
68
  createdAt: number;
68
- }
69
-
70
- // ─── Singleflight Result ───────────────────────────────────────────────────
71
-
72
- /** Internal type: singleflight returns either a raw response or a cache entry. */
73
- interface SingleflightResult {
74
- /** Non-null when the response wasn't cacheable — only the first caller gets it. */
75
- response: Response | null;
76
- /** Non-null when the response was cached — all callers construct from this. */
77
- entry: CacheEntry | null;
69
+ /**
70
+ * The Vary header value from the original response, if any.
71
+ * Used to build variant-aware cache keys for subsequent requests.
72
+ */
73
+ vary: string | null;
78
74
  }
79
75
 
80
76
  // ─── LRU Cache ─────────────────────────────────────────────────────────────
@@ -154,6 +150,59 @@ export interface ResponseCache {
154
150
  clear(): void;
155
151
  }
156
152
 
153
+ // ─── Cache-Control parsing ─────────────────────────────────────────────────
154
+
155
+ /**
156
+ * Check if a Cache-Control header value contains directives that forbid
157
+ * storing the response in a shared cache. We check for `no-store` and
158
+ * `private` — both indicate the response must not be reused.
159
+ *
160
+ * This is intentionally simple: we don't parse `max-age`, `s-maxage`,
161
+ * `must-revalidate`, etc. This is a short-TTL render cache, not an HTTP
162
+ * cache — we just need to respect explicit "don't cache this" signals.
163
+ */
164
+ function hasCacheControlNoStore(headerValue: string | null): boolean {
165
+ if (!headerValue) return false;
166
+ // Split on comma, trim whitespace, check for no-store or private directives.
167
+ // Case-insensitive per HTTP spec.
168
+ const lower = headerValue.toLowerCase();
169
+ return lower.includes('no-store') || lower.includes('private');
170
+ }
171
+
172
+ // ─── Vary header handling ──────────────────────────────────────────────────
173
+
174
+ /**
175
+ * Parse a Vary header value into a sorted list of header names.
176
+ * Returns null if there is no Vary header or it's empty.
177
+ * Returns ['*'] if Vary: * (meaning the response varies on everything —
178
+ * effectively uncacheable).
179
+ */
180
+ function parseVaryHeader(headerValue: string | null): string[] | null {
181
+ if (!headerValue) return null;
182
+ const trimmed = headerValue.trim();
183
+ if (trimmed === '') return null;
184
+ if (trimmed === '*') return ['*'];
185
+
186
+ // Split on comma, normalize to lowercase, sort for deterministic keys
187
+ return trimmed
188
+ .split(',')
189
+ .map((h) => h.trim().toLowerCase())
190
+ .filter((h) => h.length > 0)
191
+ .sort();
192
+ }
193
+
194
+ /**
195
+ * Build a Vary-aware suffix for the cache key. For each header name in the
196
+ * Vary list, append the request's value for that header. This ensures that
197
+ * requests with different Accept-Language (for example) get different cache
198
+ * entries.
199
+ */
200
+ function buildVarySuffix(req: Request, varyHeaders: string[]): string {
201
+ return varyHeaders.map((h) => `${h}=${req.headers.get(h) ?? ''}`).join('&');
202
+ }
203
+
204
+ // ─── Factory ───────────────────────────────────────────────────────────────
205
+
157
206
  /**
158
207
  * Create a response cache with singleflight deduplication and LRU caching.
159
208
  */
@@ -161,7 +210,22 @@ export function createResponseCache(config: ResolvedResponseCacheConfig): Respon
161
210
  const lru = new LruCache(config.maxSize, config.ttlMs);
162
211
  const singleflight = createSingleflight();
163
212
 
213
+ /**
214
+ * Known Vary headers per path. When a response includes a Vary header,
215
+ * we store the parsed header names so that subsequent requests to the
216
+ * same path can include the Vary'd header values in their cache key
217
+ * BEFORE rendering (i.e., on cache lookup, not just after the first
218
+ * render). This avoids the "first request always misses" problem for
219
+ * Vary'd responses.
220
+ */
221
+ const knownVaryHeaders = new Map<string, string[]>();
222
+
164
223
  function buildCacheKey(req: Request, isRscPayload: boolean): string | null {
224
+ // Never cache non-GET requests. POST/PUT/DELETE have side effects and
225
+ // may carry per-request state (e.g., form flash data via ALS) that makes
226
+ // the rendered output unique even for the same URL.
227
+ if (req.method !== 'GET') return null;
228
+
165
229
  // When publicOnly is true, skip caching for authenticated requests
166
230
  if (config.publicOnly) {
167
231
  if (req.headers.has('Cookie') || req.headers.has('Authorization')) {
@@ -170,13 +234,30 @@ export function createResponseCache(config: ResolvedResponseCacheConfig): Respon
170
234
  }
171
235
 
172
236
  const url = new URL(req.url);
173
- return `${req.method}:${url.pathname}:${isRscPayload ? 'rsc' : 'html'}`;
237
+ // Include search params in the cache key. Pages that use searchParams
238
+ // (e.g., ?sort=asc, ?page=2) produce different output per query string.
239
+ let key = `${url.pathname}${url.search}:${isRscPayload ? 'rsc' : 'html'}`;
240
+
241
+ // If we've seen a Vary header for this path before, include the varied
242
+ // request header values in the key so different variants get different
243
+ // cache entries.
244
+ const pathKey = `${url.pathname}:${isRscPayload ? 'rsc' : 'html'}`;
245
+ const varyHeaders = knownVaryHeaders.get(pathKey);
246
+ if (varyHeaders) {
247
+ if (varyHeaders[0] === '*') {
248
+ // Vary: * means the response varies on everything — uncacheable
249
+ return null;
250
+ }
251
+ key += ':' + buildVarySuffix(req, varyHeaders);
252
+ }
253
+
254
+ return key;
174
255
  }
175
256
 
176
257
  /**
177
258
  * Check if a response is cacheable.
178
- * Responses with Set-Cookie headers are never cached — they contain
179
- * user-specific state that must not be shared across requests.
259
+ * Responses with Set-Cookie headers, Cache-Control: no-store/private,
260
+ * error/redirect status codes, or Vary: * are never cached.
180
261
  */
181
262
  function isCacheable(response: Response): boolean {
182
263
  // Don't cache error responses
@@ -188,6 +269,14 @@ export function createResponseCache(config: ResolvedResponseCacheConfig): Respon
188
269
  // Don't cache responses with Set-Cookie (user-specific state)
189
270
  if (response.headers.has('Set-Cookie')) return false;
190
271
 
272
+ // Respect Cache-Control: no-store and private directives.
273
+ // If the application explicitly says "don't cache this," we obey.
274
+ if (hasCacheControlNoStore(response.headers.get('Cache-Control'))) return false;
275
+
276
+ // Vary: * means the response varies on everything — don't cache
277
+ const vary = parseVaryHeader(response.headers.get('Vary'));
278
+ if (vary && vary[0] === '*') return false;
279
+
191
280
  // Only cache responses with a body
192
281
  if (!response.body) return false;
193
282
 
@@ -196,13 +285,27 @@ export function createResponseCache(config: ResolvedResponseCacheConfig): Respon
196
285
 
197
286
  /** Construct a fresh Response from a cache entry (each caller gets their own). */
198
287
  function responseFromEntry(entry: CacheEntry): Response {
288
+ // Null-body statuses (204, 304) cannot have a body per HTTP spec.
289
+ // The Response constructor throws if you pass a body with these statuses.
290
+ const isNullBody = entry.status === 204 || entry.status === 304;
199
291
  // slice(0) creates a copy so each caller owns their buffer
200
- return new Response(entry.body.slice(0), {
292
+ return new Response(isNullBody ? null : entry.body.slice(0), {
201
293
  status: entry.status,
202
294
  headers: entry.headers,
203
295
  });
204
296
  }
205
297
 
298
+ /**
299
+ * Record the Vary header from a response so future requests to the same
300
+ * path include varied header values in their cache key.
301
+ */
302
+ function recordVaryHeaders(pathKey: string, response: Response): void {
303
+ const vary = parseVaryHeader(response.headers.get('Vary'));
304
+ if (vary) {
305
+ knownVaryHeaders.set(pathKey, vary);
306
+ }
307
+ }
308
+
206
309
  return {
207
310
  async getOrRender(
208
311
  req: Request,
@@ -211,7 +314,9 @@ export function createResponseCache(config: ResolvedResponseCacheConfig): Respon
211
314
  ): Promise<Response> {
212
315
  const cacheKey = buildCacheKey(req, isRscPayload);
213
316
 
214
- // No cache key = skip caching entirely
317
+ // No cache key = skip caching and singleflight entirely.
318
+ // This covers POST requests, authenticated requests (publicOnly),
319
+ // and Vary: * responses.
215
320
  if (cacheKey === null) {
216
321
  return renderFn();
217
322
  }
@@ -223,18 +328,41 @@ export function createResponseCache(config: ResolvedResponseCacheConfig): Respon
223
328
  }
224
329
 
225
330
  // Singleflight: concurrent requests to the same key share one render.
226
- // The singleflight returns a SingleflightResult so all waiters
227
- // can construct their own Response from the same cached data.
228
- const result: SingleflightResult = await singleflight.do(cacheKey, async () => {
331
+ // We buffer the response body into an ArrayBuffer so ALL callers —
332
+ // including the singleflight leader get independent copies.
333
+ // This fixes the body-loss bug where the leader consumed the body
334
+ // and concurrent waiters got an empty response.
335
+ const result: CacheEntry | null = await singleflight.do(cacheKey, async () => {
229
336
  const response = await renderFn();
230
337
 
338
+ // Record Vary headers for future cache key construction
339
+ const url = new URL(req.url);
340
+ const pathKey = `${url.pathname}:${isRscPayload ? 'rsc' : 'html'}`;
341
+ recordVaryHeaders(pathKey, response);
342
+
231
343
  if (!isCacheable(response)) {
232
- return { response, entry: null };
344
+ // Buffer the body even for non-cacheable responses so the
345
+ // singleflight leader and all concurrent waiters each get
346
+ // an independent copy. Without this, the leader consumes
347
+ // the body stream and waiters get an empty response.
348
+ const body = await response.arrayBuffer();
349
+ const headers: [string, string][] = [];
350
+ response.headers.forEach((value, key) => {
351
+ headers.push([key, value]);
352
+ });
353
+ // Return as a CacheEntry shape but DON'T store in LRU.
354
+ // Callers construct Responses from this, but it won't be
355
+ // reused for future requests.
356
+ return {
357
+ body,
358
+ status: response.status,
359
+ headers,
360
+ createdAt: Date.now(),
361
+ vary: response.headers.get('Vary'),
362
+ };
233
363
  }
234
364
 
235
365
  // Buffer the response body for caching.
236
- // The original Response body is consumed here — callers get copies
237
- // from the cached ArrayBuffer.
238
366
  const body = await response.arrayBuffer();
239
367
  const headers: [string, string][] = [];
240
368
  response.headers.forEach((value, key) => {
@@ -246,24 +374,28 @@ export function createResponseCache(config: ResolvedResponseCacheConfig): Respon
246
374
  status: response.status,
247
375
  headers,
248
376
  createdAt: Date.now(),
377
+ vary: response.headers.get('Vary'),
249
378
  };
250
379
 
251
- lru.set(cacheKey, entry);
380
+ // Re-check the cache key now that we know the Vary headers.
381
+ // The initial key may not have included Vary'd header values
382
+ // if this was the first request to this path. Rebuild the key
383
+ // with the now-known Vary headers for correct LRU storage.
384
+ const updatedKey = buildCacheKey(req, isRscPayload);
385
+ if (updatedKey) {
386
+ lru.set(updatedKey, entry);
387
+ }
252
388
 
253
- return { response: null, entry };
389
+ return entry;
254
390
  });
255
391
 
256
- // Non-cacheable response only the first caller gets the original.
257
- // For singleflight, this means concurrent waiters get the same promise
258
- // result. The first caller already consumed the body, so subsequent
259
- // callers would get an empty body. This is acceptable: non-cacheable
260
- // responses (errors, redirects, Set-Cookie) are rare under concurrent
261
- // identical requests, and the status + headers are still correct.
262
- if (result.response) {
263
- return result.response;
392
+ if (result === null) {
393
+ // Shouldn't happen singleflight always returns a result.
394
+ // Defensive fallback: re-render.
395
+ return renderFn();
264
396
  }
265
397
 
266
- return responseFromEntry(result.entry!);
398
+ return responseFromEntry(result);
267
399
  },
268
400
 
269
401
  get size() {
@@ -272,6 +404,7 @@ export function createResponseCache(config: ResolvedResponseCacheConfig): Respon
272
404
 
273
405
  clear() {
274
406
  lru.clear();
407
+ knownVaryHeaders.clear();
275
408
  },
276
409
  };
277
410
  }
@@ -352,12 +352,7 @@ export async function buildRouteElement(
352
352
  // same urlPath (e.g., /(marketing) and /(app) both have "/"),
353
353
  // which would cause the wrong cached layout to be reused
354
354
  const skip =
355
- shouldSkipSegment(
356
- segment.urlPath,
357
- layoutComponent,
358
- isLeaf,
359
- clientStateTree ?? null
360
- ) &&
355
+ shouldSkipSegment(segment.urlPath, layoutComponent, isLeaf, clientStateTree ?? null) &&
361
356
  hasRenderedLayoutBelow &&
362
357
  segment.segmentType !== 'group';
363
358
 
@@ -23,6 +23,8 @@ import config from 'virtual:timber-config';
23
23
  import buildManifest from 'virtual:timber-build-manifest';
24
24
  // @ts-expect-error — virtual module provided by timber-entries plugin
25
25
  import loadUserInstrumentation from 'virtual:timber-instrumentation';
26
+ // @ts-expect-error — virtual module provided by timber-fonts plugin
27
+ import fontCss from 'virtual:timber-font-css';
26
28
 
27
29
  import type { FormRerender } from '#/server/action-handler.js';
28
30
  import { handleActionRequest, isActionRequest } from '#/server/action-handler.js';
@@ -368,7 +370,8 @@ async function renderRoute(
368
370
  throw error;
369
371
  }
370
372
 
371
- const { element, headElements, layoutComponents, deferSuspenseFor, skippedSegments } = routeResult;
373
+ const { element, headElements, layoutComponents, deferSuspenseFor, skippedSegments } =
374
+ routeResult;
372
375
 
373
376
  // Build head HTML for injection into the SSR output.
374
377
  // Collects CSS, fonts, and modulepreload from the build manifest for matched segments.
@@ -385,11 +388,11 @@ async function renderRoute(
385
388
  headHtml += buildCssLinkTags(cssUrls);
386
389
  }
387
390
 
388
- // Inject font CSS stylesheetpure CSS, no JS needed.
389
- // The URL is set by the timber-fonts plugin when fonts are registered.
390
- const fontCssUrl = (config as { fontCssUrl?: string | null }).fontCssUrl;
391
- if (fontCssUrl) {
392
- headHtml += `<link rel="stylesheet" href="${fontCssUrl}">`;
391
+ // Inline font CSS as a <style> tag @font-face rules and scoped classes.
392
+ // The virtual:timber-font-css module is loaded lazily, so the font registry
393
+ // is always populated by the time we get here (no timing issues).
394
+ if (fontCss) {
395
+ headHtml += `<style data-timber-fonts>${fontCss}</style>`;
393
396
  }
394
397
 
395
398
  const fontEntries = collectRouteFonts(segments, typedManifest);
@@ -45,18 +45,45 @@ export async function buildRscPayloadResponse(
45
45
  skippedSegments?: string[]
46
46
  ): Promise<Response> {
47
47
  // Read the first chunk from the RSC stream before committing headers.
48
+ // Race the first read against signal detection — if an async component
49
+ // throws a RedirectSignal or DenySignal, the onError callback fires
50
+ // signals.onSignal() and we can react immediately without waiting for
51
+ // the full macrotask queue.
52
+ //
53
+ // The rejection chain for an async-wrapped page component:
54
+ // 1. PageComponent throws RedirectSignal
55
+ // 2. withSpan catches and re-throws (microtask 1)
56
+ // 3. TracedPage promise rejects (microtask 2)
57
+ // 4. React Flight rejection handler → onError (microtask 3+)
58
+ //
59
+ // Promise.race reacts the instant onError fires, eliminating the
60
+ // per-request setTimeout(0) macrotask delay for the common case
61
+ // (no signal). A 50ms ceiling timeout guards against edge cases
62
+ // where onError never fires.
48
63
  const reader = rscStream.getReader();
49
- const firstRead = await reader.read();
64
+ const signalDetected = new Promise<void>((resolve) => {
65
+ signals.onSignal = resolve;
66
+ });
50
67
 
51
- // Yield to the microtask queue so that async component rejections
52
- // (e.g. an async-wrapped page component that throws redirect())
53
- // propagate to the onError callback before we check the signals.
54
- // The rejected Promise from an async component resolves in the next
55
- // microtask after read(), so we need at least one tick.
56
- //
57
- // Uses queueMicrotask instead of setTimeout(0) to stay within the
58
- // same tick — no full event loop round-trip needed.
59
- await new Promise<void>((r) => queueMicrotask(r));
68
+ type RaceResult =
69
+ | { type: 'data'; chunk: ReadableStreamReadResult<Uint8Array> }
70
+ | { type: 'signal' };
71
+
72
+ const first: RaceResult = await Promise.race([
73
+ reader.read().then((chunk) => ({ type: 'data' as const, chunk })),
74
+ signalDetected.then(() => ({ type: 'signal' as const })),
75
+ ]);
76
+
77
+ // If data arrived first, still check signals — they may have fired
78
+ // concurrently. Also do a final ceiling timeout check for edge cases
79
+ // where the signal fires just after the first read resolves.
80
+ if (first.type === 'data' && !signals.redirectSignal && !signals.denySignal) {
81
+ // Brief yield to let any in-flight microtask rejections complete.
82
+ await new Promise<void>((r) => setTimeout(r, 0));
83
+ }
84
+
85
+ // Detach the callback — no longer needed after this point.
86
+ signals.onSignal = undefined;
60
87
 
61
88
  // Check for redirect/deny signals detected during initial rendering
62
89
  const trackedRedirect = signals.redirectSignal as RedirectSignal | null;
@@ -75,6 +102,11 @@ export async function buildRscPayloadResponse(
75
102
  );
76
103
  }
77
104
 
105
+ // Extract the first chunk from the race result.
106
+ // If the signal won the race, read the first chunk now (the stream
107
+ // was already cancelled above, but we need a firstRead shape below).
108
+ const firstRead = first.type === 'data' ? first.chunk : await reader.read();
109
+
78
110
  // Reconstruct the stream: prepend the buffered first chunk,
79
111
  // then continue piping from the original reader.
80
112
  const patchedStream = new ReadableStream<Uint8Array>({
@@ -24,11 +24,17 @@ import { isDebug } from '#/server/debug.js';
24
24
  *
25
25
  * Signals fire asynchronously via `onError` during stream consumption.
26
26
  * The first signal of each type wins — subsequent signals are ignored.
27
+ *
28
+ * `onSignal` is an optional callback fired when a DenySignal or
29
+ * RedirectSignal is captured. Consumers use it with Promise.race to
30
+ * react immediately instead of polling with setTimeout/queueMicrotask.
27
31
  */
28
32
  export interface RenderSignals {
29
33
  denySignal: DenySignal | null;
30
34
  redirectSignal: RedirectSignal | null;
31
35
  renderError: { error: unknown; status: number } | null;
36
+ /** Callback fired when a redirect or deny signal is captured in onError. */
37
+ onSignal?: () => void;
32
38
  }
33
39
 
34
40
  export interface RscStreamResult {
@@ -67,11 +73,13 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
67
73
  if (isAbortError(error) || req.signal?.aborted) return;
68
74
  if (error instanceof DenySignal) {
69
75
  signals.denySignal = error;
76
+ signals.onSignal?.();
70
77
  // Return structured digest for client-side error boundaries
71
78
  return JSON.stringify({ type: 'deny', status: error.status, data: error.data });
72
79
  }
73
80
  if (error instanceof RedirectSignal) {
74
81
  signals.redirectSignal = error;
82
+ signals.onSignal?.();
75
83
  return JSON.stringify({
76
84
  type: 'redirect',
77
85
  location: error.location,
@@ -98,11 +106,7 @@ export function renderRscStream(element: React.ReactElement, req: Request): RscS
98
106
  // directive isn't at the very top of the file, or the component is
99
107
  // re-exported through a barrel file without 'use client'.
100
108
  // See LOCAL-297.
101
- if (
102
- isDebug() &&
103
- error instanceof Error &&
104
- error.message.includes('Invalid hook call')
105
- ) {
109
+ if (isDebug() && error instanceof Error && error.message.includes('Invalid hook call')) {
106
110
  console.error(
107
111
  '[timber] A React hook was called during RSC rendering. This usually means a ' +
108
112
  "'use client' component is being executed as a server component instead of " +