@timber-js/app 0.2.0-alpha.94 → 0.2.0-alpha.96

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 (49) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/{interception-DSv3A2Zn.js → interception-BsLCA9gk.js} +4 -4
  3. package/dist/_chunks/interception-BsLCA9gk.js.map +1 -0
  4. package/dist/_chunks/{ssr-data-DzuI0bIV.js → router-ref-C8OCm7g7.js} +26 -2
  5. package/dist/_chunks/router-ref-C8OCm7g7.js.map +1 -0
  6. package/dist/_chunks/{use-params-Br9YSUFV.js → use-params-IOPu7E8t.js} +3 -27
  7. package/dist/_chunks/use-params-IOPu7E8t.js.map +1 -0
  8. package/dist/client/browser-dev.d.ts +7 -0
  9. package/dist/client/browser-dev.d.ts.map +1 -1
  10. package/dist/client/error-boundary.d.ts.map +1 -1
  11. package/dist/client/error-boundary.js +16 -2
  12. package/dist/client/error-boundary.js.map +1 -1
  13. package/dist/client/index.js +2 -2
  14. package/dist/client/internal.js +2 -2
  15. package/dist/fonts/pipeline.d.ts +29 -0
  16. package/dist/fonts/pipeline.d.ts.map +1 -1
  17. package/dist/fonts/transform.d.ts +0 -8
  18. package/dist/fonts/transform.d.ts.map +1 -1
  19. package/dist/fonts/virtual-modules.d.ts +49 -5
  20. package/dist/fonts/virtual-modules.d.ts.map +1 -1
  21. package/dist/index.js +229 -89
  22. package/dist/index.js.map +1 -1
  23. package/dist/plugins/fonts.d.ts.map +1 -1
  24. package/dist/plugins/mdx.d.ts.map +1 -1
  25. package/dist/routing/index.js +1 -1
  26. package/dist/routing/link-codegen.d.ts.map +1 -1
  27. package/dist/server/action-handler.d.ts +14 -5
  28. package/dist/server/action-handler.d.ts.map +1 -1
  29. package/dist/server/internal.js +2 -2
  30. package/dist/server/internal.js.map +1 -1
  31. package/dist/server/pipeline.d.ts +10 -1
  32. package/dist/server/pipeline.d.ts.map +1 -1
  33. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  34. package/package.json +6 -7
  35. package/src/cli.ts +0 -0
  36. package/src/client/browser-dev.ts +25 -0
  37. package/src/client/error-boundary.tsx +49 -4
  38. package/src/fonts/pipeline.ts +39 -0
  39. package/src/fonts/transform.ts +61 -8
  40. package/src/fonts/virtual-modules.ts +73 -5
  41. package/src/plugins/fonts.ts +49 -14
  42. package/src/plugins/mdx.ts +42 -9
  43. package/src/routing/link-codegen.ts +29 -9
  44. package/src/server/action-handler.ts +41 -7
  45. package/src/server/pipeline.ts +12 -3
  46. package/src/server/rsc-entry/index.ts +78 -23
  47. package/dist/_chunks/interception-DSv3A2Zn.js.map +0 -1
  48. package/dist/_chunks/ssr-data-DzuI0bIV.js.map +0 -1
  49. package/dist/_chunks/use-params-Br9YSUFV.js.map +0 -1
@@ -119,7 +119,16 @@ export interface PipelineConfig {
119
119
  * If this function throws, the pipeline falls back to a bare
120
120
  * `new Response(null, { status: denyStatus })`.
121
121
  */
122
- renderDenyFallback?: (deny: DenySignal, req: Request, responseHeaders: Headers) => Response | Promise<Response>;
122
+ renderDenyFallback?: (deny: DenySignal, req: Request, responseHeaders: Headers,
123
+ /**
124
+ * The matched route, if available. Provided by both the middleware-stage
125
+ * and render-stage catch blocks (matching runs before middleware). When
126
+ * present, the renderer should resolve the deny status file against the
127
+ * matched chain so colocated `403.tsx`/`4xx.tsx`/`401.json` files are
128
+ * picked up. Falls back to the root-only chain when omitted (e.g. for
129
+ * deny()s thrown before route matching could complete). See TIM-822.
130
+ */
131
+ match?: RouteMatch) => Response | Promise<Response>;
123
132
  }
