@timber-js/app 0.2.0-alpha.207 → 0.2.0-alpha.208

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 (31) hide show
  1. package/dist/_chunks/{error-boundary-BndF-3Td.js → error-boundary-BrHmG-2X.js} +2 -2
  2. package/dist/_chunks/{error-boundary-BndF-3Td.js.map → error-boundary-BrHmG-2X.js.map} +1 -1
  3. package/dist/_chunks/{status-page-marker-DwQBrLBz.js → status-page-marker-CNbpUOPB.js} +88 -9
  4. package/dist/_chunks/status-page-marker-CNbpUOPB.js.map +1 -0
  5. package/dist/client/error-boundary.js +1 -1
  6. package/dist/client/history.d.ts +8 -0
  7. package/dist/client/history.d.ts.map +1 -1
  8. package/dist/client/internal.js +30 -7
  9. package/dist/client/internal.js.map +1 -1
  10. package/dist/client/navigation-commit.d.ts +4 -0
  11. package/dist/client/navigation-commit.d.ts.map +1 -1
  12. package/dist/client/router-pipeline.d.ts +2 -2
  13. package/dist/client/router-pipeline.d.ts.map +1 -1
  14. package/dist/client/router.d.ts.map +1 -1
  15. package/dist/client/segment-cache.d.ts +44 -4
  16. package/dist/client/segment-cache.d.ts.map +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +101 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/plugins/react-resolve.d.ts +49 -0
  21. package/dist/plugins/react-resolve.d.ts.map +1 -0
  22. package/dist/server/internal.js +1 -1
  23. package/package.json +3 -3
  24. package/src/client/history.ts +17 -0
  25. package/src/client/navigation-commit.ts +6 -0
  26. package/src/client/router-pipeline.ts +31 -4
  27. package/src/client/router.ts +22 -2
  28. package/src/client/segment-cache.ts +110 -11
  29. package/src/index.ts +2 -0
  30. package/src/plugins/react-resolve.ts +149 -0
  31. package/dist/_chunks/status-page-marker-DwQBrLBz.js.map +0 -1
