@timber-js/app 0.2.0-alpha.163 → 0.2.0-alpha.164

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 (102) hide show
  1. package/dist/_chunks/{actions-cjklt63G.js → actions-CSDD6x7U.js} +2 -2
  2. package/dist/_chunks/{actions-cjklt63G.js.map → actions-CSDD6x7U.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-CzYUlgXA.js → cache-api-eb1gydM7.js} +41 -13
  4. package/dist/_chunks/cache-api-eb1gydM7.js.map +1 -0
  5. package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js → cli-schema-sync-mGfRbjh2.js} +2 -2
  6. package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js.map → cli-schema-sync-mGfRbjh2.js.map} +1 -1
  7. package/dist/_chunks/{plugin-context-DeAxFRMq.js → plugin-context-BnaiU_cF.js} +37 -2
  8. package/dist/_chunks/plugin-context-BnaiU_cF.js.map +1 -0
  9. package/dist/_chunks/{walkers-9mz9T7mb.js → walkers-BL3MCMgO.js} +2 -2
  10. package/dist/_chunks/{walkers-9mz9T7mb.js.map → walkers-BL3MCMgO.js.map} +1 -1
  11. package/dist/adapters/cloudflare-kv-cache.d.ts +1 -0
  12. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
  13. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  14. package/dist/adapters/nitro.d.ts +11 -0
  15. package/dist/adapters/nitro.d.ts.map +1 -1
  16. package/dist/adapters/nitro.js +77 -64
  17. package/dist/adapters/nitro.js.map +1 -1
  18. package/dist/cache/index.d.ts +3 -0
  19. package/dist/cache/index.d.ts.map +1 -1
  20. package/dist/cache/index.js +1 -1
  21. package/dist/cache/redis-handler.d.ts +1 -0
  22. package/dist/cache/redis-handler.d.ts.map +1 -1
  23. package/dist/cache/tag-aware-handler.d.ts +1 -0
  24. package/dist/cache/tag-aware-handler.d.ts.map +1 -1
  25. package/dist/cache/timber-cache.d.ts.map +1 -1
  26. package/dist/cli.js +2 -2
  27. package/dist/client/slot-context.d.ts +29 -0
  28. package/dist/client/slot-context.d.ts.map +1 -0
  29. package/dist/client/slot-outlet.d.ts +16 -0
  30. package/dist/client/slot-outlet.d.ts.map +1 -0
  31. package/dist/client/slot-provider.d.ts +20 -0
  32. package/dist/client/slot-provider.d.ts.map +1 -0
  33. package/dist/config-types.d.ts +2 -1
  34. package/dist/config-types.d.ts.map +1 -1
  35. package/dist/dev-tools/logs.d.ts.map +1 -1
  36. package/dist/index.js +293 -124
  37. package/dist/index.js.map +1 -1
  38. package/dist/plugin-context.d.ts +27 -0
  39. package/dist/plugin-context.d.ts.map +1 -1
  40. package/dist/plugins/cache.d.ts.map +1 -1
  41. package/dist/plugins/client-chunks.d.ts.map +1 -1
  42. package/dist/plugins/dev-server.d.ts.map +1 -1
  43. package/dist/plugins/prebuilt-options-analysis.d.ts +40 -0
  44. package/dist/plugins/prebuilt-options-analysis.d.ts.map +1 -0
  45. package/dist/plugins/prebuilt.d.ts.map +1 -1
  46. package/dist/plugins/prerender-sugar.d.ts.map +1 -1
  47. package/dist/routing/index.js +2 -2
  48. package/dist/server/html-injector-core.d.ts +30 -9
  49. package/dist/server/html-injector-core.d.ts.map +1 -1
  50. package/dist/server/html-injectors.d.ts.map +1 -1
  51. package/dist/server/index.js +1 -1
  52. package/dist/server/internal.js +346 -51
  53. package/dist/server/internal.js.map +1 -1
  54. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  55. package/dist/server/pipeline-phases.d.ts.map +1 -1
  56. package/dist/server/prebuilt/cache-key.d.ts +4 -0
  57. package/dist/server/prebuilt/cache-key.d.ts.map +1 -1
  58. package/dist/server/prebuilt/key-discipline.d.ts +23 -0
  59. package/dist/server/prebuilt/key-discipline.d.ts.map +1 -0
  60. package/dist/server/prebuilt/slots.d.ts +74 -0
  61. package/dist/server/prebuilt/slots.d.ts.map +1 -0
  62. package/dist/server/prebuilt-builder.d.ts.map +1 -1
  63. package/dist/server/prebuilt-runtime.d.ts +12 -2
  64. package/dist/server/prebuilt-runtime.d.ts.map +1 -1
  65. package/dist/server/rsc-entry/deny-fallback.d.ts +29 -0
  66. package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -0
  67. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  68. package/docs/api/34-api-config.mdx +165 -3
  69. package/docs/learn/00-introduction.mdx +78 -44
  70. package/docs/learn/13-configuration.mdx +27 -8
  71. package/package.json +3 -2
  72. package/src/adapters/cloudflare-kv-cache.ts +5 -1
  73. package/src/adapters/nitro.ts +82 -64
  74. package/src/cache/index.ts +25 -2
  75. package/src/cache/redis-handler.ts +27 -7
  76. package/src/cache/tag-aware-handler.ts +5 -1
  77. package/src/cache/timber-cache.ts +21 -10
  78. package/src/client/slot-context.ts +48 -0
  79. package/src/client/slot-outlet.tsx +22 -0
  80. package/src/client/slot-provider.tsx +25 -0
  81. package/src/config-types.ts +2 -1
  82. package/src/dev-tools/logs.ts +7 -0
  83. package/src/plugin-context.ts +54 -0
  84. package/src/plugins/cache.ts +1 -2
  85. package/src/plugins/client-chunks.ts +42 -1
  86. package/src/plugins/dev-server.ts +12 -69
  87. package/src/plugins/prebuilt-options-analysis.ts +175 -0
  88. package/src/plugins/prebuilt.ts +82 -127
  89. package/src/plugins/prerender-sugar.ts +1 -2
  90. package/src/server/html-injector-core.ts +85 -27
  91. package/src/server/html-injectors.ts +5 -1
  92. package/src/server/node-stream-transforms.ts +6 -1
  93. package/src/server/pipeline-phases.ts +4 -1
  94. package/src/server/prebuilt/cache-key.ts +74 -0
  95. package/src/server/prebuilt/key-discipline.ts +53 -0
  96. package/src/server/prebuilt/slots.ts +167 -0
  97. package/src/server/prebuilt-builder.ts +57 -23
  98. package/src/server/prebuilt-runtime.ts +144 -60
  99. package/src/server/rsc-entry/deny-fallback.ts +92 -0
  100. package/src/server/rsc-entry/index.ts +16 -70
  101. package/dist/_chunks/cache-api-CzYUlgXA.js.map +0 -1
  102. package/dist/_chunks/plugin-context-DeAxFRMq.js.map +0 -1