124
133
  /**
125
134
  * Run segment param coercion on the matched route's segments.
@@ -1 +1 @@
1
- {"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/server/pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAY,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AACxD,OAAO,EAAsB,KAAK,YAAY,EAAE,MAAM,wBAAwB,CAAC;AA6B/E,OAAO,EAAkB,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAO7D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAOvD;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAMhG;AAID,sEAAsE;AACtE,MAAM,WAAW,UAAU;IACzB,mDAAmD;IACnD,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,sEAAsE;IACtE,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IACjD,oEAAoE;IACpE,eAAe,EAAE,YAAY,EAAE,CAAC;CACjC;AAED,6DAA6D;AAC7D,MAAM,MAAM,YAAY,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,UAAU,GAAG,IAAI,CAAC;AAEnE,sEAAsE;AACtE,MAAM,MAAM,oBAAoB,GAAG,CACjC,QAAQ,EAAE,MAAM,KACb,OAAO,oBAAoB,EAAE,kBAAkB,GAAG,IAAI,CAAC;AAE5D,iEAAiE;AACjE,MAAM,WAAW,mBAAmB;IAClC,iEAAiE;IACjE,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,6DAA6D;AAC7D,MAAM,MAAM,aAAa,GAAG,CAC1B,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,YAAY,CAAC,EAAE,mBAAmB,KAC/B,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;AAElC,+DAA+D;AAC/D,MAAM,MAAM,iBAAiB,GAAG,CAC9B,KAAK,EAAE,UAAU,EACjB,GAAG,EAAE,OAAO,EACZ,eAAe,EAAE,OAAO,KACrB,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAI1B,MAAM,WAAW,cAAc;IAC7B,iFAAiF;IACjF,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,OAAO,CAAC;QAAE,OAAO,EAAE,WAAW,CAAA;KAAE,CAAC,CAAC;IACtD,qEAAqE;IACrE,UAAU,EAAE,YAAY,CAAC;IACzB,iGAAiG;IACjG,kBAAkB,CAAC,EAAE,oBAAoB,CAAC;IAC1C,kEAAkE;IAClE,MAAM,EAAE,aAAa,CAAC;IACtB,kEAAkE;IAClE,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,eAAe,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACzF,kFAAkF;IAClF,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,gFAAgF;IAChF,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,yGAAyG;IACzG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,OAAO,4BAA4B,EAAE,mBAAmB,EAAE,CAAC;IAClF;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,UAAU,GAAG,OAAO,GAAG,KAAK,CAAC;IAC5C;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAC;IACpE;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAExD;;;;;;;;;;OAUG;IACH,mBAAmB,CAAC,EAAE,CACpB,KAAK,EAAE,OAAO,EACd,GAAG,EAAE,OAAO,EACZ,eAAe,EAAE,OAAO,KACrB,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IAClC;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,CACnB,IAAI,EAAE,UAAU,EAChB,GAAG,EAAE,OAAO,EACZ,eAAe,EAAE,OAAO,KACrB,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CACnC;AAID;;;;;;;;;GASG;AACH,wBAAsB,mBAAmB,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CA+B1E;AAID;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAsc1F"}
1
+ {"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/server/pipeline.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AAuBA,OAAO,uCAAuC,CAAC;AA0C/C,OAAO,EAKL,KAAK,mBAAmB,EACzB,MAAM,cAAc,CAAC;AAoCtB;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,mBAAmB,EAAE,KAAK,IAAI,GACtF,IAAI,CAEN;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAE5E;AA4jBD,OAAO,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAInE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;8BAnTrC,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AAqThD,wBAAiE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AAuBA,OAAO,uCAAuC,CAAC;AA0C/C,OAAO,EAKL,KAAK,mBAAmB,EACzB,MAAM,cAAc,CAAC;AAoCtB;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,mBAAmB,EAAE,KAAK,IAAI,GACtF,IAAI,CAEN;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAE5E;AAmnBD,OAAO,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAInE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;8BA7UrC,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AA+UhD,wBAAiE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.94",
3
+ "version": "0.2.0-alpha.96",
4
4
  "description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -110,11 +110,6 @@
110
110
  "publishConfig": {
111
111
  "access": "public"
112
112
  },
113
- "scripts": {
114
- "build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
115
- "typecheck": "tsgo --noEmit",
116
- "prepublishOnly": "pnpm run build"
117
- },
118
113
  "dependencies": {
119
114
  "@opentelemetry/api": "^1.9.1",
120
115
  "@opentelemetry/context-async-hooks": "^2.6.1",
@@ -157,5 +152,9 @@
157
152
  },
158
153
  "engines": {
159
154
  "node": ">=22.12.0"
155
+ },
156
+ "scripts": {
157
+ "build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
158
+ "typecheck": "tsgo --noEmit"
160
159
  }
161
- }
160
+ }
package/src/cli.ts CHANGED
File without changes
@@ -242,6 +242,10 @@ export function setupClientErrorForwarding(hot: Pick<HotInterface, 'send'>): voi
242
242
  if (!event.error && !event.message) return;
243
243
  // Skip errors during page unload — these are abort-related, not application errors
244
244
  if (isPageUnloading()) return;
245
+ // Skip framework signal digests (redirect/deny) — these are control-flow
246
+ // signals, not application errors. They should never surface as dev
247
+ // overlays even if they escape an error boundary. See TIM-838.
248
+ if (isFrameworkSignalError(event.error)) return;
245
249
 
246
250
  const error = event.error;
247
251
  hot.send('timber:client-error', {
@@ -256,6 +260,8 @@ export function setupClientErrorForwarding(hot: Pick<HotInterface, 'send'>): voi
256
260
  if (!reason) return;
257
261
  // Skip rejections during page unload — aborted fetches/streams cause these
258
262
  if (isPageUnloading()) return;
263
+ // Skip framework signal digests (redirect/deny). See TIM-838.
264
+ if (isFrameworkSignalError(reason)) return;
259
265
 
260
266
  const message = reason instanceof Error ? reason.message : String(reason);
261
267
  const stack = reason instanceof Error ? (reason.stack ?? '') : '';
@@ -267,3 +273,22 @@ export function setupClientErrorForwarding(hot: Pick<HotInterface, 'send'>): voi
267
273
  });
268
274
  });
269
275
  }
276
+
277
+ /**
278
+ * Detect errors whose `digest` marks them as a framework control-flow signal
279
+ * (RedirectSignal, DenySignal). These must never surface as dev overlays —
280
+ * they are expected during navigation and are handled by the error boundary
281
+ * and router. Exported for tests.
282
+ */
283
+ export function isFrameworkSignalError(error: unknown): boolean {
284
+ if (!error || typeof error !== 'object') return false;
285
+ const digest = (error as { digest?: unknown }).digest;
286
+ if (typeof digest !== 'string') return false;
287
+ try {
288
+ const parsed = JSON.parse(digest) as { type?: unknown } | null;
289
+ if (!parsed || typeof parsed !== 'object') return false;
290
+ return parsed.type === 'redirect' || parsed.type === 'deny';
291
+ } catch {
292
+ return false;
293
+ }
294
+ }
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { Component, createElement, type ReactNode } from 'react';
22
22
  import { getSsrData } from './ssr-data.js';