@@ -0,0 +1,49 @@
1
+ /**
2
+ * timber-react-resolve — Deduplicate React across timber and user project.
3
+ *
4
+ * Without this plugin, pnpm's strict isolation can install two physical
5
+ * copies of React when the user's pinned version differs from timber's
6
+ * peer dep. Timber's 47 bare `from 'react'` imports resolve to one copy
7
+ * while react-dom uses the other — causing "Invalid hook call" hydration
8
+ * crashes with no indication of the real cause.
9
+ *
10
+ * The fix: inject `resolve.dedupe` entries so Vite always resolves React
11
+ * packages from the project root. Unlike `resolve.alias`, `dedupe`
12
+ * preserves Vite's per-environment export conditions — the RSC
13
+ * environment still gets the `react-server` build, SSR gets the SSR
14
+ * build, and the browser gets the browser build. The deduplication
15
+ * happens at the package level, not the file level.
16
+ *
17
+ * A `configResolved` check detects version mismatches between timber's
18
+ * resolved React and the project's React, and logs an actionable error.
19
+ *
20
+ * Design doc: 18-build-system.md §"Module Resolution"
21
+ * Task: TIM-1520
22
+ */
23
+ import type { Plugin } from 'vite';
24
+ /**
25
+ * React packages to deduplicate. Vite's `resolve.dedupe` extracts the
26
+ * base package name (via `deepImportRE`) before checking, so subpath
27
+ * specifiers like `react/jsx-runtime` are covered by the base `react`
28
+ * entry — listing them separately has no effect.
29
+ */
30
+ export declare const REACT_DEDUPE_PACKAGES: readonly ['react', 'react-dom'];
31
+ export interface ReactVersionMismatch {
32
+ specifier: string;
33
+ timberVersion: string;
34
+ projectVersion: string;
35
+ }
36
+ /**
37
+ * Check whether timber's React and the user project's React resolve to
38
+ * the same version. Returns mismatches if any.
39
+ */
40
+ export declare function detectReactMismatch(projectRoot: string): ReactVersionMismatch[];
41
+ /**
42
+ * Format React version mismatches as an actionable warning.
43
+ */
44
+ export declare function formatReactMismatch(mismatches: ReactVersionMismatch[]): string;
45
+ /**
46
+ * Create the timber-react-resolve Vite plugin.
47
+ */
48
+ export declare function timberReactResolve(): Plugin;
49
+ //# sourceMappingURL=react-resolve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react-resolve.d.ts","sourceRoot":"","sources":["../../src/plugins/react-resolve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAKH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAEnC;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,YAAI,OAAO,EAAE,WAAW,CAAU,CAAC;AAsBrE,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,oBAAoB,EAAE,CAc/E;AAED;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,oBAAoB,EAAE,GAAG,MAAM,CA4B9E;AAED;;GAEG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CA8B3C"}
@@ -13,7 +13,7 @@ import { a as getManifestEntry, b as wasInvalidatedSince, c as lookupPrebuiltPay
13
13
  import { n as createSingleflight } from "../_chunks/singleflight-2lUWfcAk.js";
14
14
  import { n as toBracketKey } from "../_chunks/resolve-schema-CBR6Lm4i.js";
15
15
  import { t as normalizeParamValue } from "../_chunks/param-value-C8TNYchQ.js";
16
- import { s as TIMBER_ERROR_HEADER, t as StatusPageMarker } from "../_chunks/status-page-marker-DwQBrLBz.js";
16
+ import { s as TIMBER_ERROR_HEADER, t as StatusPageMarker } from "../_chunks/status-page-marker-CNbpUOPB.js";
17
17
  import { createHash } from "node:crypto";
18
18
  import { readFile } from "node:fs/promises";
19
19
  import React, { createElement, useContext } from "react";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.207",
3
+ "version": "0.2.0-alpha.208",
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",
@@ -165,8 +165,8 @@
165
165
  "@vitejs/plugin-react": "^6.1.0",
166
166
  "@vitejs/plugin-rsc": ">=0.5.35",
167
167
  "nuqs": "^2.0.0",
168
- "react": "19.3.0",
169
- "react-dom": "19.3.0",
168
+ "react": "^19.3.0",
169
+ "react-dom": "^19.3.0",
170
170
  "satteri": "^0.9.5",
171
171
  "vite": "8.3.0",
172
172
  "vite-plugin-satteri": "^0.2.15",
@@ -91,6 +91,23 @@ export class HistoryStack {
91
91
  return this.entries.delete(url);
92
92
  }
93
93
 
94
+ /**
95
+ * Delete all entries whose pathname matches `pathname` (TIM-1465).
96
+ * Used by `invalidatePath` when the invalidation target has no search
97
+ * string — `/products` should evict `/products?page=1` too, because
98
+ * `revalidatePath('/products')` invalidates the route regardless of
99
+ * query.
100
+ */
101
+ deleteByPathname(pathname: string): void {
102
+ for (const url of this.entries.keys()) {
103
+ const qIndex = url.indexOf('?');
104
+ const urlPathname = qIndex === -1 ? url : url.slice(0, qIndex);
105
+ if (urlPathname === pathname) {
106
+ this.entries.delete(url);
107
+ }
108
+ }
109
+ }
110
+
94
111
  /**
95
112
  * Evict all cached payloads (TIM-1476). Called after a server action
96
113
  * that revalidated data — history entries are equally stale since they
@@ -154,6 +154,10 @@ export interface NavigationCommitter {
154
154
  prepareNavigation(url: string, opts: NavigationCommitInput): PreparedNavigation;
155
155
  currentStateTree(): StateTree | undefined;
156
156
  updateSegmentCache(segmentInfo: SegmentInfo[] | null | undefined): void;
157
+ /** Whether segment reuse is currently suppressed (TIM-1466). */
158
+ isSegmentReuseSuppressed(): boolean;
159
+ /** Lift segment reuse suppression after fresh data has been fetched (TIM-1466). */
160
+ restoreSegmentReuse(): void;
157
161
  }
