@timber-js/app 0.2.0-alpha.197 → 0.2.0-alpha.199
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/{actions-BS-m5SLv.js → actions-d1hCqnU3.js} +35 -8
- package/dist/_chunks/actions-d1hCqnU3.js.map +1 -0
- package/dist/_chunks/als-registry-C6kcfprT.js.map +1 -1
- package/dist/_chunks/{cache-api-DqzgTEqk.js → cache-api-ByagcC-J.js} +2 -2
- package/dist/_chunks/{cache-api-DqzgTEqk.js.map → cache-api-ByagcC-J.js.map} +1 -1
- package/dist/_chunks/canonicalize-CgHoscYO.js +66 -0
- package/dist/_chunks/canonicalize-CgHoscYO.js.map +1 -0
- package/dist/_chunks/{chains-CZG7E5zg.js → chains-Bpb0W4ax.js} +3 -3
- package/dist/_chunks/{chains-CZG7E5zg.js.map → chains-Bpb0W4ax.js.map} +1 -1
- package/dist/_chunks/{cli-check-bE3H5Bjr.js → cli-check-D6VolrDV.js} +3 -3
- package/dist/_chunks/{cli-check-bE3H5Bjr.js.map → cli-check-D6VolrDV.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-DTy_-Msq.js → cli-schema-sync-D6rO-VcS.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-DTy_-Msq.js.map → cli-schema-sync-D6rO-VcS.js.map} +1 -1
- package/dist/_chunks/{convention-lint-n3RJLgww.js → convention-lint-fRkwVwEH.js} +27 -4
- package/dist/_chunks/convention-lint-fRkwVwEH.js.map +1 -0
- package/dist/_chunks/error-boundary-BfPHZjm0.js +1050 -0
- package/dist/_chunks/error-boundary-BfPHZjm0.js.map +1 -0
- package/dist/_chunks/{live-graph-BXDsdzBv.js → live-graph-D_2D32Ad.js} +3 -3
- package/dist/_chunks/{live-graph-BXDsdzBv.js.map → live-graph-D_2D32Ad.js.map} +1 -1
- package/dist/_chunks/{logger-DDirEsn7.js → logger-uLBuGKDI.js} +471 -440
- package/dist/_chunks/logger-uLBuGKDI.js.map +1 -0
- package/dist/_chunks/{navigation-root-B00jjGd5.js → navigation-context-D0TU0Jog.js} +3 -101
- package/dist/_chunks/navigation-context-D0TU0Jog.js.map +1 -0
- package/dist/_chunks/navigation-root-mHSK9psY.js +126 -0
- package/dist/_chunks/{navigation-root-B00jjGd5.js.map → navigation-root-mHSK9psY.js.map} +1 -1
- package/dist/_chunks/{poison-scan-BoDLgbix.js → poison-scan-Bm9Yyqk9.js} +2 -2
- package/dist/_chunks/{poison-scan-BoDLgbix.js.map → poison-scan-Bm9Yyqk9.js.map} +1 -1
- package/dist/_chunks/{scanner-tdFPvDYi.js → scanner-AiazgH_f.js} +6 -5
- package/dist/_chunks/scanner-AiazgH_f.js.map +1 -0
- package/dist/_chunks/{segment-keys-BhqoHiLc.js → segment-keys-lqtdookO.js} +2 -65
- package/dist/_chunks/segment-keys-lqtdookO.js.map +1 -0
- package/dist/_chunks/{ssr-data-BQGhTPAK.js → ssr-data-D6T6Y3ef.js} +4 -26
- package/dist/_chunks/ssr-data-D6T6Y3ef.js.map +1 -0
- package/dist/_chunks/state-FippDgxN.js +52 -0
- package/dist/_chunks/state-FippDgxN.js.map +1 -0
- package/dist/_chunks/status-page-marker-DwQBrLBz.js +496 -0
- package/dist/_chunks/status-page-marker-DwQBrLBz.js.map +1 -0
- package/dist/_chunks/{walkers-DNX05dC0.js → walkers-B6XUtmqK.js} +2 -2
- package/dist/_chunks/{walkers-DNX05dC0.js.map → walkers-B6XUtmqK.js.map} +1 -1
- package/dist/analyze/crawl-entry.js +2 -2
- package/dist/analyze/graph-command.js +2 -2
- package/dist/cache/index.js +1 -1
- package/dist/cli.js +2 -2
- package/dist/client/browser-entry/action-dispatch.d.ts +6 -4
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/action-queue.d.ts +44 -0
- package/dist/client/browser-entry/action-queue.d.ts.map +1 -0
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/deny-last-resort.d.ts +29 -0
- package/dist/client/deny-last-resort.d.ts.map +1 -0
- package/dist/client/error-boundary.d.ts +48 -2
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +2 -2
- package/dist/client/history.d.ts +21 -2
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/index.js +34 -14
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.d.ts +1 -0
- package/dist/client/internal.d.ts.map +1 -1
- package/dist/client/internal.js +272 -1225
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-commit.d.ts +12 -19
- package/dist/client/navigation-commit.d.ts.map +1 -1
- package/dist/client/navigation-transition.d.ts +62 -11
- package/dist/client/navigation-transition.d.ts.map +1 -1
- package/dist/client/router-effects.d.ts +9 -8
- package/dist/client/router-effects.d.ts.map +1 -1
- package/dist/client/router-lifecycle.d.ts +60 -17
- package/dist/client/router-lifecycle.d.ts.map +1 -1
- package/dist/client/router-pipeline.d.ts +7 -4
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router-types.d.ts +63 -8
- package/dist/client/router-types.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +0 -9
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +23 -8
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/state.d.ts +16 -0
- package/dist/client/state.d.ts.map +1 -1
- package/dist/client/status-page-marker.d.ts +25 -0
- package/dist/client/status-page-marker.d.ts.map +1 -0
- package/dist/cookies/index.js +1 -1
- package/dist/dev-tools/holding-server.d.ts +4 -17
- package/dist/dev-tools/holding-server.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +74 -175
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/routing/codegen-write.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/routing/interception.d.ts +2 -2
- package/dist/routing/slot-placement.d.ts +2 -2
- package/dist/server/access-gate.d.ts +73 -1
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/actions.d.ts +16 -1
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +3 -9
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/children-interception.d.ts +1 -1
- package/dist/server/default-status-page.d.ts +2 -2
- package/dist/server/default-status-page.d.ts.map +1 -1
- package/dist/server/deny-boundary.d.ts +15 -9
- package/dist/server/deny-boundary.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts +21 -4
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/error-response-headers.d.ts +3 -0
- package/dist/server/error-response-headers.d.ts.map +1 -0
- package/dist/server/index.js +3 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.d.ts +1 -2
- package/dist/server/internal.d.ts.map +1 -1
- package/dist/server/internal.js +2339 -2506
- package/dist/server/internal.js.map +1 -1
- package/dist/server/metadata-collector.d.ts +2 -5
- package/dist/server/metadata-collector.d.ts.map +1 -1
- package/dist/server/param-coercion.d.ts +10 -3
- package/dist/server/param-coercion.d.ts.map +1 -1
- package/dist/server/pipeline-outcome.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts +11 -0
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/port-resolution.d.ts +3 -89
- package/dist/server/port-resolution.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +38 -10
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/response-cache-policy.d.ts +3 -0
- package/dist/server/response-cache-policy.d.ts.map +1 -0
- package/dist/server/route-element-builder.d.ts +11 -41
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-element-helpers.d.ts +12 -0
- package/dist/server/route-element-helpers.d.ts.map +1 -0
- package/dist/server/route-module-loader.d.ts +37 -0
- package/dist/server/route-module-loader.d.ts.map +1 -0
- package/dist/server/rsc-cache-key-guard.d.ts.map +1 -1
- package/dist/server/rsc-entry/action-middleware-runner.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts +22 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +4 -11
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/rsc-error-envelope.d.ts +11 -0
- package/dist/server/rsc-error-envelope.d.ts.map +1 -0
- package/dist/server/skippable-prefix.d.ts +18 -15
- package/dist/server/skippable-prefix.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/slot-subtree-contain.d.ts +54 -0
- package/dist/server/slot-subtree-contain.d.ts.map +1 -0
- package/dist/server/stream-utils.d.ts.map +1 -1
- package/dist/server/utils/element-type.d.ts +10 -0
- package/dist/server/utils/element-type.d.ts.map +1 -1
- package/dist/shared/rsc-error-envelope.d.ts +0 -9
- package/dist/shared/rsc-error-envelope.d.ts.map +1 -1
- package/dist/shared/status-reason-phrase.d.ts +26 -0
- package/dist/shared/status-reason-phrase.d.ts.map +1 -0
- package/docs/api/30-api-server.mdx +4 -2
- package/docs/api/31-api-client.mdx +5 -1
- package/docs/api/36-cli.mdx +5 -3
- package/docs/learn/12-error-handling.mdx +5 -1
- package/package.json +10 -10
- package/src/client/browser-entry/action-dispatch.ts +166 -99
- package/src/client/browser-entry/action-queue.ts +90 -0
- package/src/client/browser-entry/router-init.ts +60 -35
- package/src/client/deny-last-resort.tsx +54 -0
- package/src/client/error-boundary.tsx +144 -42
- package/src/client/history.ts +52 -3
- package/src/client/internal.ts +1 -0
- package/src/client/link.tsx +70 -35
- package/src/client/navigation-commit.ts +79 -27
- package/src/client/navigation-transition.ts +176 -127
- package/src/client/router-effects.ts +14 -17
- package/src/client/router-lifecycle.ts +181 -115
- package/src/client/router-pipeline.ts +94 -71
- package/src/client/router-types.ts +61 -7
- package/src/client/router.ts +147 -74
- package/src/client/rsc-fetch.ts +0 -13
- package/src/client/segment-cache.ts +43 -10
- package/src/client/state.ts +26 -0
- package/src/client/status-page-marker.tsx +32 -0
- package/src/dev-tools/holding-server.ts +4 -17
- package/src/index.ts +18 -34
- package/src/plugins/dev-server.ts +2 -1
- package/src/react-canary.d.ts +2 -0
- package/src/routing/codegen-write.ts +2 -0
- package/src/routing/interception.ts +2 -2
- package/src/routing/slot-placement.ts +2 -2
- package/src/server/access-gate.tsx +89 -21
- package/src/server/action-client.ts +2 -2
- package/src/server/action-handler.ts +23 -10
- package/src/server/actions.ts +81 -34
- package/src/server/als-registry.ts +3 -9
- package/src/server/children-interception.ts +1 -1
- package/src/server/default-status-page.ts +7 -47
- package/src/server/deny-boundary.ts +45 -28
- package/src/server/deny-renderer.ts +6 -2
- package/src/server/error-boundary-wrapper.ts +23 -4
- package/src/server/error-response-headers.ts +18 -0
- package/src/server/internal.ts +2 -10
- package/src/server/metadata-collector.ts +3 -18
- package/src/server/param-coercion.ts +13 -4
- package/src/server/pipeline-outcome.ts +35 -13
- package/src/server/pipeline-phases.ts +22 -16
- package/src/server/port-resolution.ts +3 -165
- package/src/server/prebuilt-builder.ts +4 -4
- package/src/server/primitives.ts +75 -11
- package/src/server/response-cache-policy.ts +45 -0
- package/src/server/route-element-builder.ts +149 -412
- package/src/server/route-element-helpers.ts +37 -0
- package/src/server/route-handler.ts +2 -2
- package/src/server/route-module-loader.ts +161 -0
- package/src/server/rsc-cache-key-guard.ts +2 -42
- package/src/server/rsc-entry/action-middleware-runner.ts +4 -4
- package/src/server/rsc-entry/api-handler.ts +5 -5
- package/src/server/rsc-entry/error-renderer.ts +3 -4
- package/src/server/rsc-entry/helpers.ts +1 -1
- package/src/server/rsc-entry/index.ts +3 -3
- package/src/server/rsc-entry/render-route.ts +4 -8
- package/src/server/rsc-entry/rsc-payload.ts +59 -42
- package/src/server/rsc-entry/rsc-stream.ts +48 -27
- package/src/server/rsc-entry/ssr-renderer.ts +6 -10
- package/src/server/rsc-error-envelope.ts +18 -0
- package/src/server/skippable-prefix.ts +105 -7
- package/src/server/slot-resolver.ts +43 -12
- package/src/server/slot-subtree-contain.ts +255 -0
- package/src/server/stream-utils.ts +12 -8
- package/src/server/utils/element-type.ts +18 -2
- package/src/shared/rsc-error-envelope.ts +0 -15
- package/src/shared/status-reason-phrase.ts +61 -0
- package/dist/_chunks/actions-BS-m5SLv.js.map +0 -1
- package/dist/_chunks/convention-lint-n3RJLgww.js.map +0 -1
- package/dist/_chunks/error-boundary-BvRCCmbN.js +0 -353
- package/dist/_chunks/error-boundary-BvRCCmbN.js.map +0 -1
- package/dist/_chunks/logger-DDirEsn7.js.map +0 -1
- package/dist/_chunks/mdx-file-CXyHGUpS.js +0 -25
- package/dist/_chunks/mdx-file-CXyHGUpS.js.map +0 -1
- package/dist/_chunks/router-ref-8gr8qsxN.js +0 -28
- package/dist/_chunks/router-ref-8gr8qsxN.js.map +0 -1
- package/dist/_chunks/rsc-error-envelope-tT5PJs4q.js +0 -40
- package/dist/_chunks/rsc-error-envelope-tT5PJs4q.js.map +0 -1
- package/dist/_chunks/scanner-tdFPvDYi.js.map +0 -1
- package/dist/_chunks/segment-keys-BhqoHiLc.js.map +0 -1
- package/dist/_chunks/ssr-data-BQGhTPAK.js.map +0 -1
- package/dist/server/tree-builder.d.ts +0 -150
- package/dist/server/tree-builder.d.ts.map +0 -1
- package/src/server/tree-builder.ts +0 -313
package/src/client/router.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Segment Router — the operations a client-side navigation can be.
|
|
2
2
|
//
|
|
3
|
-
// `navigate`, `refresh`, `handlePopState`, `prefetch` and `
|
|
3
|
+
// `navigate`, `refresh`, `handlePopState`, `prefetch` and `applyActionResult`
|
|
4
4
|
// live here; each is wiring over modules that own one concern apiece:
|
|
5
5
|
//
|
|
6
6
|
// router-types.ts — RouterDeps / RouterInstance / the option shapes
|
|
@@ -24,6 +24,7 @@ import { createNavigationLifecycle } from './router-lifecycle.ts';
|
|
|
24
24
|
import { createNavigationPipeline, prefetchKeyFor } from './router-pipeline.ts';
|
|
25
25
|
import { recordSkew } from './router-skew.ts';
|
|
26
26
|
import type { NavigationOptions, RouterDeps, RouterInstance } from './router-types.ts';
|
|
27
|
+
import { createRenderOwner, type CommitOutcome } from './navigation-transition.ts';
|
|
27
28
|
|
|
28
29
|
// ─── Router Factory ──────────────────────────────────────────────
|
|
29
30
|
|
|
@@ -46,19 +47,20 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
46
47
|
|
|
47
48
|
// Ownership of the router: who is navigating, whose fetch may be cut, and
|
|
48
49
|
// the pending store TopLoader subscribes to. See router-lifecycle.ts.
|
|
50
|
+
const lifecycle = createNavigationLifecycle(deps);
|
|
49
51
|
const {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
currentOwner,
|
|
53
|
+
createNavOwner,
|
|
52
54
|
runNavigation,
|
|
53
55
|
markHandedOff,
|
|
54
56
|
forgetOlderHandoffs,
|
|
55
57
|
isPending,
|
|
56
58
|
getPendingUrl,
|
|
57
59
|
onPendingChange,
|
|
58
|
-
} =
|
|
60
|
+
} = lifecycle;
|
|
59
61
|
|
|
60
62
|
// Fetch → commit → hand to React. See router-pipeline.ts.
|
|
61
|
-
const { performNavigationFetch, renderViaTransition,
|
|
63
|
+
const { performNavigationFetch, renderViaTransition, resolveForFallback } =
|
|
62
64
|
createNavigationPipeline({
|
|
63
65
|
deps,
|
|
64
66
|
prefetchCache,
|
|
@@ -76,15 +78,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
76
78
|
// navigation still own the router?" question is asked once rather than
|
|
77
79
|
// per branch (TIM-1275, TIM-1276).
|
|
78
80
|
const { leaveSpaIfOwned, leaveSpaSuperseding } = createSpaExits({
|
|
79
|
-
|
|
80
|
-
supersede: () => void
|
|
81
|
+
currentOwner,
|
|
82
|
+
supersede: () => void createNavOwner('navigation'),
|
|
81
83
|
});
|
|
82
84
|
|
|
83
85
|
// Every way an RSC fetch can fail that the router answers rather than
|
|
84
86
|
// rethrows. Shared by navigate(), refresh(), and traversals so a path that
|
|
85
87
|
// grows a new branch grows it for all of them (TIM-1277).
|
|
86
88
|
const recoverFromNavigationError = createNavigationRecovery({
|
|
87
|
-
|
|
89
|
+
currentOwner,
|
|
88
90
|
leaveSpaIfOwned,
|
|
89
91
|
// Hoisted — `navigate` is a function declaration below.
|
|
90
92
|
navigate: (url) => navigate(url, { replace: true }),
|
|
@@ -137,10 +139,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
137
139
|
|
|
138
140
|
await runNavigation(
|
|
139
141
|
url,
|
|
140
|
-
async (
|
|
142
|
+
async (owner) => {
|
|
141
143
|
// When Navigation API is active, initiate the navigation via
|
|
142
144
|
// navigation.navigate() BEFORE the fetch. Must happen after
|
|
143
|
-
//
|
|
145
|
+
// createNavOwner supersedes the previous navigation (done by
|
|
144
146
|
// runNavigation) so the old deferred is resolved first.
|
|
145
147
|
if (!effectiveSkipHistory && deps.navigationNavigate) {
|
|
146
148
|
deps.setRouterNavigating?.(true);
|
|
@@ -150,14 +152,18 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
150
152
|
}
|
|
151
153
|
|
|
152
154
|
try {
|
|
153
|
-
await renderViaTransition(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
155
|
+
await renderViaTransition(
|
|
156
|
+
fetchUrl,
|
|
157
|
+
owner,
|
|
158
|
+
() =>
|
|
159
|
+
performNavigationFetch(fetchUrl, {
|
|
160
|
+
replace,
|
|
161
|
+
commitUrl: url,
|
|
162
|
+
signal: owner.fetchAbort.signal,
|
|
163
|
+
skipHistory: effectiveSkipHistory,
|
|
164
|
+
departingUrl,
|
|
165
|
+
}),
|
|
166
|
+
options.onCommit
|
|
161
167
|
);
|
|
162
168
|
|
|
163
169
|
// Scroll-to-top on forward navigation, scroll to the #fragment target
|
|
@@ -173,7 +179,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
173
179
|
// load of the destination, fragment included (TIM-1234). Reloading
|
|
174
180
|
// instead would rebuild the page they were *leaving* and discard
|
|
175
181
|
// the click (TIM-1275).
|
|
176
|
-
if (await recoverFromNavigationError(error,
|
|
182
|
+
if (await recoverFromNavigationError(error, owner, url, departingUrl)) return;
|
|
177
183
|
throw error;
|
|
178
184
|
}
|
|
179
185
|
},
|
|
@@ -198,31 +204,37 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
198
204
|
/** Restored after paint when present — traversals only. */
|
|
199
205
|
scrollY?: number;
|
|
200
206
|
externalSignal?: AbortSignal;
|
|
207
|
+
/** Fires once when React commits, or when the navigation is abandoned. */
|
|
208
|
+
onCommit?: (outcome: CommitOutcome) => void;
|
|
201
209
|
} = {}
|
|
202
210
|
): Promise<void> {
|
|
203
211
|
await runNavigation(
|
|
204
212
|
url,
|
|
205
|
-
async (
|
|
213
|
+
async (owner) => {
|
|
206
214
|
try {
|
|
207
|
-
await renderViaTransition(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
payload
|
|
219
|
-
params
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
215
|
+
await renderViaTransition(
|
|
216
|
+
url,
|
|
217
|
+
owner,
|
|
218
|
+
async () => {
|
|
219
|
+
const result = await fetchRscPayload(
|
|
220
|
+
url,
|
|
221
|
+
deps,
|
|
222
|
+
opts.stateTree,
|
|
223
|
+
undefined,
|
|
224
|
+
owner.fetchAbort.signal
|
|
225
|
+
);
|
|
226
|
+
const payload = await resolveForFallback(result.payload);
|
|
227
|
+
const params = await result.params;
|
|
228
|
+
const { navState, commit } = prepareNavigation(url, {
|
|
229
|
+
payload,
|
|
230
|
+
params,
|
|
231
|
+
segmentInfo: result.segmentInfo,
|
|
232
|
+
skippedSegments: result.skippedSegments,
|
|
233
|
+
});
|
|
234
|
+
return { ...result, payload, params, navState, commit };
|
|
235
|
+
},
|
|
236
|
+
opts.onCommit
|
|
237
|
+
);
|
|
226
238
|
} catch (error) {
|
|
227
239
|
// Neither path is a navigate(), and neither has a caller that
|
|
228
240
|
// handles a rejection — `refresh()` is `void`-called everywhere it
|
|
@@ -235,7 +247,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
235
247
|
// browser has already traversed, and `refresh()` is by definition
|
|
236
248
|
// where it already is. So `hardNavigate()` takes its same-document
|
|
237
249
|
// branch and reloads rather than pushing an entry.
|
|
238
|
-
if (await recoverFromNavigationError(error,
|
|
250
|
+
if (await recoverFromNavigationError(error, owner, url, url)) return;
|
|
239
251
|
throw error;
|
|
240
252
|
}
|
|
241
253
|
|
|
@@ -245,7 +257,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
245
257
|
);
|
|
246
258
|
}
|
|
247
259
|
|
|
248
|
-
async function refresh(): Promise<void> {
|
|
260
|
+
async function refresh(options?: { onCommit?: (outcome: CommitOutcome) => void }): Promise<void> {
|
|
249
261
|
const currentUrl = deps.getCurrentUrl();
|
|
250
262
|
|
|
251
263
|
// A refresh on a stale client is a full document load of the current URL.
|
|
@@ -256,7 +268,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
256
268
|
await leaveSpaSuperseding(currentUrl, currentUrl);
|
|
257
269
|
}
|
|
258
270
|
|
|
259
|
-
await fetchCommitAndRender(currentUrl);
|
|
271
|
+
await fetchCommitAndRender(currentUrl, { onCommit: options?.onCommit });
|
|
260
272
|
}
|
|
261
273
|
|
|
262
274
|
async function handlePopState(
|
|
@@ -290,7 +302,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
290
302
|
// synchronous — the fn resolves immediately.
|
|
291
303
|
await runNavigation(
|
|
292
304
|
url,
|
|
293
|
-
async () => {
|
|
305
|
+
async (owner) => {
|
|
294
306
|
// clearSegmentCacheOnEmpty: popstate to an entry without layout
|
|
295
307
|
// metadata (e.g., initial SSR page) clears the cache so the next
|
|
296
308
|
// forward navigation gets a full render.
|
|
@@ -302,13 +314,30 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
302
314
|
// here. Publishing first would advertise the replayed route's slot
|
|
303
315
|
// keys while the slot content cache still describes the departing
|
|
304
316
|
// one (TIM-1423, codex on #1108).
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
317
|
+
//
|
|
318
|
+
// Routed through renderViaTransition so the replay gets the same
|
|
319
|
+
// lifecycle every other render has — handoff tracking, commit-on-
|
|
320
|
+
// commit, forgetOlderHandoffs — rather than a hand-rolled publish
|
|
321
|
+
// that misses one of them (TIM-1478). The perform resolves
|
|
322
|
+
// immediately: there is no fetch, and the cached payload is
|
|
323
|
+
// already decoded.
|
|
324
|
+
await renderViaTransition(url, owner, async () => {
|
|
325
|
+
const { navState, commit } = prepareNavigation(url, {
|
|
326
|
+
payload: entry.payload,
|
|
327
|
+
params: entry.params,
|
|
328
|
+
segmentInfo: entry.segmentInfo,
|
|
329
|
+
clearSegmentCacheOnEmpty: true,
|
|
330
|
+
});
|
|
331
|
+
return {
|
|
332
|
+
payload: entry.payload,
|
|
333
|
+
params: entry.params,
|
|
334
|
+
navState,
|
|
335
|
+
commit,
|
|
336
|
+
decodePromise: null,
|
|
337
|
+
segmentInfo: entry.segmentInfo ?? null,
|
|
338
|
+
skippedSegments: null,
|
|
339
|
+
};
|
|
310
340
|
});
|
|
311
|
-
renderPayload(entry.payload, navState, entry.params, commit);
|
|
312
341
|
restoreScrollAfterPaint(scrollY);
|
|
313
342
|
},
|
|
314
343
|
externalSignal
|
|
@@ -356,7 +385,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
356
385
|
|
|
357
386
|
// Don't prefetch if already cached (ready or negative)
|
|
358
387
|
if (prefetchCache.has(cacheKey)) return;
|
|
359
|
-
if (historyStack.has(fetchUrl)) return;
|
|
360
388
|
|
|
361
389
|
// Fire-and-forget. Concurrent hovers coalesce in the singleflight;
|
|
362
390
|
// a click during the round-trip joins the same flight via
|
|
@@ -399,32 +427,77 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
399
427
|
getPendingUrl,
|
|
400
428
|
onPendingChange,
|
|
401
429
|
prefetch,
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
430
|
+
epoch: () => lifecycle.epoch(),
|
|
431
|
+
|
|
432
|
+
async applyActionResult(epoch, tree) {
|
|
433
|
+
if (!lifecycle.isEpochCurrent(epoch)) return false;
|
|
434
|
+
if (tree === undefined) {
|
|
435
|
+
// Await through the commit so the next queued action captures a
|
|
436
|
+
// current epoch — refresh() alone resolves before React commits
|
|
437
|
+
// on the History API fallback (codex on #1125). The outcome
|
|
438
|
+
// distinguishes a committed refresh from one superseded by a
|
|
439
|
+
// navigation the user started while the refresh was in flight
|
|
440
|
+
// (TIM-1477).
|
|
441
|
+
let outcomeResolve!: (o: CommitOutcome) => void;
|
|
442
|
+
const outcomePromise = new Promise<CommitOutcome>((r) => (outcomeResolve = r));
|
|
443
|
+
const [, outcome] = await Promise.all([
|
|
444
|
+
refresh({ onCommit: outcomeResolve }).catch(() => {}),
|
|
445
|
+
outcomePromise,
|
|
446
|
+
]);
|
|
447
|
+
return outcome === 'committed';
|
|
448
|
+
}
|
|
449
|
+
// Render the piggybacked payload from a server action response.
|
|
450
|
+
// Routed through renderViaTransition so the piggybacked tree gets
|
|
451
|
+
// the same commit lifecycle every other render has (TIM-1478).
|
|
452
|
+
// The perform resolves immediately — the payload is already in hand.
|
|
412
453
|
const currentUrl = deps.getCurrentUrl();
|
|
413
|
-
const
|
|
414
|
-
const params = readPublishedParams(
|
|
415
|
-
|
|
416
|
-
// Preserve existing segmentInfo so away-and-back navigation replays
|
|
417
|
-
// with a correct segment cache (TIM-1037).
|
|
454
|
+
const payloadTree = readPayloadTree(tree);
|
|
455
|
+
const params = readPublishedParams(tree);
|
|
418
456
|
const existingEntry = historyStack.get(currentUrl);
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
457
|
+
|
|
458
|
+
let outcomeResolve!: (o: CommitOutcome) => void;
|
|
459
|
+
const outcomePromise = new Promise<CommitOutcome>((r) => (outcomeResolve = r));
|
|
460
|
+
// Place in the lifecycle's slot so a concurrent navigation supersedes
|
|
461
|
+
// this owner. The epoch check above ensures the slot is idle. Don't use
|
|
462
|
+
// createNavOwner — that bumps navigationSeq, which is reserved for
|
|
463
|
+
// navigations (the epoch test pins this: TIM-1474).
|
|
464
|
+
const owner = createRenderOwner('revalidation');
|
|
465
|
+
lifecycle.placeRevalidationOwner(owner);
|
|
466
|
+
const [, outcome] = await Promise.all([
|
|
467
|
+
renderViaTransition(
|
|
468
|
+
currentUrl,
|
|
469
|
+
owner,
|
|
470
|
+
async () => {
|
|
471
|
+
const { navState, commit } = prepareNavigation(currentUrl, {
|
|
472
|
+
payload: payloadTree,
|
|
473
|
+
params,
|
|
474
|
+
segmentInfo: existingEntry?.segmentInfo,
|
|
475
|
+
});
|
|
476
|
+
return {
|
|
477
|
+
payload: payloadTree,
|
|
478
|
+
params,
|
|
479
|
+
navState,
|
|
480
|
+
commit,
|
|
481
|
+
decodePromise: null,
|
|
482
|
+
segmentInfo: existingEntry?.segmentInfo ?? null,
|
|
483
|
+
skippedSegments: null,
|
|
484
|
+
};
|
|
485
|
+
},
|
|
486
|
+
outcomeResolve
|
|
487
|
+
).catch(() => {}),
|
|
488
|
+
outcomePromise,
|
|
489
|
+
]);
|
|
490
|
+
return outcome === 'committed';
|
|
491
|
+
},
|
|
492
|
+
runWhenIdle: (task: () => void) => lifecycle.runWhenIdle(task),
|
|
493
|
+
settleHandoffs: () => lifecycle.settleHandoffs(),
|
|
494
|
+
invalidatePath(path: string): void {
|
|
495
|
+
historyStack.delete(path);
|
|
496
|
+
prefetchCache.invalidateUrl(path);
|
|
497
|
+
},
|
|
498
|
+
evictStaleCaches(): void {
|
|
499
|
+
prefetchCache.clearReady();
|
|
500
|
+
historyStack.clearExcept(deps.getCurrentUrl());
|
|
428
501
|
},
|
|
429
502
|
initSegmentCache: (segments: SegmentInfo[]) => updateSegmentCache(segments),
|
|
430
503
|
segmentCache,
|
package/src/client/rsc-fetch.ts
CHANGED
|
@@ -58,15 +58,6 @@ export interface FetchResult {
|
|
|
58
58
|
segmentInfo: SegmentInfo[] | null;
|
|
59
59
|
/** Segment paths that were skipped by the server (for client-side merging). */
|
|
60
60
|
skippedSegments: string[] | null;
|
|
61
|
-
/**
|
|
62
|
-
* HTTP status of the navigation response.
|
|
63
|
-
*
|
|
64
|
-
* Read at the commit site to decide whether the tree this payload produced
|
|
65
|
-
* is one worth caching. A deny page replaced a subtree, so `segmentInfo`
|
|
66
|
-
* describes more than actually mounted — but it cannot simply be emptied,
|
|
67
|
-
* because the merge point is chosen out of it. See TIM-1356.
|
|
68
|
-
*/
|
|
69
|
-
status: number;
|
|
70
61
|
}
|
|
71
62
|
|
|
72
63
|
// ─── URL Helpers ─────────────────────────────────────────────────
|
|
@@ -537,7 +528,6 @@ export async function fetchRscPayload(
|
|
|
537
528
|
const fetchPromise = deps.fetch(rscUrl, { headers, redirect: 'manual', signal });
|
|
538
529
|
let segmentInfo: SegmentInfo[] | null = null;
|
|
539
530
|
let skippedSegments: string[] | null = null;
|
|
540
|
-
let status = 200;
|
|
541
531
|
// Track when the full RSC body stream is consumed (not just shell).
|
|
542
532
|
// Initialized to resolved for bodyless responses; overwritten when
|
|
543
533
|
// the response has a body.
|
|
@@ -592,7 +582,6 @@ export async function fetchRscPayload(
|
|
|
592
582
|
// as React elements — React 19 Float handles them. See TIM-1151.
|
|
593
583
|
segmentInfo = extractSegmentInfo(response);
|
|
594
584
|
skippedSegments = extractSkippedSegments(response);
|
|
595
|
-
status = response.status;
|
|
596
585
|
|
|
597
586
|
// Wrap the body to track full stream consumption. createFromFetch's
|
|
598
587
|
// thenable resolves when the root model (shell) arrives, but we need
|
|
@@ -641,7 +630,6 @@ export async function fetchRscPayload(
|
|
|
641
630
|
decodePromise,
|
|
642
631
|
segmentInfo,
|
|
643
632
|
skippedSegments,
|
|
644
|
-
status,
|
|
645
633
|
};
|
|
646
634
|
}
|
|
647
635
|
// Test/fallback path: return raw text
|
|
@@ -681,6 +669,5 @@ export async function fetchRscPayload(
|
|
|
681
669
|
decodePromise: null,
|
|
682
670
|
segmentInfo: extractSegmentInfo(response),
|
|
683
671
|
skippedSegments: extractSkippedSegments(response),
|
|
684
|
-
status: response.status,
|
|
685
672
|
};
|
|
686
673
|
}
|
|
@@ -27,14 +27,6 @@ export interface PrefetchResult {
|
|
|
27
27
|
params?: PublishedParams | Promise<PublishedParams>;
|
|
28
28
|
/** Segment paths skipped by the server (for client-side merging). */
|
|
29
29
|
skippedSegments?: string[] | null;
|
|
30
|
-
/**
|
|
31
|
-
* HTTP status of the prefetched response. A deny payload is an ordinary
|
|
32
|
-
* Flight response carrying a 4xx, so it prefetches and caches like any
|
|
33
|
-
* other — and the tree it renders is one the segment cache must not learn
|
|
34
|
-
* from. Absent on entries that predate the field, and on the negative
|
|
35
|
-
* sentinel. See TIM-1356.
|
|
36
|
-
*/
|
|
37
|
-
status?: number;
|
|
38
30
|
}
|
|
39
31
|
|
|
40
32
|
/**
|
|
@@ -363,6 +355,7 @@ export class PrefetchCache {
|
|
|
363
355
|
private static readonly TTL_MS = 30_000;
|
|
364
356
|
private entries = new Map<string, ReadyEntry>();
|
|
365
357
|
private flights = createSingleflight({ timeoutMs: PREFETCH_SINGLEFLIGHT_TIMEOUT_MS });
|
|
358
|
+
private evictionGen = 0;
|
|
366
359
|
|
|
367
360
|
set(key: PrefetchKey, result: PrefetchResult): void {
|
|
368
361
|
this.entries.set(prefetchMapKey(key), {
|
|
@@ -414,14 +407,18 @@ export class PrefetchCache {
|
|
|
414
407
|
doFetch: (signal: AbortSignal) => Promise<PrefetchResult>,
|
|
415
408
|
isNonRoute: (error: unknown) => boolean
|
|
416
409
|
): Promise<FlightOutcome> {
|
|
410
|
+
// Capture the eviction generation before the flight starts. If an
|
|
411
|
+
// action evicts caches while this flight is in progress, the gen
|
|
412
|
+
// will have bumped and the stale result is discarded (TIM-1476).
|
|
413
|
+
const genAtStart = this.evictionGen;
|
|
417
414
|
return this.flights.do(prefetchMapKey(key), async (signal) => {
|
|
418
415
|
try {
|
|
419
416
|
const result = await doFetch(signal);
|
|
420
|
-
if (!signal.aborted) this.set(key, result);
|
|
417
|
+
if (!signal.aborted && this.evictionGen === genAtStart) this.set(key, result);
|
|
421
418
|
return { kind: 'ready' as const, result };
|
|
422
419
|
} catch (err) {
|
|
423
420
|
if (isNonRoute(err)) {
|
|
424
|
-
if (!signal.aborted) this.setNegative(key);
|
|
421
|
+
if (!signal.aborted && this.evictionGen === genAtStart) this.setNegative(key);
|
|
425
422
|
return { kind: 'non-route' as const };
|
|
426
423
|
}
|
|
427
424
|
throw err;
|
|
@@ -456,4 +453,40 @@ export class PrefetchCache {
|
|
|
456
453
|
isNegative(key: PrefetchKey): boolean {
|
|
457
454
|
return this.get(key) === NEGATIVE_ENTRY;
|
|
458
455
|
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
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.
|
|
461
|
+
*/
|
|
462
|
+
invalidateUrl(url: string): void {
|
|
463
|
+
for (const [mapKey] of this.entries) {
|
|
464
|
+
if (mapKey.endsWith(':' + url)) {
|
|
465
|
+
this.entries.delete(mapKey);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Evict all ready entries and fence in-flight singleflights (TIM-1476).
|
|
472
|
+
* Called after a server action that revalidated data — the mutation may
|
|
473
|
+
* affect any route, not just the paths the server named. In-flight
|
|
474
|
+
* singleflights are not aborted (they may carry post-mutation data),
|
|
475
|
+
* but their results are discarded if they complete after eviction:
|
|
476
|
+
* `fetchOrCoalesce` captures the generation before the flight and
|
|
477
|
+
* skips the store when it has been bumped.
|
|
478
|
+
*
|
|
479
|
+
* Consumers that join an in-flight flight via `joinInflight` must
|
|
480
|
+
* check `getEvictionGen()` before and after the await: if the gen
|
|
481
|
+
* changed, the result predates the eviction and must be discarded
|
|
482
|
+
* (codex on #1131 round 2).
|
|
483
|
+
*/
|
|
484
|
+
clearReady(): void {
|
|
485
|
+
this.entries.clear();
|
|
486
|
+
this.evictionGen++;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
getEvictionGen(): number {
|
|
490
|
+
return this.evictionGen;
|
|
491
|
+
}
|
|
459
492
|
}
|
package/src/client/state.ts
CHANGED
|
@@ -69,3 +69,29 @@ export let unloading = false;
|
|
|
69
69
|
export function _setUnloading(value: boolean): void {
|
|
70
70
|
unloading = value;
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
// ─── Status Page Commit Flag (from navigation-commit.ts) ────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether a status page for the main route has rendered since the last
|
|
77
|
+
* navigation was prepared. Raised by `StatusPageMarker`, consumed by the
|
|
78
|
+
* navigation's commit. See navigation-commit.ts §"What a status page
|
|
79
|
+
* teaches the cache".
|
|
80
|
+
*/
|
|
81
|
+
export let statusPageRendered = false;
|
|
82
|
+
|
|
83
|
+
export function _setStatusPageRendered(value: boolean): void {
|
|
84
|
+
statusPageRendered = value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What a status page report does beyond raising the flag: registered by the
|
|
89
|
+
* router's committer so the report can clear the segment cache at once, for
|
|
90
|
+
* the commits no navigation consumes (a row arriving inside Suspense after
|
|
91
|
+
* its navigation published, a status document being hydrated).
|
|
92
|
+
*/
|
|
93
|
+
export let onStatusPageRendered: (() => void) | undefined;
|
|
94
|
+
|
|
95
|
+
export function _setOnStatusPageRendered(handler: (() => void) | undefined): void {
|
|
96
|
+
onStatusPageRendered = handler;
|
|
97
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one way a status page tells the router it is on screen.
|
|
5
|
+
*
|
|
6
|
+
* A status page replaces a subtree, and the segment cache must not learn a
|
|
7
|
+
* chain that never mounted (see `navigation-commit.ts` §"What a status page
|
|
8
|
+
* teaches the cache"). Status pages are produced in three places — an
|
|
9
|
+
* in-tree catch site rendering the page it owns, the server's fallback
|
|
10
|
+
* renderers, and a client boundary rendering its fallback for a Flight row —
|
|
11
|
+
* and every one of them wraps the page in this marker, so the report does
|
|
12
|
+
* not depend on which producer it was. In particular the in-tree case, the
|
|
13
|
+
* common `access.ts` deny, never involves a client boundary at all.
|
|
14
|
+
*
|
|
15
|
+
* A layout effect, on every commit that renders the marker rather than on
|
|
16
|
+
* mount only: React reuses the fiber when one status page is replaced by
|
|
17
|
+
* another at the same position, and the second one must report too. Layout
|
|
18
|
+
* effects run child-before-parent, so the report lands before the ancestor
|
|
19
|
+
* `NavigationRoot` publishes the same commit, and never for a render React
|
|
20
|
+
* discards. Idempotent, so StrictMode's double invocation and a status page
|
|
21
|
+
* re-rendering on its own state are harmless. See TIM-1495.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { useLayoutEffect, type ReactNode } from 'react';
|
|
25
|
+
import { reportStatusPageRendered } from './navigation-commit.ts';
|
|
26
|
+
|
|
27
|
+
export function StatusPageMarker({ children }: { children: ReactNode }): ReactNode {
|
|
28
|
+
useLayoutEffect(() => {
|
|
29
|
+
reportStatusPageRendered();
|
|
30
|
+
});
|
|
31
|
+
return children;
|
|
32
|
+
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* The holding server is started in rootSync's config() hook (the
|
|
15
15
|
* earliest Vite plugin lifecycle point where we know the port) and
|
|
16
16
|
* closed directly in timber-dev-server's configureServer() hook
|
|
17
|
-
* (the last plugin,
|
|
17
|
+
* (the last plugin, awaited). configureServer runs during
|
|
18
18
|
* createServer(), well before Vite's port probe and bind, so the
|
|
19
19
|
* port is free by the time httpServerStart() runs. (TIM-1419)
|
|
20
20
|
*
|
|
@@ -121,22 +121,9 @@ const HOLDING_PAGE_HTML = [
|
|
|
121
121
|
/**
|
|
122
122
|
* Create a holding HTTP server that serves a loading page during startup.
|
|
123
123
|
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* // Use bindWithBump() to honor the default-3000 + auto-bump policy
|
|
128
|
-
* // (see ../server/port-resolution.ts and TIM-842).
|
|
129
|
-
* const holding = createHoldingServer();
|
|
130
|
-
* await bindWithBump((p) => holding.listen(p), { startPort: 3000, autoBump: true });
|
|
131
|
-
*
|
|
132
|
-
* // In last plugin's configureServer() — close directly so Vite 8's
|
|
133
|
-
* // isPortAvailable() probe and httpServer.listen() both see a free
|
|
134
|
-
* // port. SO_REUSEADDR allows immediate rebind. (TIM-1419)
|
|
135
|
-
* if (ctx.holdingServer) {
|
|
136
|
-
* ctx.holdingServer.close().catch(() => {});
|
|
137
|
-
* ctx.holdingServer = null;
|
|
138
|
-
* }
|
|
139
|
-
* ```
|
|
124
|
+
* The config hook attempts one bind on the requested port. If it fails,
|
|
125
|
+
* startup continues without the holding page; Vite owns port selection.
|
|
126
|
+
* The last configureServer hook awaits close() before Vite probes/binds.
|
|
140
127
|
*/
|
|
141
128
|
export function createHoldingServer(): HoldingServer {
|
|
142
129
|
const server = http.createServer((req, res) => {
|
package/src/index.ts
CHANGED
|
@@ -50,7 +50,7 @@ import {
|
|
|
50
50
|
shouldEnableEncryption,
|
|
51
51
|
} from './server/action-encryption.ts';
|
|
52
52
|
import { createHoldingServer } from './dev-tools/holding-server.ts';
|
|
53
|
-
import { resolveStartPort
|
|
53
|
+
import { resolveStartPort } from './server/port-resolution.ts';
|
|
54
54
|
import type { TimberUserConfig } from './config-types.ts';
|
|
55
55
|
import type { PluginContext, TimberPluginApi } from './plugin-context.ts';
|
|
56
56
|
import {
|
|
@@ -319,13 +319,10 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
|
|
|
319
319
|
// override below fixes Vite's native path; plugin-react's compiler
|
|
320
320
|
// plugin is handled by patchReactCompilerForProdJsx above.
|
|
321
321
|
// ── Resolve dev/preview port (TIM-842) ───────────────────────
|
|
322
|
-
// Default port is 3000
|
|
323
|
-
// overrides (`--port`, `PORT` env, `vite.config.ts` server.port)
|
|
324
|
-
// are honored as-is and fail loudly via `strictPort: true`.
|
|
322
|
+
// Default port is 3000. Vite handles conflicts and honors server.strictPort.
|
|
325
323
|
//
|
|
326
324
|
// For dev (`command === 'serve' && !isPreview`) we start the
|
|
327
|
-
// holding server
|
|
328
|
-
// the probe with the real bind to eliminate TOCTOU races.
|
|
325
|
+
// holding server on the requested port when available.
|
|
329
326
|
//
|
|
330
327
|
// For `vite preview` (`command === 'serve' && isPreview`) we
|
|
331
328
|
// resolve the config/env port but delegate the actual bind +
|
|
@@ -354,31 +351,20 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
|
|
|
354
351
|
? undefined
|
|
355
352
|
: 'localhost';
|
|
356
353
|
if (command === 'serve' && !isPreview && !isMiddlewareMode) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const result = await startDevServerPort({
|
|
360
|
-
configPort:
|
|
361
|
-
typeof userConfig.server?.port === 'number' ? userConfig.server.port : undefined,
|
|
354
|
+
const start = resolveStartPort({
|
|
355
|
+
configPort: userConfig.server?.port,
|
|
362
356
|
envPort: process.env.PORT,
|
|
363
|
-
listen: (p) => holdingRef.listen(p, resolvedHost),
|
|
364
357
|
});
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
resolvedDevPort = result.port;
|
|
376
|
-
resolvedDevPortExplicit = result.explicit;
|
|
377
|
-
} else if (result.explicit) {
|
|
378
|
-
// Explicit port failed to bind (another process holds it).
|
|
379
|
-
// Forward it as the starting point but without strictPort
|
|
380
|
-
// so Vite auto-bumps to the next free port. TIM-1419.
|
|
381
|
-
resolvedDevPort = result.port;
|
|
358
|
+
// Vite owns port selection and remembers its bound port on restart.
|
|
359
|
+
// Never forward a probed/bumped port: replacement config runs while
|
|
360
|
+
// the old listener still owns the port. The holding page is optional.
|
|
361
|
+
resolvedDevPort = start.port;
|
|
362
|
+
resolvedDevPortExplicit = userConfig.server?.strictPort ?? false;
|
|
363
|
+
if (start.port !== 0) {
|
|
364
|
+
ctx.holdingServer = createHoldingServer();
|
|
365
|
+
await ctx.holdingServer.listen(start.port, resolvedHost).catch(() => {
|
|
366
|
+
ctx.holdingServer = null;
|
|
367
|
+
});
|
|
382
368
|
}
|
|
383
369
|
} else if (command === 'serve' && isPreview) {
|
|
384
370
|
// Preview mode: resolve the starting port from config/env/
|
|
@@ -445,11 +431,9 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
|
|
|
445
431
|
// Dev/preview mode: set outDir so dev-time references are
|
|
446
432
|
// consistent, and surface the resolved port to Vite.
|
|
447
433
|
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
// port becomes unavailable between close and bind (e.g., a second
|
|
452
|
-
// app instance starting on the same default port). TIM-1419.
|
|
434
|
+
// Dev auto-bumps unless the user sets server.strictPort.
|
|
435
|
+
// Always forward the requested port so Vite can recognize unchanged
|
|
436
|
+
// config and reuse its actual bound port across restarts (TIM-1456).
|
|
453
437
|
//
|
|
454
438
|
// For `vite preview`, we set `preview.strictPort` based on
|
|
455
439
|
// whether the user explicitly set the port. Implicit (default)
|