23
+ import { getRouterOrNull } from './router-ref.js';
23
24
 
24
25
  // ─── Page Unload Detection ───────────────────────────────────────────────────
25
26
  // Track whether the page is being unloaded (user refreshed or navigated away).
@@ -93,6 +94,13 @@ interface TimberErrorBoundaryState {
93
94
  error: Error | null;
94
95
  }
95
96
 
97
+ // Module-level guard: the first boundary to catch a given redirect digest
98
+ // schedules the navigation and marks it handled here. Subsequent renders
99
+ // (React retries the error-boundary render path multiple times in dev, and
100
+ // outer boundaries that also see the error) become no-ops, preventing
101
+ // duplicate router.navigate() calls.
102
+ const _handledRedirects = new WeakSet<object>();
103
+
96
104
  // ─── Component ───────────────────────────────────────────────────────────────
97
105
 
98
106
  export class TimberErrorBoundary extends Component<
@@ -138,11 +146,48 @@ export class TimberErrorBoundary extends Component<
138
146
  const error = this.state.error;
139
147
  const parsed = parseDigest(error);
140
148
 
141
- // RedirectSignal errors must propagate through all error boundaries
142
- // so the SSR shell fails and the pipeline catch block can produce a
143
- // proper HTTP redirect response. See design/04-authorization.md.
149
+ // RedirectSignal handling splits by environment:
150
+ //
151
+ // - SSR (no window): re-throw so the Fizz shell fails and the server
152
+ // pipeline catch block can produce a proper HTTP 3xx response. See
153
+ // design/04-authorization.md.
154
+ //
155
+ // - Client (window exists): a RedirectSignal that leaked into the RSC
156
+ // Flight payload during client navigation would otherwise bubble past
157
+ // every error boundary to `window.onerror`, which in dev triggers the
158
+ // Vite error overlay (false positive — navigation itself works).
159
+ // Instead, ask the router to perform an SPA navigation to the target
160
+ // and render nothing while it takes over. The router's own catch path
161
+ // handles cross-origin / hard navigations. See TIM-838.
144
162
  if (parsed?.type === 'redirect') {
145
- throw error;
163
+ if (typeof window === 'undefined') {
164
+ throw error;
165
+ }
166
+ // Dedupe: React may render the error-boundary fallback path more
167
+ // than once for a single caught error (dev-mode double render, and
168
+ // additional boundaries above us in the tree). Only schedule the
169
+ // navigation on the first render that sees this error instance.
170
+ const alreadyHandled = _handledRedirects.has(error);
171
+ if (!alreadyHandled) {
172
+ _handledRedirects.add(error);
173
+ const router = getRouterOrNull();
174
+ if (router) {
175
+ // Schedule the navigation outside the render phase to avoid
176
+ // setState-during-render warnings from router state updates.
177
+ queueMicrotask(() => {
178
+ router.navigate(parsed.location, { replace: true }).catch(() => {
179
+ // Fall back to a hard navigation if the soft navigation fails.
180
+ window.location.href = parsed.location;
181
+ });
182
+ });
183
+ } else {
184
+ // No router available (shouldn't happen post-bootstrap) — fall
185
+ // back to a hard navigation so the user still ends up at the
186
+ // target.
187
+ window.location.href = parsed.location;
188
+ }
189
+ }
190
+ return null;
146
191
  }
147
192
 