158
162
 
159
163
  /**
@@ -205,6 +209,8 @@ export function createNavigationCommitter(deps: {
205
209
 
206
210
  return {
207
211
  updateSegmentCache,
212
+ isSegmentReuseSuppressed: () => segmentCache.isReuseSuppressed(),
213
+ restoreSegmentReuse: () => segmentCache.restoreReuse(),
208
214
 
209
215
  /**
210
216
  * The X-Timber-State-Tree to send with the next RSC request.
@@ -60,7 +60,7 @@ export interface NavigationFetchOptions {
60
60
 
61
61
  export interface NavigationPipelineDeps extends Pick<
62
62
  NavigationCommitter,
63
- 'prepareNavigation' | 'currentStateTree'
63
+ 'prepareNavigation' | 'currentStateTree' | 'isSegmentReuseSuppressed' | 'restoreSegmentReuse'
64
64
  > {
65
65
  deps: RouterDeps;
66
66
  prefetchCache: PrefetchCache;
@@ -174,6 +174,8 @@ export function createNavigationPipeline({
174
174
  prefetchCache,
175
175
  currentStateTree,
176
176
  prepareNavigation,
177
+ isSegmentReuseSuppressed,
178
+ restoreSegmentReuse,
177
179
  markHandedOff,
178
180
  forgetOlderHandoffs,
179
181
  }: NavigationPipelineDeps): NavigationPipeline {
@@ -339,6 +341,11 @@ export function createNavigationPipeline({
339
341
  // `currentUrl` is read back off the key rather than computed beside it, so
340
342
  // the pathname sent as X-Timber-URL is the one the lookup used.
341
343
  const stateTree = currentStateTree();
344
+ // Capture whether reuse is suppressed at the time we build the state
345
+ // tree. If so, segments/slots are empty and the server will send a
346
+ // full render. We restore reuse after the response is processed so
347
+ // only navigations that fetched with suppression can lift it (TIM-1466).
348
+ let reuseSuppressedAtFetch = isSegmentReuseSuppressed();
342
349
  const cacheKey = prefetchKeyFor(url, options.departingUrl ?? deps.getCurrentUrl(), stateTree);
343
350
  const currentUrl = cacheKey.from;
344
351
 
@@ -381,11 +388,16 @@ export function createNavigationPipeline({
381
388
  // Capture the eviction generation before awaiting the flight.
382
389
  // If an action evicts caches while we wait, the result predates
383
390
  // the mutation and must be discarded (TIM-1476, codex on #1131).
391
+ // Also check the flight's own starting gen: if the flight started
392
+ // before a per-URL invalidation (TIM-1464), a joiner that arrived
393
+ // after the bump would see genBefore === genAfter but the flight
394
+ // still predates the invalidation.
384
395
  const genBefore = prefetchCache.getEvictionGen();
385
396
  try {
386
397
  const outcome = await raceAbort(inflight, options.signal);
387
- if (prefetchCache.getEvictionGen() !== genBefore) {
388
- // Eviction happened while we waited — discard stale result,
398
+ if (prefetchCache.getEvictionGen() !== genBefore || outcome.startGen !== genBefore) {
399
+ // Eviction happened while we waited, or the flight predates
400
+ // an invalidation we arrived after — discard stale result,
389
401
  // fall through to a fresh fetch below.
390
402
  } else if (outcome.kind === 'non-route') {
391
403
  throw new NonRscResponse(url);
@@ -422,7 +434,13 @@ export function createNavigationPipeline({
422
434
  // No in-flight hover prefetch, or the joined flight failed — fetch
423
435
  // directly with the navigation's signal so superseded navigations
424
436
  // abort immediately.
425
- result = await fetchRscPayload(url, deps, stateTree, currentUrl, options.signal);
437
+ // Resample both the state tree and the suppression flag: if we fell
438
+ // through because an invalidation bumped the eviction gen,
439
+ // invalidateReuse() has suppressed segment/slot reuse since our
440
+ // original capture (TIM-1466, codex on #1167).
441
+ reuseSuppressedAtFetch = isSegmentReuseSuppressed();
442
+ const freshStateTree = currentStateTree();
443
+ result = await fetchRscPayload(url, deps, freshStateTree, currentUrl, options.signal);
426
444
  }
427
445
 
428
446
  // Update the browser history — skip when the Navigation API has already
@@ -467,6 +485,15 @@ export function createNavigationPipeline({
467
485
  skippedSegments: result.skippedSegments,
468
486
  });
469
487
 
488
+ // This navigation's fetch used the suppressed state tree (empty
489
+ // segments/slots), so the server sent a full render. The response
490
+ // data is post-invalidation — safe to restore reuse for subsequent
491
+ // navigations. Must happen here (at response time, before the commit
492
+ // thunk), not in set(), because a pre-invalidation navigation's
493
+ // commit thunk also calls set() and would lift suppression too early
494
+ // (codex on #1167).
495
+ if (reuseSuppressedAtFetch) restoreSegmentReuse();
496
+
470
497
  return {
471
498
  ...result,
472
499
  payload,
@@ -39,7 +39,13 @@ export function createRouter(deps: RouterDeps): RouterInstance {
39
39
 
40
40
  // Every write that makes a page "current", and the read that reports the
41
41
  // current page back to the server. See navigation-commit.ts.
42
- const { prepareNavigation, currentStateTree, updateSegmentCache } = createNavigationCommitter({
42
+ const {
43
+ prepareNavigation,
44
+ currentStateTree,
45
+ updateSegmentCache,
46
+ isSegmentReuseSuppressed,
47
+ restoreSegmentReuse,
48
+ } = createNavigationCommitter({
43
49
  segmentCache,
44
50
  historyStack,
45
51
  clientSegmentCache: () => deps.clientSegmentCache,
@@ -66,6 +72,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
66
72
  prefetchCache,
67
73
  currentStateTree,
68
74
  prepareNavigation,
75
+ isSegmentReuseSuppressed,
76
+ restoreSegmentReuse,
69
77
  markHandedOff,
70
78
  forgetOlderHandoffs,
71
79
  });
@@ -492,8 +500,20 @@ export function createRouter(deps: RouterDeps): RouterInstance {
492
500
  runWhenIdle: (task: () => void) => lifecycle.runWhenIdle(task),
493
501
  settleHandoffs: () => lifecycle.settleHandoffs(),
494
502
  invalidatePath(path: string): void {
495
- historyStack.delete(path);
503
+ const hasSearch = path.includes('?');
504
+ if (hasSearch) {
505
+ historyStack.delete(path);
506
+ } else {
507
+ // TIM-1465: evict all query variants sharing this pathname
508
+ historyStack.deleteByPathname(path);
509
+ }
496
510
  prefetchCache.invalidateUrl(path);
511
+ // TIM-1466: suppress segment/slot reuse so the next navigation
512
+ // gets a full render instead of reusing stale shared layouts.
513
+ // Uses invalidateReuse() instead of clear() to preserve treePaths
514
+ // for interception scoping — a modal mounted on the current tree
515
+ // must still be navigable (codex on #1167).
516
+ segmentCache.invalidateReuse();
497
517
  },
498
518
  evictStaleCaches(): void {
499
519
  prefetchCache.clearReady();
@@ -74,6 +74,16 @@ export interface SegmentNode {
74
74
  */