@@ -60,7 +60,6 @@
60
60
  import { readFileSync } from 'node:fs';
61
61
  import { relative } from 'node:path';
62
62
  import MagicString from 'magic-string';
63
- import { parseAst } from 'vite';
64
63
  import type { Plugin } from 'vite';
65
64
  import type { PluginContext } from '../plugin-context.js';
66
65
  import type { SegmentNode } from '../routing/types.js';
@@ -70,6 +69,7 @@ import {
70
69
  generateStableCallsiteId,
71
70
  } from './callsite-ast.js';
72
71
  import { collectJsxUsages, recordCallSiteProps } from './callsite-jsx.js';
72
+ import { mayPrerender, unwrapExpression } from './prebuilt-options-analysis.js';
73
73
  import { cleanPrebuiltDir, runPrebuiltCapture } from './prebuilt-capture.js';
74
74
  import type {
75
75
  CallExpressionNode,
@@ -135,86 +135,6 @@ function importInsertionOffset(program: ProgramNode): number {
135
135
  return program.body.length > 0 ? program.body[program.body.length - 1].end : 0;
136
136
  }
137
137
 
138
- /** TS/paren wrappers that may sit between a declarator and the call. */
139
- const EXPRESSION_WRAPPER_TYPES = new Set([
140
- 'TSAsExpression',
141
- 'TSSatisfiesExpression',
142
- 'TSNonNullExpression',
143
- 'ParenthesizedExpression',
144
- ]);
145
-
146
- function unwrapExpression(node: PositionedNode): PositionedNode {
147
- let current = node;
148
- while (EXPRESSION_WRAPPER_TYPES.has(current.type) && current.expression) {
149
- current = current.expression as PositionedNode;
150
- }
151
- return current;
152
- }
153
-
154
- interface ObjectPropertyNode extends PositionedNode {
155
- type: 'Property';
156
- computed?: boolean;
157
- key: PositionedNode & { name?: string; value?: unknown };
158
- value: PositionedNode & { value?: unknown };
159
- }
160
-
161
- /**
162
- * Can this call site's options put the component in the prerender tier?
163
- *
164
- * Runtime-tier call sites (`ttl`/`tags` without `prerender`) must do NO
165
- * build work — recording them would make the post-build hook import the
166
- * built RSC entry (running production startup) and load route modules for
167
- * routes that have nothing to prerender (codex P2 on PR #836). The answer
168
- * is static only when the options argument is an object literal:
169
- *
170
- * - literal with `prerender: true` → yes
171
- * - literal with `prerender: false`/absent → no (spread-free)
172
- * - anything else (identifier, spread, non-literal `prerender` value)
173
- * → conservatively yes — the capture pass re-checks the real options
174
- * at runtime and skips non-prerender registrations; over-inclusion
175
- * costs a wasted entry load, under-inclusion would silently skip
176
- * prerendering.
177
- */
178
- function mayPrerender(call: CallExpressionNode): boolean {
179
- if (call.arguments.length < 2) return false; // no options → no lifetime → never prerender
180
- const options = unwrapExpression(call.arguments[1] as PositionedNode);
181
- if (options.type !== 'ObjectExpression') return true;
182
- const properties = (options as unknown as { properties: PositionedNode[] }).properties ?? [];
183
- // Walk in source order with last-write-wins, mirroring object-literal
184
- // evaluation: `{ prerender: false, ...opts }` may end up prerenderable
185
- // (the spread can override), while `{ ...opts, prerender: false }`
186
- // is statically false (codex P2 on PR #836). Computed keys are treated
187
- // like spreads — they may evaluate to 'prerender'.
188
- type Verdict = 'absent' | 'true' | 'false' | 'unknown';
189
- let verdict: Verdict = 'absent';
190
- for (const prop of properties) {
191
- if (prop.type === 'SpreadElement') {
192
- verdict = 'unknown';
193
- continue;
194
- }
195
- if (prop.type !== 'Property') continue;
196
- const property = prop as ObjectPropertyNode;
197
- if (property.computed) {
198
- verdict = 'unknown';
199
- continue;
200
- }
201
- const keyName =
202
- property.key.type === 'Identifier'
203
- ? property.key.name
204
- : property.key.type === 'Literal'
205
- ? String(property.key.value)
206
- : undefined;
207
- if (keyName !== 'prerender') continue;
208
- const value = unwrapExpression(property.value);
209
- if (value.type === 'Literal') {
210
- verdict = (value as { value?: unknown }).value === true ? 'true' : 'false';
211
- } else {
212
- verdict = 'unknown'; // `prerender: flag` — not statically known
213
- }
214
- }
215
- return verdict === 'true' || verdict === 'unknown';
216
- }
217
-
218
138
  interface CandidateSite {
219
139
  call: CallExpressionNode;
220
140
  stmt: PositionedNode;
@@ -336,44 +256,40 @@ function collectPageAndLayoutFiles(root: SegmentNode): string[] {
336
256
  return files;
337
257
  }
338
258
 
339
- /**
340
- * For a route file (page/layout), check if it imports from any prebuilt-
341
- * declaring module and extract JSX call-site props for those bindings.
342
- * Uses `getModuleInfo` to get the RESOLVED import targets and the module's
343
- * transformed code (available on Rollup's ModuleInfo at generateBundle).
344
- */
345
- function collectCrossModuleProps(
259
+ // Sentinel key for default-export component IDs in the binding map.
260
+ // Uses \0 prefix so it can never collide with a real JS export name
261
+ // (TIM-1187: 'Component'/'default' leaked into the named-import namespace).
262
+ const DEFAULT_SENTINEL = '\0default';
263
+
264
+ async function collectCrossModuleProps(
346
265
  ctx: PluginContext,
347
266
  moduleId: string,
348
- getInfo: (id: string) => PrebuiltModuleInfo | null
349
- ): void {
267
+ getInfo: (id: string) => PrebuiltModuleInfo | null,
268
+ resolve: (source: string, importer: string) => Promise<string | null>
269
+ ): Promise<void> {
350
270
  const bindingNames = ctx.prebuiltBindingNames!;
351
271
 
352
272
  const info = getInfo(moduleId);
353
273
  if (!info) return;
354
274
 
355
- // Which of this module's imports are prebuilt-declaring modules?
356
- // Exclude self — same-module usages are already captured in the transform.
357
275
  const declaringImports = info.importedIds.filter((id) => bindingNames.has(id) && id !== moduleId);
358
276
  if (declaringImports.length === 0) return;
359
277
 
360
278
  // Collect exported binding names from reachable declaring modules.
361
- // On collision (two declaring modules export the same name) drop the
362
- // entry — we can't tell which import it came from without resolving the
363
- // raw specifier, and a wrong match would produce spurious cache entries.
279
+ // Skip sentinel keys — they are per-module and handled in the default
280
+ // import branch below. Only real export names go into the merged map.
364
281
  const exportNameToId = new Map<string, string>();
365
282
  const collisions = new Set<string>();
366
283
  for (const declId of declaringImports) {
367
284
  const map = bindingNames.get(declId)!;
368
285
  for (const [name, compId] of map) {
286
+ if (name === DEFAULT_SENTINEL) continue;
369
287
  if (exportNameToId.has(name)) collisions.add(name);
370
288
  exportNameToId.set(name, compId);
371
289
  }
372
290
  }
373
291
  for (const name of collisions) exportNameToId.delete(name);
374
292
 
375
- // Read the route file source. getModuleInfo may provide `code` on
376
- // Vite's extended Rollup interface; fall back to reading from disk.
377
293
  let source: string | null = null;
378
294
  const extInfo = getInfo(moduleId) as unknown as { code?: string | null };
379
295
  if (extInfo && typeof extInfo.code === 'string') {
@@ -389,17 +305,17 @@ function collectCrossModuleProps(
389
305
 
390
306
  let program: ProgramNode;
391
307
  try {
392
- program = parseAst(source, { lang: 'tsx' }) as unknown as ProgramNode;
308
+ program = ctx.parseCached!.parse(source);
393
309
  } catch {
394
310
  return;
395
311
  }
396
312
 
397
- // Build local-name → componentId by matching import specifiers against
398
- // the known exported binding names from declaring modules.
313
+ const declaringSet = new Set(declaringImports);
399
314
  const localToComponentId = new Map<string, string>();
400
315
  for (const stmt of program.body) {
401
316
  if (stmt.type !== 'ImportDeclaration') continue;
402
317
  const importDecl = stmt as unknown as {
318
+ source: { value: string };
403
319
  specifiers: Array<{
404
320
  type: string;
405
321
  local: { name: string };
@@ -409,19 +325,26 @@ function collectCrossModuleProps(
409
325
  for (const spec of importDecl.specifiers) {
410
326
  if (spec.type === 'ImportSpecifier') {
411
327
  const exportedName = spec.imported?.name ?? spec.local.name;
412
- const compId = exportNameToId.get(exportedName);
413
- if (compId) localToComponentId.set(spec.local.name, compId);
328
+ // `import { default as X }` is parsed as an ImportSpecifier
329
+ // with imported.name === 'default' — route through the same
330
+ // per-source sentinel lookup as ImportDefaultSpecifier.
331
+ if (exportedName === 'default') {
332
+ const resolvedId = await resolve(importDecl.source.value, moduleId);
333
+ if (resolvedId && declaringSet.has(resolvedId)) {
334
+ const map = bindingNames.get(resolvedId)!;
335
+ const compId = map.get(DEFAULT_SENTINEL);
336
+ if (compId) localToComponentId.set(spec.local.name, compId);
337
+ }
338
+ } else {
339
+ const compId = exportNameToId.get(exportedName);
340
+ if (compId) localToComponentId.set(spec.local.name, compId);
341
+ }
414
342
  } else if (spec.type === 'ImportDefaultSpecifier') {
415
- // Default exports may be declared as `export default cache.component(...)`
416
- // (recorded as 'Component') or `const X = cache.component(...); export default X`
417
- // (recorded under the variable name 'X'). When the declaring module has
418
- // exactly one prebuilt binding, a default import unambiguously refers to it.
419
- const compId = exportNameToId.get('Component') ?? exportNameToId.get('default');
420
- if (compId) {
421
- localToComponentId.set(spec.local.name, compId);
422
- } else if (exportNameToId.size === 1) {
423
- const [, soleId] = [...exportNameToId.entries()][0];
424
- localToComponentId.set(spec.local.name, soleId);
343
+ const resolvedId = await resolve(importDecl.source.value, moduleId);
344
+ if (resolvedId && declaringSet.has(resolvedId)) {
345
+ const map = bindingNames.get(resolvedId)!;
346
+ const compId = map.get(DEFAULT_SENTINEL);
347
+ if (compId) localToComponentId.set(spec.local.name, compId);
425
348
  }
426
349
  }
427
350
  }
@@ -462,7 +385,7 @@ export function timberPrebuilt(ctx: PluginContext): Plugin {
462
385
 
463
386
  let program: ProgramNode;
464
387
  try {
465
- program = parseAst(code, { lang: 'tsx' }) as unknown as ProgramNode;
388
+ program = ctx.parseCached!.parse(code);
466
389
  } catch {
467
390
  return;
468
391
  }
@@ -516,14 +439,25 @@ export function timberPrebuilt(ctx: PluginContext): Plugin {
516
439
  // prop detection at generateBundle time (TIM-1142).
517
440
  const bindingMap = new Map<string, string>();
518
441
  let componentIdIdx = 0;
519
- for (const { call, displayName } of callSites) {
442
+ for (const { call, stmt, displayName } of callSites) {
520
443
  if (!ctx.dev && mayPrerender(call)) {
521
- bindingMap.set(displayName, componentIds[componentIdIdx++]);
444
+ const compId = componentIds[componentIdIdx++];
445
+ // Direct `export default cache.component(...)` produces
446
+ // displayName 'Component' from an ExportDefaultDeclaration —
447
+ // a synthetic name, not a real export. Store only the sentinel
448
+ // key. A variable literally named `Component` (from a
449
+ // VariableDeclarator) is a real binding and stored as-is.
450
+ if (stmt.type === 'ExportDefaultDeclaration') {
451
+ bindingMap.set(DEFAULT_SENTINEL, compId);
452
+ } else {
453
+ bindingMap.set(displayName, compId);
454
+ }
522
455
  }
523
456
  }
524
- // Detect `export default <identifier>` re-exports that reference
525
- // a declared binding and add a 'default' alias so cross-module
526
- // default imports can resolve the component ID.
457
+ // Detect default re-exports that reference a declared binding
458
+ // and add a sentinel alias so cross-module default imports can
459
+ // resolve the component ID. Handles both `export default X` and
460
+ // `export { X as default }` (TIM-1189).
527
461
  for (const stmt of program.body) {
528
462
  if (
529
463
  stmt.type === 'ExportDefaultDeclaration' &&
@@ -531,8 +465,21 @@ export function timberPrebuilt(ctx: PluginContext): Plugin {
531
465
  (stmt.declaration as PositionedNode).type === 'Identifier'
532
466
  ) {
533
467
  const name = (stmt.declaration as PositionedNode as unknown as IdentifierNode).name;
534
- if (bindingMap.has(name) && !bindingMap.has('default')) {
535
- bindingMap.set('default', bindingMap.get(name)!);
468
+ if (bindingMap.has(name) && !bindingMap.has(DEFAULT_SENTINEL)) {
469
+ bindingMap.set(DEFAULT_SENTINEL, bindingMap.get(name)!);
470
+ }
471
+ }
472
+ if (stmt.type === 'ExportNamedDeclaration' && Array.isArray(stmt.specifiers)) {
473
+ for (const spec of stmt.specifiers as Array<{
474
+ local: IdentifierNode;
475
+ exported: IdentifierNode;
476
+ }>) {
477
+ if (spec.exported.name === 'default') {
478
+ const compId = bindingMap.get(spec.local.name);
479
+ if (compId && !bindingMap.has(DEFAULT_SENTINEL)) {
480
+ bindingMap.set(DEFAULT_SENTINEL, compId);
481
+ }
482
+ }
536
483
  }
537
484
  }
538
485
  }
@@ -545,7 +492,10 @@ export function timberPrebuilt(ctx: PluginContext): Plugin {
545
492
  // Same-module JSX usages: if a prebuilt component is declared AND
546
493
  // used (<BlogBody variant="full" />) in the same module, capture
547
494
  // its static call-site props here (TIM-1142, design/44 §Props).
548
- const declaredBindings = new Set(bindingMap.keys());
495
+ // Exclude sentinel keys — they are not real JSX element names.
496
+ const declaredBindings = new Set(
497
+ [...bindingMap.keys()].filter((k) => k !== DEFAULT_SENTINEL)
498
+ );
549
499
  if (declaredBindings.size > 0) {
550
500
  const usages = collectJsxUsages(program, declaredBindings);
551
501
  for (const [bindingName, propsList] of usages) {
@@ -582,7 +532,7 @@ export function timberPrebuilt(ctx: PluginContext): Plugin {
582
532
  * SHARED components work — a component imported by several routes is
583
533
  * captured under every importing route's param combos.
584
534
  */
585
- generateBundle(
535
+ async generateBundle(
586
536
  _options?: unknown,
587
537
  bundle?: Record<
588
538
  string,
@@ -591,18 +541,23 @@ export function timberPrebuilt(ctx: PluginContext): Plugin {
591
541
  ) {
592
542
  if (ctx.dev) return;
593
543
  if (this?.environment?.name !== 'rsc') return;
594
- // Recompute attribution from scratch every build — a stale map from
595
- // a previous watch-mode build would otherwise keep scheduling the
596
- // capture (and its built-entry import) for components that no
597
- // longer exist (codex P2 on PR #836).
598
544
  ctx.routePrebuiltComponents = undefined;
599
545
  const moduleComponents = ctx.prebuiltModuleComponents;
600
546
  if (!moduleComponents || moduleComponents.size === 0 || !ctx.routeTree) return;
601
547
 
602
548
  const self = this as unknown as {
603
549
  getModuleInfo?: (id: string) => PrebuiltModuleInfo | null;
550
+ resolve?: (
551
+ source: string,
552
+ importer: string,
553
+ opts?: unknown
554
+ ) => Promise<{ id: string } | null>;
604
555
  };
605
556
  const getInfo = (id: string) => self.getModuleInfo?.(id) ?? null;
557
+ const resolveFn = async (source: string, importer: string): Promise<string | null> => {
558
+ const result = await self.resolve?.(source, importer);
559
+ return result?.id ?? null;
560
+ };
606
561
 
607
562
  const routeComponents = new Map<string, string[]>();
608
563
  for (const filePath of collectPageAndLayoutFiles(ctx.routeTree.root)) {
@@ -648,7 +603,7 @@ export function timberPrebuilt(ctx: PluginContext): Plugin {
648
603
  visited.add(modId);
649
604
  if (!scannedModules.has(modId)) {
650
605
  scannedModules.add(modId);
651
- collectCrossModuleProps(ctx, modId, getInfo);
606
+ await collectCrossModuleProps(ctx, modId, getInfo, resolveFn);
652
607
  }
653
608
  const modInfo = getInfo(modId);
654
609
  if (!modInfo) continue;
@@ -22,7 +22,6 @@
22
22
  */
23
23
 
24
24
  import MagicString from 'magic-string';
25
- import { parseAst } from 'vite';
26
25
  import type { Plugin } from 'vite';
27
26
  import type { PluginContext } from '../plugin-context.js';
28
27
  import type { SegmentNode, RouteFile } from '../routing/types.js';
@@ -205,7 +204,7 @@ export function timberPrerenderSugar(ctx: PluginContext): Plugin {
205
204
  // This page has a resolved prerender value — wrap its default export
206
205
  let program: { body: AstNode[] };
207
206
  try {
208
- program = parseAst(code, { lang: 'tsx' }) as unknown as { body: AstNode[] };
207
+ program = ctx.parseCached!.parse(code) as unknown as { body: AstNode[] };
209
208
  } catch {
210
209
  return;
211
210
  }
@@ -112,58 +112,116 @@ export class BufferAggregator {
112
112
  *
113
113
  * - `passthrough` — already found and removed the suffix on a previous chunk.
114
114
  * Caller emits the chunk unchanged.
115
- * - `noSuffix` — current chunk does not contain the suffix. Caller emits unchanged.
115
+ * - `noSuffix` — current chunk does not contain the suffix. When `emitSafe`
116
+ * is set, the caller must emit those bytes (the safe head minus the
117
+ * retained tail). When `emitSafe` is null, the entire chunk was absorbed
118
+ * into the tail buffer and there is nothing to emit yet.
116
119
  * - `suffixFound` — caller emits `before` and `after` (skipping the suffix
117
120
  * itself). The suffix will be re-emitted later by {@link suffixFlush}.
118
121
  */
119
122
  export type SuffixChunkResult =
120
123
  | { kind: 'passthrough' }
121
- | { kind: 'noSuffix' }
124
+ | { kind: 'noSuffix'; emitSafe?: Uint8Array }
122
125
  | { kind: 'suffixFound'; before: Uint8Array | null; after: Uint8Array | null };
123
126
 
127
+ /**
128
+ * State for the suffix mover. Works entirely in bytes — no TextDecoder
129
+ * needed because `</body></html>` is pure ASCII and its byte values are
130
+ * all < 0x80, so the pattern cannot appear inside multi-byte UTF-8
131
+ * sequences. This avoids the surrogate-pair and codepoint-corruption
132
+ * hazards that a text-based search + re-encode would introduce.
133
+ *
134
+ * We retain a trailing byte buffer (length `SUFFIX_BYTES.length - 1`)
135
+ * across chunks so the suffix is detected even when split across chunk
136
+ * boundaries — the same tail-buffer approach the head injector uses.
137
+ */
124
138
  export interface SuffixState {
125
139
  found: boolean;
140
+ /** Trailing bytes from the previous chunk for cross-boundary search. */
141
+ tail: Uint8Array | null;
142
+ readonly tailLen: number;
126
143
  }
127
144
 
128
145
  export function createSuffixState(): SuffixState {
129
- return { found: false };
146
+ return {
147
+ found: false,
148
+ tail: null,
149
+ tailLen: SUFFIX_BYTES.byteLength - 1,
150
+ };
151
+ }
152
+
153
+ /** Find `needle` in `haystack` starting from byte offset `start`. Returns -1 if not found. */
154
+ function indexOfBytes(haystack: Uint8Array, needle: Uint8Array, start = 0): number {
155
+ const hLen = haystack.byteLength;
156
+ const nLen = needle.byteLength;
157
+ if (nLen === 0) return start;
158
+ const last = hLen - nLen;
159
+ outer: for (let i = start; i <= last; i++) {
160
+ for (let j = 0; j < nLen; j++) {
161
+ if (haystack[i + j] !== needle[j]) continue outer;
162
+ }
163
+ return i;
164
+ }
165
+ return -1;
166
+ }
167
+
168
+ /** Concatenate two Uint8Arrays into one. */
169
+ function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
170
+ const result = new Uint8Array(a.byteLength + b.byteLength);
171
+ result.set(a, 0);
172
+ result.set(b, a.byteLength);
173
+ return result;
130
174
  }
131
175
 
132
176
  /**
133
- * Search a chunk for {@link SUFFIX}. If found, mark state and return the
134
- * surrounding bytes (the caller emits them; the suffix is held for the
135
- * end of the stream). The bytes-vs-text dance matches what both Web and
136
- * Node implementations did before extraction — search via decoded UTF-8
137
- * to avoid splitting multi-byte characters at the suffix boundary.
177
+ * Search a chunk for {@link SUFFIX_BYTES}. If found, mark state and return
178
+ * the surrounding bytes (the caller emits them; the suffix is held for the
179
+ * end of the stream). A trailing byte tail is retained across calls so the
180
+ * suffix is detected even when split across chunk boundaries.
181
+ *
182
+ * All output is raw byte slices — no text decoding or re-encoding — so
183
+ * multi-byte UTF-8 codepoints and astral characters are never corrupted.
138
184
  */
139
185
  export function processSuffixChunk(state: SuffixState, chunk: Uint8Array): SuffixChunkResult {
140
186
  if (state.found) return { kind: 'passthrough' };
141
187
 
142
- const text = new TextDecoder().decode(chunk, { stream: true });
143
- const idx = text.indexOf(SUFFIX);
144
- if (idx === -1) return { kind: 'noSuffix' };
145
-
146
- state.found = true;
147
-
148
- // Whole chunk was the suffix — nothing to emit before/after.
149
- if (chunk.byteLength === SUFFIX_BYTES.byteLength) {
150
- return { kind: 'suffixFound', before: null, after: null };
188
+ // Combine tail (if any) with the new chunk for searching.
189
+ const combined = state.tail ? concatBytes(state.tail, chunk) : chunk;
190
+
191
+ const idx = indexOfBytes(combined, SUFFIX_BYTES);
192
+ if (idx !== -1) {
193
+ state.found = true;
194
+ state.tail = null;
195
+ const beforeEnd = idx;
196
+ const afterStart = idx + SUFFIX_BYTES.byteLength;
197
+ return {
198
+ kind: 'suffixFound',
199
+ before: beforeEnd > 0 ? combined.slice(0, beforeEnd) : null,
200
+ after: afterStart < combined.byteLength ? combined.slice(afterStart) : null,
201
+ };
151
202
  }
152
203
 
153
- const beforeText = text.slice(0, idx);
154
- const afterText = text.slice(idx + SUFFIX.length);
155
- return {
156
- kind: 'suffixFound',
157
- before: beforeText ? encoder.encode(beforeText) : null,
158
- after: afterText ? encoder.encode(afterText) : null,
159
- };
204
+ // Suffix not yet seen — emit everything except the last tailLen bytes,
205
+ // which might be the start of the suffix split across chunks.
206
+ const safeEnd = Math.max(0, combined.byteLength - state.tailLen);
207
+ state.tail = combined.slice(safeEnd);
208
+
209
+ if (safeEnd === 0) return { kind: 'noSuffix' };
210
+ return { kind: 'noSuffix', emitSafe: combined.slice(0, safeEnd) };
160
211
  }
161
212
 
162
213
  /**
163
- * Final emit for the suffix mover. Always returns the suffix bytes — even
164
- * if no suffix was ever observed in the input — so output is well-formed.
214
+ * Final emit for the suffix mover. Returns any leftover tail bytes
215
+ * followed by the suffix. Always appends the suffix — even if none was
216
+ * ever observed in the input — so output is well-formed.
165
217
  */
166
- export function suffixFlush(_state: SuffixState): Uint8Array {
218
+ export function suffixFlush(state: SuffixState): Uint8Array {
219
+ if (state.tail && state.tail.byteLength > 0) {
220
+ const result = concatBytes(state.tail, SUFFIX_BYTES);
221
+ state.tail = null;
222
+ return result;
223
+ }
224
+ state.tail = null;
167
225
  return SUFFIX_BYTES;
168
226
  }
169
227
 
@@ -110,10 +110,14 @@ export function createMoveSuffixStream(): TransformStream<Uint8Array, Uint8Array
110
110
  return new TransformStream<Uint8Array, Uint8Array>({
111
111
  transform(chunk, controller) {
112
112
  const result = processSuffixChunk(state, chunk);
113
- if (result.kind === 'passthrough' || result.kind === 'noSuffix') {
113
+ if (result.kind === 'passthrough') {
114
114
  controller.enqueue(chunk);
115
115
  return;
116
116
  }
117
+ if (result.kind === 'noSuffix') {
118
+ if (result.emitSafe) controller.enqueue(result.emitSafe);
119
+ return;
120
+ }
117
121
  if (result.before) controller.enqueue(result.before);
118
122
  if (result.after) controller.enqueue(result.after);
119
123
  },
@@ -103,11 +103,16 @@ export function createNodeMoveSuffixTransform(): Transform {
103
103
  return new Transform({
104
104
  transform(chunk: Buffer, _encoding, callback) {
105
105
  const result = processSuffixChunk(state, chunk);
106
- if (result.kind === 'passthrough' || result.kind === 'noSuffix') {
106
+ if (result.kind === 'passthrough') {
107
107
  this.push(chunk);
108
108
  callback();
109
109
  return;
110
110
  }
111
+ if (result.kind === 'noSuffix') {
112
+ if (result.emitSafe) this.push(Buffer.from(result.emitSafe));
113
+ callback();
114
+ return;
115
+ }
111
116
  if (result.before) this.push(Buffer.from(result.before));
112
117
  if (result.after) this.push(Buffer.from(result.after));
113
118
  callback();
@@ -446,7 +446,10 @@ export async function handleRequest(
446
446
  // Fires before middleware so the browser can begin fetching critical
447
447
  // assets while middleware runs. Non-fatal — a failing emitter never
448
448
  // blocks the request.
449
- if (config.earlyHints) {
449
+ //
450
+ // Skip on RSC requests (client-side navigations) — the browser already
451
+ // has CSS, fonts, and JS chunks from the initial full-page load.
452
+ if (config.earlyHints && !isRscRequest) {
450
453
  try {
451
454
  await config.earlyHints(match, req, responseHeaders);
452
455
  } catch (err) {
@@ -177,6 +177,80 @@ function toUnkeyable(error: unknown): null {
177
177
  return null;
178
178
  }
179
179
 
180
+ /**
181
+ * Split a props record into data props (participate in the cache key) and
182
+ * slot values (React-element holes, excluded from the key) per the
183
+ * component's declared `slots` option (TIM-1173).
184
+ *
185
+ * Every DECLARED slot lands in `slotValues` — present or not — because the
186
+ * capture render always injects a SlotOutlet placeholder for each declared
187
+ * slot: the cached shell is identical whether the caller passed the slot
188
+ * or omitted it, so slot presence must not vary the key. `dataProps` is
189
+ * everything else and is what gets hashed.
190
+ *
191
+ * Returns null when the props OBJECT itself is outside the identity
192
+ * contract at the top level — accessors, symbol-keyed or non-enumerable
193
+ * properties, or a non-`Object.prototype` prototype. Splitting such an
194
+ * object would normalize away observable state (and reading an accessor
195
+ * would invoke user code, which this walk must never do) BEFORE
196
+ * `computeComponentCacheKey` gets the chance to reject it — a cached
197
+ * shell could then be served for inputs a live render distinguishes.
198
+ * Null means unkeyable: the caller renders live with the original props.
199
+ * The walk is descriptor-reads only; getters are never invoked. Nested
200
+ * data-prop values still face the full allowlist when hashed.
201
+ */
202
+ /** Create an own enumerable data property — safe for keys like `__proto__`. */
203
+ function defineOwn(target: Record<string, unknown>, key: string, value: unknown): void {
204
+ Object.defineProperty(target, key, {
205
+ value,
206
+ enumerable: true,
207
+ writable: true,
208
+ configurable: true,
209
+ });
210
+ }
211
+
212
+ export function splitSlotProps(
213
+ props: Record<string, unknown>,
214
+ slots: readonly string[]
215
+ ): { dataProps: Record<string, unknown>; slotValues: Record<string, unknown> } | null {
216
+ // TOTAL, like tryCanonicalize: the reflection ops below run against a
217
+ // user-controlled object, and a Proxy trap (getPrototypeOf, ownKeys,
218
+ // getOwnPropertyDescriptor) can throw arbitrarily — that must degrade
219
+ // to unkeyable (live render), not abort the request. toUnkeyable logs
220
+ // the unexpected throw for visibility. Codex P2 on PR #890.
221
+ try {
222
+ if (Object.getPrototypeOf(props) !== Object.prototype) return null;
223
+ if (Object.getOwnPropertySymbols(props).length > 0) return null;
224
+ const slotSet = new Set(slots);
225
+ const dataProps: Record<string, unknown> = {};
226
+ const slotValues: Record<string, unknown> = {};
227
+ // Own-property defines + Object.hasOwn, never assignment or `in`: a
228
+ // prop/slot legitimately named `__proto__` must land as an own data
229
+ // property (assignment would mutate the map's prototype), and an absent
230
+ // slot named `toString`/`constructor` must not read as present through
231
+ // the prototype chain (the provider would then expose the inherited
232
+ // function instead of undefined). Codex P2 on PR #890.
233
+ for (const key of Object.getOwnPropertyNames(props)) {
234
+ const desc = Object.getOwnPropertyDescriptor(props, key);
235
+ if (
236
+ desc === undefined ||
237
+ desc.get !== undefined ||
238
+ desc.set !== undefined ||
239
+ !desc.enumerable
240
+ ) {
241
+ return null;
242
+ }
243
+ defineOwn(slotSet.has(key) ? slotValues : dataProps, key, desc.value);
244
+ }
245
+ for (const slot of slotSet) {
246
+ if (!Object.hasOwn(slotValues, slot)) defineOwn(slotValues, slot, undefined);
247
+ }
248
+ return { dataProps, slotValues };
249
+ } catch (error) {
250
+ return toUnkeyable(error);
251
+ }
252
+ }
253
+
180
254
  /**
181
255
  * Compute the cache-entry hash for a component render:
182
256
  * `sha256({props, params}).slice(0, 16)` over canonical JSON.