148
193
  // If this boundary has a status filter, check whether the error matches.
@@ -31,6 +31,7 @@ import type { CachedFont } from './google.js';
31
31
  import { generateVariableClass, generateFontFamilyClass, generateFontFaces } from './css.js';
32
32
  import { generateFallbackCss } from './fallbacks.js';
33
33
  import { generateLocalFontFaces } from './local.js';
34
+ import { VirtualFontCssIdMap } from './virtual-modules.js';
34
35
 
35
36
  /**
36
37
  * Backwards-compatible alias for the registry shape that the standalone
@@ -55,6 +56,18 @@ export class FontPipeline {
55
56
  */
56
57
  private readonly _entries = new Map<string, FontEntry>();
57
58
 
59
+ /**
60
+ * Per-importer virtual CSS module id registry. Populated by the
61
+ * transform hook when it inlines a font function call, read by the
62
+ * plugin's `load` hook when Vite asks for the module's CSS source.
63
+ *
64
+ * Lives on the pipeline (rather than a module-level singleton) so
65
+ * multiple parallel plugin instances — e.g. in the test suite — cannot
66
+ * collide on the hash-to-importer map. Also makes `clear()` reset the
67
+ * id map alongside the font entries so test fixtures start clean.
68
+ */
69
+ readonly cssIdMap = new VirtualFontCssIdMap();
70
+
58
71
  // ── Read-only accessors ─────────────────────────────────────────────────
59
72
 
60
73
  /** Number of registered fonts. */
@@ -179,6 +192,7 @@ export class FontPipeline {
179
192
  /** Drop every registered font. Used by tests and rebuild flows. */
180
193
  clear(): void {
181
194
  this._entries.clear();
195
+ this.cssIdMap.clear();
182
196
  }
183
197
 
184
198
  // ── Derived views ───────────────────────────────────────────────────────
@@ -216,6 +230,31 @@ export class FontPipeline {
216
230
  }
217
231
  return cssParts.join('\n\n');
218
232
  }
233
+
234
+ /**
235
+ * Render the combined CSS for every font registered from a specific
236
+ * importer file. This is the anchor point for the per-importer virtual
237
+ * CSS module (`virtual:timber-font-css/<hash>.css`) that the transform
238
+ * hook injects as a side-effect import.
239
+ *
240
+ * Scoping by importer means two concurrent requests rendering different
241
+ * routes cannot see each other's font CSS: each importer's virtual
242
+ * module only contains CSS for fonts that module registered. It also
243
+ * means Vite's CSS HMR can surgically reload just the affected importer
244
+ * when a single file changes, rather than rebuilding one giant blob.
245
+ *
246
+ * Returns an empty string when no fonts are registered under `importer`.
247
+ * The empty string is semantically equivalent to "no CSS" — Vite's CSS
248
+ * pipeline handles it as a valid empty CSS module.
249
+ */
250
+ getCssForSegment(importer: string): string {
251
+ const cssParts: string[] = [];
252
+ for (const entry of this._entries.values()) {
253
+ if (entry.importer !== importer) continue;
254
+ cssParts.push(renderEntryCss(entry));
255
+ }
256
+ return cssParts.join('\n\n');
257
+ }
219
258
  }
220
259
 
221
260
  /**
@@ -19,7 +19,6 @@ import {
19
19
  detectDynamicFontCallAst,
20
20
  } from './ast.js';
21
21
  import type { FontPipeline } from './pipeline.js';
22
- import { VIRTUAL_FONT_CSS_REGISTER } from './virtual-modules.js';
23
22
 
24
23
  /**
25
24
  * Regex that matches imports from either `@timber/fonts/google` or
@@ -306,11 +305,46 @@ function transformLocalFonts(
306
305
  /**
307
306
  * Run the timber-fonts transform pass on a single source file.
308
307
  *
309
- * Returns the rewritten code (with font calls inlined and the side-effect
310
- * `virtual:timber-font-css-register` import prepended) or `null` if the
311
- * file does not import from any timber-fonts virtual module and therefore
312
- * needs no transformation.
308
+ * Returns the rewritten code (with font calls inlined and a side-effect
309
+ * `import 'virtual:timber-font-css/<hash>.css'` prepended so Vite's CSS
310
+ * pipeline owns the font CSS) or `null` if the file does not import from
311
+ * any timber-fonts virtual module and therefore needs no transformation.
313
312
  */