75
75
  export class SegmentCache {
76
76
  private root: SegmentNode | undefined;
77
+ /**
78
+ * Monotonic counter. Bumped by `invalidateReuse()` (TIM-1466).
79
+ * `serializeStateTree` suppresses `segments` and `slots` when
80
+ * `reuseInvalidatedAt > reuseRestoredAt`. Only `restoreReuse()`
81
+ * advances `reuseRestoredAt` — `set()` does not, because a
82
+ * pre-invalidation navigation's commit thunk would lift suppression
83
+ * before a post-invalidation request has been sent (codex on #1167).
84
+ */
85
+ private reuseInvalidatedAt = 0;
86
+ private reuseRestoredAt = 0;
77
87
 
78
88
  get(segment: string): SegmentNode | undefined {
79
89
  if (segment === '/' || segment === this.root?.segment) {
@@ -90,6 +100,33 @@ export class SegmentCache {
90
100
 
91
101
  clear(): void {
92
102
  this.root = undefined;
103
+ this.reuseRestoredAt = this.reuseInvalidatedAt;
104
+ }
105
+
106
+ /**
107
+ * Suppress segment and slot reuse without destroying the tree
108
+ * structure (TIM-1466). `serializeStateTree` sends empty
109
+ * `segments`/`slots` (forcing a full render) while `treePaths`
110
+ * still reflects the mounted tree for interception scoping.
111
+ * Call `restoreReuse()` once post-invalidation data has been
112
+ * fetched and committed.
113
+ */
114
+ invalidateReuse(): void {
115
+ this.reuseInvalidatedAt++;
116
+ }
117
+
118
+ /**
119
+ * Lift reuse suppression. Called after the segment cache has been
120
+ * rebuilt from a response that was fetched with suppression active
121
+ * (i.e. the server sent a full render). Safe to call idempotently.
122
+ */
123
+ restoreReuse(): void {
124
+ this.reuseRestoredAt = this.reuseInvalidatedAt;
125
+ }
126
+
127
+ /** Whether segment/slot reuse is currently suppressed (TIM-1466). */
128
+ isReuseSuppressed(): boolean {
129
+ return this.reuseInvalidatedAt > this.reuseRestoredAt;
93
130
  }
94
131
 
95
132
  /**
@@ -116,9 +153,12 @@ export class SegmentCache {
116
153
  const segments: string[] = [];
117
154
  const slots: string[] = [];
118
155
  const treePaths: string[] = [];
156
+ const suppressed = this.reuseInvalidatedAt > this.reuseRestoredAt;
119
157
  if (this.root) {
120
- collectSyncSegments(this.root, segments, mergeableFilter);
121
- collectSyncSlots(this.root, slots);
158
+ if (!suppressed) {
159
+ collectSyncSegments(this.root, segments, mergeableFilter);
160
+ collectSyncSlots(this.root, slots);
161
+ }
122
162
  collectTreePaths(this.root, treePaths);
123
163
  }
124
164
  const tree: StateTree = { segments };
@@ -324,6 +364,27 @@ function prefetchMapKey(key: PrefetchKey): string {
324
364
  return `${key.from.length}:${key.from}:${key.scope.length}:${key.scope}:${key.url}`;
325
365
  }
326
366
 
367
+ /**
368
+ * Extract the destination URL from a length-prefixed map key.
369
+ * Key format: `${from.length}:${from}:${scope.length}:${scope}:${url}`
370
+ */
371
+ function extractUrlFromMapKey(mapKey: string): string | null {
372
+ // Parse from-length prefix
373
+ const firstColon = mapKey.indexOf(':');
374
+ if (firstColon === -1) return null;
375
+ const fromLen = parseInt(mapKey.slice(0, firstColon), 10);
376
+ if (Number.isNaN(fromLen)) return null;
377
+ // Skip past `from.length:from:`
378
+ const scopeStart = firstColon + 1 + fromLen + 1;
379
+ // Parse scope-length prefix
380
+ const secondColon = mapKey.indexOf(':', scopeStart);
381
+ if (secondColon === -1) return null;
382
+ const scopeLen = parseInt(mapKey.slice(scopeStart, secondColon), 10);
383
+ if (Number.isNaN(scopeLen)) return null;
384
+ // Skip past `scope.length:scope:` — remainder is the URL
385
+ return mapKey.slice(secondColon + 1 + scopeLen + 1);
386
+ }
387
+
327
388
  /**
328
389
  * The `scope` half of a `PrefetchKey`, derived from the state tree that will
329
390
  * actually be sent. Taking it from the header value rather than re-walking the
@@ -334,8 +395,12 @@ export function prefetchScopeOf(stateTree: StateTree | undefined): string {
334
395
  }
335
396
 
336
397
  /** Outcome of a prefetch flight — discriminated so NonRscResponse is a
337
- * success (stores a negative entry) rather than a rejection. */
338
- export type FlightOutcome = { kind: 'ready'; result: PrefetchResult } | { kind: 'non-route' };
398
+ * success (stores a negative entry) rather than a rejection.
399
+ * `startGen` is the eviction generation at flight start — joiners that
400
+ * started after a gen bump must reject pre-invalidation results (TIM-1464). */
401
+ export type FlightOutcome =
402
+ | { kind: 'ready'; result: PrefetchResult; startGen: number }
403
+ | { kind: 'non-route'; startGen: number };
339
404
 
340
405
  /**
341
406
  * Short-lived cache for hover-triggered prefetches. Entries expire after
@@ -410,16 +475,19 @@ export class PrefetchCache {
410
475
  // Capture the eviction generation before the flight starts. If an
411
476
  // action evicts caches while this flight is in progress, the gen
412
477
  // will have bumped and the stale result is discarded (TIM-1476).
478
+ // The starting gen is also included in the outcome so joiners
479
+ // that started after a gen bump can detect pre-invalidation
480
+ // results (TIM-1464).
413
481
  const genAtStart = this.evictionGen;
414
482
  return this.flights.do(prefetchMapKey(key), async (signal) => {
415
483
  try {
416
484
  const result = await doFetch(signal);
417
485
  if (!signal.aborted && this.evictionGen === genAtStart) this.set(key, result);
418
- return { kind: 'ready' as const, result };
486
+ return { kind: 'ready' as const, result, startGen: genAtStart };
419
487
  } catch (err) {
420
488
  if (isNonRoute(err)) {
421
489
  if (!signal.aborted && this.evictionGen === genAtStart) this.setNegative(key);
422
- return { kind: 'non-route' as const };
490
+ return { kind: 'non-route' as const, startGen: genAtStart };
423
491
  }
424
492
  throw err;
425
493
  }
@@ -455,16 +523,47 @@ export class PrefetchCache {
455
523
  }
456
524
 
457
525
  /**
458
- * Invalidate all entries whose destination URL matches (TIM-1454).
459
- * Used by `revalidatePath` on a non-current route so the next navigation
460
- * fetches fresh data instead of replaying a stale prefetch.
526
+ * Invalidate entries whose destination URL matches (TIM-1454).
527
+ *
528
+ * When `url` has no search string, evicts all entries sharing that
529
+ * pathname — `/products` evicts `/products?page=1` too, because
530
+ * `revalidatePath('/products')` invalidates the route regardless of
531
+ * query (TIM-1465).
532
+ *
533
+ * When `url` has a search string, only the exact URL is evicted.
534
+ *
535
+ * Also bumps the eviction generation so in-flight singleflight
536
+ * flights for the invalidated URL(s) discard their results on
537
+ * completion (TIM-1464).
461
538
  */
462
539
  invalidateUrl(url: string): void {
540
+ const hasSearch = url.includes('?');
541
+ const pathname = hasSearch ? null : url;
542
+
463
543
  for (const [mapKey] of this.entries) {
464
- if (mapKey.endsWith(':' + url)) {
465
- this.entries.delete(mapKey);
544
+ // The map key is `${from.length}:${from}:${scope.length}:${scope}:${url}`.
545
+ // The destination URL is the suffix after the last unescaped colon pair.
546
+ if (hasSearch) {
547
+ if (mapKey.endsWith(':' + url)) {
548
+ this.entries.delete(mapKey);
549
+ }
550
+ } else {
551
+ // Extract the URL suffix — everything after the scope block.
552
+ // Match any entry whose URL pathname matches.
553
+ const entryUrl = extractUrlFromMapKey(mapKey);
554
+ if (entryUrl !== null) {
555
+ const qIndex = entryUrl.indexOf('?');
556
+ const entryPathname = qIndex === -1 ? entryUrl : entryUrl.slice(0, qIndex);
557
+ if (entryPathname === pathname) {
558
+ this.entries.delete(mapKey);
559
+ }
560
+ }
466
561
  }
467
562
  }
563
+
564
+ // Fence in-flight flights so a prefetch that started before
565
+ // invalidation cannot store its stale result (TIM-1464).
566
+ this.evictionGen++;
468
567
  }
469
568
 
470
569
  /**
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ import { timberBuildManifest } from './plugins/build-manifest.ts';
28
28
  import { timberDevLogs, originalConsole, wrapLoggerWithOriginalConsole } from './dev-tools/logs.ts';
29
29
  import { resolveForwardConsole } from './dev-tools/browser-logs.ts';
30
30
  import { timberReactProd } from './plugins/react-prod.ts';
31
+ import { timberReactResolve } from './plugins/react-resolve.ts';
31
32
  import { timberChunks } from './plugins/chunks.ts';
32
33
  import { clientChunkGroup } from './plugins/client-chunks.ts';
33
34
  import { timberServerBundle } from './plugins/server-bundle.ts';
@@ -553,6 +554,7 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
553
554
  // imports manifest. See @vitejs/plugin-rsc's buildApp implementation.
554
555
  return [
555
556
  rootSync,
557
+ timberReactResolve(),
556
558
  timberReactProd(),
557
559
  // @vitejs/plugin-react provides React Fast Refresh (state-preserving HMR)
558
560
  // for client components via Babel transform. Placed before @vitejs/plugin-rsc
@@ -0,0 +1,149 @@
1
+ /**
2
+ * timber-react-resolve — Deduplicate React across timber and user project.
3
+ *
4
+ * Without this plugin, pnpm's strict isolation can install two physical
5
+ * copies of React when the user's pinned version differs from timber's
6
+ * peer dep. Timber's 47 bare `from 'react'` imports resolve to one copy
7
+ * while react-dom uses the other — causing "Invalid hook call" hydration
8
+ * crashes with no indication of the real cause.
9
+ *
10
+ * The fix: inject `resolve.dedupe` entries so Vite always resolves React
11
+ * packages from the project root. Unlike `resolve.alias`, `dedupe`
12
+ * preserves Vite's per-environment export conditions — the RSC
13
+ * environment still gets the `react-server` build, SSR gets the SSR
14
+ * build, and the browser gets the browser build. The deduplication
15
+ * happens at the package level, not the file level.
16
+ *
17
+ * A `configResolved` check detects version mismatches between timber's
18
+ * resolved React and the project's React, and logs an actionable error.
19
+ *
20
+ * Design doc: 18-build-system.md §"Module Resolution"
21
+ * Task: TIM-1520
22
+ */
23
+
24
+ import { createRequire } from 'node:module';
25
+ import { dirname } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+ import type { Plugin } from 'vite';
28
+
29
+ /**
30
+ * React packages to deduplicate. Vite's `resolve.dedupe` extracts the
31
+ * base package name (via `deepImportRE`) before checking, so subpath
32
+ * specifiers like `react/jsx-runtime` are covered by the base `react`
33
+ * entry — listing them separately has no effect.
34
+ */
35
+ export const REACT_DEDUPE_PACKAGES = ['react', 'react-dom'] as const;
36
+
37
+ // ANSI escape codes — match config-validation.ts style
38
+ const RED = '\x1b[31m';
39
+ const BOLD = '\x1b[1m';
40
+ const DIM = '\x1b[2m';
41
+ const RESET = '\x1b[0m';
42
+
43
+ /**
44
+ * Read the version from a package's package.json given a resolve root.
45
+ */
46
+ function readPackageVersion(packageName: string, resolveRoot: string): string | null {
47
+ try {
48
+ const req = createRequire(`${resolveRoot}/package.json`);
49
+ const pkgJsonPath = req.resolve(`${packageName}/package.json`);
50
+ const pkg = req(pkgJsonPath) as { version?: string };
51
+ return pkg.version ?? null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ export interface ReactVersionMismatch {
58
+ specifier: string;
59
+ timberVersion: string;
60
+ projectVersion: string;
61
+ }
62
+
63
+ /**
64
+ * Check whether timber's React and the user project's React resolve to
65
+ * the same version. Returns mismatches if any.
66
+ */
67
+ export function detectReactMismatch(projectRoot: string): ReactVersionMismatch[] {
68
+ const mismatches: ReactVersionMismatch[] = [];
69
+ const timberRoot = dirname(dirname(fileURLToPath(import.meta.url)));
70
+
71
+ for (const pkg of ['react', 'react-dom'] as const) {
72
+ const timberVersion = readPackageVersion(pkg, timberRoot);
73
+ const projectVersion = readPackageVersion(pkg, projectRoot);
74
+
75
+ if (timberVersion && projectVersion && timberVersion !== projectVersion) {
76
+ mismatches.push({ specifier: pkg, timberVersion, projectVersion });
77
+ }
78
+ }
79
+
80
+ return mismatches;
81
+ }
82
+
83
+ /**
84
+ * Format React version mismatches as an actionable warning.
85
+ */
86
+ export function formatReactMismatch(mismatches: ReactVersionMismatch[]): string {
87
+ if (mismatches.length === 0) return '';
88
+
89
+ const lines: string[] = [];
90
+ lines.push(
91
+ `${RED}${BOLD}[timber]${RESET} ${RED}React version mismatch detected — this causes silent hydration crashes${RESET}`
92
+ );
93
+ lines.push('');
94
+ for (const m of mismatches) {
95
+ lines.push(
96
+ ` ${RED}✗${RESET} ${m.specifier}: timber has ${BOLD}${m.timberVersion}${RESET}, project has ${BOLD}${m.projectVersion}${RESET}`
97
+ );
98
+ }
99
+ lines.push('');
100
+ lines.push(
101
+ ` ${DIM}This typically causes "Invalid hook call" errors because two copies of${RESET}`
102
+ );
103
+ lines.push(` ${DIM}React are loaded. timber's resolve.dedupe should handle this, but${RESET}`);
104
+ lines.push(
105
+ ` ${DIM}if you see hook errors, ensure your React version satisfies timber's peer dep.${RESET}`
106
+ );
107
+ lines.push('');
108
+ lines.push(` ${DIM}Fix: update React in your project to match —${RESET}`);
109
+ lines.push(
110
+ ` ${BOLD}pnpm add react@${mismatches[0].timberVersion} react-dom@${mismatches[0].timberVersion}${RESET}`
111
+ );
112
+
113
+ return lines.join('\n');
114
+ }
115
+
116
+ /**
117
+ * Create the timber-react-resolve Vite plugin.
118
+ */
119
+ export function timberReactResolve(): Plugin {
120
+ return {
121
+ name: 'timber-react-resolve',
122
+ enforce: 'pre',
123
+
124
+ config(userConfig) {
125
+ // Merge with any existing dedupe entries the user may have set.
126
+ const existing = userConfig.resolve?.dedupe ?? [];
127
+ const merged = [...existing];
128
+
129
+ for (const pkg of REACT_DEDUPE_PACKAGES) {
130
+ if (!merged.includes(pkg)) {
131
+ merged.push(pkg);
132
+ }
133
+ }
134
+
135
+ return {
136
+ resolve: {
137
+ dedupe: merged,
138
+ },
139
+ };
140
+ },
141
+
142
+ configResolved(resolved) {
143
+ const mismatches = detectReactMismatch(resolved.root);
144
+ if (mismatches.length > 0) {
145
+ process.stderr.write(`${formatReactMismatch(mismatches)}\n\n`);
146
+ }
147
+ },
148
+ };
149
+ }