@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.
- package/dist/_chunks/{error-boundary-BndF-3Td.js → error-boundary-BrHmG-2X.js} +2 -2
- package/dist/_chunks/{error-boundary-BndF-3Td.js.map → error-boundary-BrHmG-2X.js.map} +1 -1
- package/dist/_chunks/{status-page-marker-DwQBrLBz.js → status-page-marker-CNbpUOPB.js} +88 -9
- package/dist/_chunks/status-page-marker-CNbpUOPB.js.map +1 -0
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/history.d.ts +8 -0
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/internal.js +30 -7
- package/dist/client/internal.js.map +1 -1
- package/dist/client/navigation-commit.d.ts +4 -0
- package/dist/client/navigation-commit.d.ts.map +1 -1
- package/dist/client/router-pipeline.d.ts +2 -2
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +44 -4
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +101 -0
- package/dist/index.js.map +1 -1
- package/dist/plugins/react-resolve.d.ts +49 -0
- package/dist/plugins/react-resolve.d.ts.map +1 -0
- package/dist/server/internal.js +1 -1
- package/package.json +3 -3
- package/src/client/history.ts +17 -0
- package/src/client/navigation-commit.ts +6 -0
- package/src/client/router-pipeline.ts +31 -4
- package/src/client/router.ts +22 -2
- package/src/client/segment-cache.ts +110 -11
- package/src/index.ts +2 -0
- package/src/plugins/react-resolve.ts +149 -0
- 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"}
|
package/dist/server/internal.js
CHANGED
|
@@ -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-
|
|
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.
|
|
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",
|
package/src/client/history.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
package/src/client/router.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|
459
|
-
*
|
|
460
|
-
*
|
|
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
|
-
|
|
465
|
-
|
|
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
|
+
}
|