313
+ /**
314
+ * File extensions that may contain real font imports. Only JS/TS module
315
+ * variants are scanned. Excluding non-JS extensions (.mdx, .md, .css,
316
+ * .json, etc.) prevents false-positive matches on text that mentions a
317
+ * font specifier inside documentation, code fences, or asset metadata.
318
+ *
319
+ * Why this matters: the heuristic below is a substring check (`code.includes`),
320
+ * not an AST parse. An MDX file documenting Next.js font compatibility
321
+ * (e.g. a code fence containing `import { Inter } from 'next/font/google'`)
322
+ * would trigger the same match as a real import. The transform then prepends
323
+ * `import 'virtual:timber-font-css/<hash>.css';` to the file, which breaks
324
+ * downstream parsers that require the file to start with their own delimiter
325
+ * — most notably MDX frontmatter, which must start at line 1 col 1 for
326
+ * `remark-frontmatter` to recognise it. See TIM-840.
327
+ */
328
+ const FONT_IMPORT_SCANNABLE_EXTENSIONS = new Set([
329
+ '.js',
330
+ '.jsx',
331
+ '.ts',
332
+ '.tsx',
333
+ '.mjs',
334
+ '.cjs',
335
+ '.mts',
336
+ '.cts',
337
+ ]);
338
+
339
+ function hasScannableExtension(id: string): boolean {
340
+ // Strip Vite query suffixes (?v=..., ?import, ?worker, etc.) before
341
+ // matching so files imported with a query string are still scanned.
342
+ const path = id.split('?', 1)[0];
343
+ const dot = path.lastIndexOf('.');
344
+ if (dot === -1) return false;
345
+ return FONT_IMPORT_SCANNABLE_EXTENSIONS.has(path.slice(dot).toLowerCase());
346
+ }
347
+
314
348
  export function runFontsTransform(
315
349
  code: string,
316
350
  id: string,
@@ -320,6 +354,14 @@ export function runFontsTransform(
320
354
  // Skip virtual modules and node_modules
321
355
  if (id.startsWith('\0') || id.includes('node_modules')) return null;
322
356
 
357
+ // Only scan JS/TS module variants. .mdx, .md, .css, .json, etc. may
358
+ // mention font specifiers in documentation or asset metadata without
359
+ // ever importing them — a substring match would be a false positive
360
+ // and the prepended virtual CSS import would corrupt downstream
361
+ // parsers (e.g. MDX frontmatter, which must start at line 1).
362
+ // See TIM-840.
363
+ if (!hasScannableExtension(id)) return null;
364
+
323
365
  const hasGoogleImport =
324
366
  code.includes('@timber/fonts/google') ||
325
367
  code.includes('@timber-js/app/fonts/google') ||
@@ -341,10 +383,21 @@ export function runFontsTransform(
341
383
  }
342
384
 
343
385
  if (transformedCode !== code) {
344
- // Inject side-effect import that registers font CSS on globalThis.
345
- // The RSC entry reads globalThis.__timber_font_css to inline a <style> tag.
386
+ // Inject a side-effect import for this file's per-importer virtual
387
+ // CSS module. The `.css` suffix is what makes Vite's CSS pipeline
388
+ // own the module — in dev that gives us HMR + source maps, and in
389
+ // production @vitejs/plugin-rsc walks the RSC module graph via
390
+ // `collectCss`, finds this virtual CSS import, and injects a
391
+ // `<link rel="stylesheet" data-rsc-css-href=...>` through React
392
+ // Float's preinit mechanism (the same path component CSS rides on).
393
+ //
394
+ // The per-importer id guarantees two concurrent requests rendering
395
+ // different routes never see each other's font CSS: each importer's
396
+ // virtual module only resolves to the fonts that importer registered
397
+ // via `FontPipeline.getCssForSegment(importer)`. See TIM-828.
346
398
  if (pipeline.size() > 0) {
347
- transformedCode = `import '${VIRTUAL_FONT_CSS_REGISTER}';\n` + transformedCode;
399
+ const virtualCssId = pipeline.cssIdMap.idFor(id);
400
+ transformedCode = `import '${virtualCssId}';\n` + transformedCode;
348
401
  }
349
402
  return { code: transformedCode, map: null };
350
403
  }
@@ -9,13 +9,27 @@
9
9
  * transform hook hasn't run yet, or when an unknown font is referenced
10
10
  * via the default export proxy).
11
11
  * - `@timber/fonts/local` — exports `localFont()` as a fallback default.
12
- * - `virtual:timber-font-css-register` — side-effect module that sets the
13
- * combined `@font-face` CSS on `globalThis.__timber_font_css`. The RSC
14
- * entry reads this at render time to inline a `<style>` tag.
12
+ * - `virtual:timber-font-css/<hash>.css` — per-importer CSS module that
13
+ * holds the combined `@font-face` + fallback + class CSS for every
14
+ * font registered under that importer. The transform hook injects a
15
+ * side-effect `import 'virtual:timber-font-css/<hash>.css'` into each
16
+ * file that calls a font function, so Vite's CSS pipeline picks it up
17
+ * like any other CSS asset. @vitejs/plugin-rsc's `collectCss` walks
18
+ * the RSC module graph, finds the virtual module, and injects a
19
+ * `<link rel="stylesheet" data-rsc-css-href=...>` via React Float —
20
+ * the same machinery component CSS rides on (TIM-828).
21
+ *
22
+ * The `.css` suffix is load-bearing: Vite's `isCSSRequest` regex matches
23
+ * on file extension, so keeping it in the virtual id is how we tell
24
+ * Vite's CSS pipeline (dev HMR + build asset emission) that this
25
+ * module is CSS. The old `virtual:timber-font-css-register` side-effect
26
+ * module, `globalThis.__timber_font_css` channel, and inline `<style>`
27
+ * tag in the RSC entry were all removed in TIM-828.
15
28
  *
16
29
  * Design doc: 24-fonts.md
17
30
  */
18
31
 
32
+ import { createHash } from 'node:crypto';
19
33
  import type { ExtractedFont, FontFaceDescriptor } from './types.js';
20
34
  import { generateVariableClass, generateFontFamilyClass, generateFontFaces } from './css.js';
21
35
  import { generateFallbackCss } from './fallbacks.js';
@@ -26,8 +40,62 @@ export const VIRTUAL_LOCAL = '@timber/fonts/local';
26
40
  export const RESOLVED_GOOGLE = '\0@timber/fonts/google';
27
41
  export const RESOLVED_LOCAL = '\0@timber/fonts/local';
28
42
 
29
- export const VIRTUAL_FONT_CSS_REGISTER = 'virtual:timber-font-css-register';
30
- export const RESOLVED_FONT_CSS_REGISTER = '\0virtual:timber-font-css-register';
43
+ /**
44
+ * Prefix used for the per-importer virtual CSS modules. The full id is
45
+ * `virtual:timber-font-css/<hash>.css` where `<hash>` is a short SHA-256
46
+ * digest of the importer path. We encode the hash (not the path itself)
47
+ * so the id stays filesystem-safe and opaque to downstream consumers.
48
+ */
49
+ export const VIRTUAL_FONT_CSS_PREFIX = 'virtual:timber-font-css/';
50
+ export const VIRTUAL_FONT_CSS_SUFFIX = '.css';
51
+ export const RESOLVED_FONT_CSS_PREFIX = '\0' + VIRTUAL_FONT_CSS_PREFIX;
52
+
53
+ /**
54
+ * In-memory map from the short hash used in virtual module ids back to
55
+ * the absolute importer path. Populated by `virtualFontCssIdFor()` when
56
+ * the transform hook registers an importer and read by the plugin's
57
+ * `load` hook when Vite asks for the CSS content.
58
+ *
59
+ * Scoped to a single `FontPipeline` instance to avoid cross-plugin-
60
+ * instance collisions during multi-build pipelines.
61
+ */
62
+ export class VirtualFontCssIdMap {
63
+ private readonly map = new Map<string, string>();
64
+
65
+ /**
66
+ * Return a stable virtual module id for `importer`. Calling this twice
67
+ * with the same importer returns the same id. Registering under an
68
+ * existing hash is idempotent.
69
+ */
70
+ idFor(importer: string): string {
71
+ const hash = createHash('sha256').update(importer).digest('hex').slice(0, 16);
72
+ this.map.set(hash, importer);
73
+ return `${VIRTUAL_FONT_CSS_PREFIX}${hash}${VIRTUAL_FONT_CSS_SUFFIX}`;
74
+ }
75
+
76
+ /** Decode the importer from a resolved (or unresolved) virtual css id. */
77
+ importerFor(id: string): string | undefined {
78
+ const body = id.startsWith('\0') ? id.slice(1) : id;
79
+ if (!body.startsWith(VIRTUAL_FONT_CSS_PREFIX)) return undefined;
80
+ if (!body.endsWith(VIRTUAL_FONT_CSS_SUFFIX)) return undefined;
81
+ const hash = body.slice(
82
+ VIRTUAL_FONT_CSS_PREFIX.length,
83
+ body.length - VIRTUAL_FONT_CSS_SUFFIX.length
84
+ );
85
+ return this.map.get(hash);
86
+ }
87
+
88
+ /** True if `id` is any form of the virtual font css module. */
89
+ matches(id: string): boolean {
90
+ const body = id.startsWith('\0') ? id.slice(1) : id;
91
+ return body.startsWith(VIRTUAL_FONT_CSS_PREFIX) && body.endsWith(VIRTUAL_FONT_CSS_SUFFIX);
92
+ }
93
+
94
+ /** Drop every registered mapping. Used by tests. */
95
+ clear(): void {
96
+ this.map.clear();
97
+ }
98
+ }
31
99
 
32
100
  /**
33
101
  * Convert a font family name to a PascalCase export name.
@@ -32,10 +32,8 @@ import { emitFontAssets, writeFontManifest, groupCachedFontsByFamily } from '../
32
32
  import {
33
33
  RESOLVED_GOOGLE,
34
34
  RESOLVED_LOCAL,
35
- RESOLVED_FONT_CSS_REGISTER,
36
35
  VIRTUAL_GOOGLE,
37
36
  VIRTUAL_LOCAL,
38
- VIRTUAL_FONT_CSS_REGISTER,
39
37
  generateGoogleVirtualModule,
40
38
  generateLocalVirtualModule,
41
39
  } from '../fonts/virtual-modules.js';
@@ -68,12 +66,34 @@ export function timberFonts(ctx: PluginContext): Plugin {
68
66
  name: 'timber-fonts',
69
67
 
70
68
  /**
71
- * Resolve `@timber/fonts/google`, `@timber/fonts/local`, and
72
- * `virtual:timber-font-css-register` virtual modules.
69
+ * `enforce: 'pre'` is load-bearing for the font CSS pipeline (TIM-828).
70
+ *
71
+ * The transform hook injects a side-effect
72
+ * `import 'virtual:timber-font-css/<hash>.css'` into every file that
73
+ * registers a font. @vitejs/plugin-rsc's `rsc:rsc-css-export-transform`
74
+ * later inspects each server component's source for CSS imports and
75
+ * wraps the default export in `import.meta.viteRsc.loadCss(...)` so
76
+ * the matched virtual CSS module ends up `preinit`'d as
77
+ * `<link rel="stylesheet" data-rsc-css-href=...>`.
78
+ *
79
+ * If timber-fonts ran in normal order it would inject the CSS import
80
+ * *after* plugin-rsc had already scanned the source, and the font CSS
81
+ * would be silently dropped from the rendered HTML. Running in the
82
+ * `pre` slot guarantees our injected import is visible to plugin-rsc's
83
+ * later CSS-export scan.
84
+ */
85
+ enforce: 'pre',
86
+
87
+ /**
88
+ * Resolve `@timber/fonts/google`, `@timber/fonts/local`, and the
89
+ * per-importer `virtual:timber-font-css/<hash>.css` modules injected
90
+ * by the transform hook.
73
91
  *
74
92
  * Strips the `\0` prefix added by the RSC plugin's re-imports and the
75
93
  * absolute root prefix added by SSR build entries before comparing
76
- * against the public IDs.
94
+ * against the public IDs. The `.css` suffix on font-css ids is
95
+ * preserved so Vite's CSS pipeline (`isCSSRequest`) recognises the
96
+ * resolved id as CSS.
77
97
  */
78
98
  resolveId(id: string) {
79
99
  let cleanId = id.startsWith('\0') ? id.slice(1) : id;
@@ -88,27 +108,42 @@ export function timberFonts(ctx: PluginContext): Plugin {
88
108
 
89
109
  if (cleanId === VIRTUAL_GOOGLE) return RESOLVED_GOOGLE;
90
110
  if (cleanId === VIRTUAL_LOCAL) return RESOLVED_LOCAL;
91
- if (cleanId === VIRTUAL_FONT_CSS_REGISTER) return RESOLVED_FONT_CSS_REGISTER;
111
+ if (pipeline.cssIdMap.matches(cleanId)) return '\0' + cleanId;
92
112
  return null;
93
113
  },
94
114
 
95
115
  /**
96
116
  * Return generated source for font virtual modules.
97
117
  *
98
- * `virtual:timber-font-css-register` is a side-effect module that sets
99
- * the combined `@font-face` CSS on `globalThis.__timber_font_css`. The
100
- * RSC entry imports it transitively via the layout file and reads
101
- * `globalThis.__timber_font_css` at render time to inline a `<style>`
102
- * tag. In dev mode we also lazily resolve Google `@font-face`
103
- * descriptors from the CDN here, since `buildStart` is a no-op in dev.
118
+ * The per-importer `virtual:timber-font-css/<hash>.css` modules
119
+ * return the combined font CSS for the specific importer that
120
+ * registered them. Vite's CSS pipeline handles injection from here:
121
+ * in dev the module rides the CSS HMR channel; in production it is
122
+ * emitted as a real CSS asset and @vitejs/plugin-rsc's `collectCss`
123
+ * walks the RSC module graph to find it.
124
+ *
125
+ * In dev mode we also lazily resolve Google `@font-face` descriptors
126
+ * from the CDN here, since `buildStart` is a no-op in dev.
104
127
  */
105
128
  async load(id: string) {
106
129
  if (id === RESOLVED_GOOGLE) return generateGoogleVirtualModule(pipeline.fonts());
107
130
  if (id === RESOLVED_LOCAL) return generateLocalVirtualModule();
108
131
 
109
- if (id === RESOLVED_FONT_CSS_REGISTER) {
132
+ if (pipeline.cssIdMap.matches(id)) {
133
+ const importer = pipeline.cssIdMap.importerFor(id);
134
+ if (!importer) {
135
+ // Hash is valid but no importer is registered — return an empty
136
+ // CSS module rather than `null`. This can happen if Vite's
137
+ // module graph invalidates the virtual module between the
138
+ // transform that registered the hash and the load that asks
139
+ // for it (e.g. after an HMR edit that removes every font from
140
+ // the file). An empty stylesheet is a valid, safe no-op.
141
+ return '';
142
+ }
143
+
110
144
  if (ctx.dev) {
111
145
  for (const font of pipeline.googleFonts()) {
146
+ if (font.importer !== importer) continue;
112
147
  if (pipeline.hasFaces(font.id)) continue;
113
148
  try {
114
149
  const faces = await resolveDevFontFaces(font);
@@ -125,7 +160,7 @@ export function timberFonts(ctx: PluginContext): Plugin {
125
160
  }
126
161
  }
127
162
 
128
- return `globalThis.__timber_font_css = ${JSON.stringify(pipeline.getCss())};`;
163
+ return pipeline.getCssForSegment(importer);
129
164
  }
130
165
  return null;
131
166
  },
@@ -11,6 +11,8 @@
11
11
  import type { Plugin } from 'vite';
12
12
  import { existsSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
+ import { createRequire } from 'node:module';
15
+ import { pathToFileURL } from 'node:url';
14
16
  import type { PluginContext } from '../plugin-context.js';
15
17
 
16
18
  const MDX_EXTENSIONS = ['mdx', 'md'];
@@ -54,15 +56,45 @@ function shouldActivate(ctx: PluginContext): boolean {
54
56
  }
55
57
 
56
58
  /**
57
- * Try to dynamically import a module by name. Returns the default export
58
- * or the module itself, or undefined if the module is not installed.
59
+ * Try to dynamically import a module by name, resolving from the user's
60
+ * project root rather than from `@timber-js/app`'s own location.
61
+ *
62
+ * Why this matters: pnpm only hoists declared (peer) dependencies into a
63
+ * package's resolution scope. The MDX integration's optional companions
64
+ * — `remark-frontmatter`, `remark-mdx-frontmatter`, `@mdx-js/rollup` — are
65
+ * installed as direct deps of the consumer (e.g. `packages/website`), not
66
+ * `@timber-js/app`. A bare `import('remark-frontmatter')` from inside this
67
+ * file resolves against `packages/timber-app/node_modules` and silently
68
+ * fails with `ERR_MODULE_NOT_FOUND`, which made `tryImport` return
69
+ * `undefined` and skipped frontmatter parsing entirely — leaving MDX to
70
+ * choke on YAML as JS expressions (TIM-840).
71
+ *
72
+ * Resolving relative to `projectRoot` (via `createRequire`) makes the
73
+ * lookup walk the consumer's `node_modules` tree first, which is where
74
+ * pnpm puts the user's deps.
75
+ *
76
+ * Returns the default export, the module itself, or undefined if the
77
+ * module is not installed.
59
78
  */
60
- async function tryImport(name: string): Promise<unknown | undefined> {
79
+ async function tryImport(name: string, projectRoot: string): Promise<unknown | undefined> {
61
80
  try {
62
- const mod = await import(name);
81
+ // Anchor resolution at the consumer's package.json so we walk their
82
+ // node_modules tree, not @timber-js/app's. createRequire().resolve()
83
+ // returns an absolute file path that ESM dynamic import accepts when
84
+ // wrapped as a file:// URL.
85
+ const require = createRequire(join(projectRoot, 'package.json'));
86
+ const resolved = require.resolve(name);
87
+ const mod = await import(pathToFileURL(resolved).href);
63
88
  return mod.default ?? mod;
64
89
  } catch {
65
- return undefined;
90
+ // Fall back to a bare import — covers the rare case where the package
91
+ // lives next to @timber-js/app (e.g. monorepo with hoisted deps).
92
+ try {
93
+ const mod = await import(name);
94
+ return mod.default ?? mod;
95
+ } catch {
96
+ return undefined;
97
+ }
66
98
  }
67
99
  }
68
100
 
@@ -80,7 +112,7 @@ export function timberMdx(ctx: PluginContext): Plugin {
80
112
  async function activate(): Promise<void> {
81
113
  if (innerPlugin !== null || !shouldActivate(ctx)) return;
82
114
 
83
- const createMdxPlugin = (await tryImport('@mdx-js/rollup')) as
115
+ const createMdxPlugin = (await tryImport('@mdx-js/rollup', ctx.root)) as
84
116
  | ((options?: Record<string, unknown>) => Plugin)
85
117
  | undefined;
86
118
 
@@ -99,10 +131,11 @@ export function timberMdx(ctx: PluginContext): Plugin {
99
131
 
100
132
  const mdxConfig = ctx.config.mdx ?? {};
101
133
 
102
- // Auto-register frontmatter plugins
134
+ // Auto-register frontmatter plugins. Resolve from the consumer's root
135
+ // so pnpm finds packages declared in the user's package.json (TIM-840).
103
136
  const remarkPlugins: unknown[] = [];
104
- const remarkFrontmatter = await tryImport('remark-frontmatter');
105
- const remarkMdxFrontmatter = await tryImport('remark-mdx-frontmatter');
137
+ const remarkFrontmatter = await tryImport('remark-frontmatter', ctx.root);
138
+ const remarkMdxFrontmatter = await tryImport('remark-mdx-frontmatter', ctx.root);
106
139
  if (remarkFrontmatter) remarkPlugins.push(remarkFrontmatter);
107
140
  if (remarkMdxFrontmatter) remarkPlugins.push(remarkMdxFrontmatter);
108
141