@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
|
@@ -18,16 +18,31 @@
|
|
|
18
18
|
* See design/10-error-handling.md §"Status-Code Files"
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { Component, createElement, type ReactNode } from 'react';
|
|
21
|
+
import { Component, createContext, createElement, useContext, type ReactNode } from 'react';
|
|
22
22
|
import { getSsrData } from './ssr-data.ts';
|
|
23
23
|
import { getRouterOrNull } from './router-ref.ts';
|
|
24
24
|
import { markStaleFromError } from './stale-client.ts';
|
|
25
|
+
import { StatusPageMarker } from './status-page-marker.tsx';
|
|
26
|
+
import { leaveSpa } from './router-effects.ts';
|
|
25
27
|
// Page unload detection: when the page is being torn down, error boundaries
|
|
26
28
|
// suppress activation — the error is from the aborted connection, not the
|
|
27
29
|
// application. unload-guard.ts is the single owner of that state; this module
|
|
28
30
|
// used to keep its own copy, which drifted (TIM-1260).
|
|
29
31
|
import { isPageUnloading } from './unload-guard.ts';
|
|
30
32
|
|
|
33
|
+
// ─── Ancestry Context ────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Tracks the chain of segment keys from root to the current boundary.
|
|
37
|
+
* Each keyed `TimberErrorBoundary` appends its `segmentKey`; unkeyed
|
|
38
|
+
* slot boundaries pass the parent value through. The claim rule uses
|
|
39
|
+
* this to find the first *reachable* owner from the `owners` list,
|
|
40
|
+
* eliminating the owner-below-throw-site escape. See TIM-1450.
|
|
41
|
+
*/
|
|
42
|
+
const DenyAncestryContext = createContext<readonly string[]>([]);
|
|
43
|
+
|
|
44
|
+
export { DenyAncestryContext };
|
|
45
|
+
|
|
31
46
|
// ─── Digest Types ────────────────────────────────────────────────────────────
|
|
32
47
|
|
|
33
48
|
/** Structured digest returned by RSC onError for DenySignal. */
|
|
@@ -36,11 +51,13 @@ interface DenyDigest {
|
|
|
36
51
|
status: number;
|
|
37
52
|
data: unknown;
|
|
38
53
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
54
|
+
* Ordered candidate owners — segment keys of segments with matching deny
|
|
55
|
+
* pages, best match first. Present = addressed (a boundary should render
|
|
56
|
+
* it). In-tree hoists produce a single-element list; late addressing
|
|
57
|
+
* (TIM-1450) produces the full candidate list so the boundary can find the
|
|
58
|
+
* first *reachable* owner via its ancestry context.
|
|
42
59
|
*/
|
|
43
|
-
|
|
60
|
+
owners?: string[];
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
/** Structured digest returned by RSC onError for RenderError. */
|
|
@@ -86,10 +103,25 @@ export interface TimberErrorBoundaryProps {
|
|
|
86
103
|
isSlotBoundary?: boolean;
|
|
87
104
|
/**
|
|
88
105
|
* Segment key of the segment that declared this boundary. Only meaningful
|
|
89
|
-
* against a deny digest carrying
|
|
106
|
+
* against a deny digest carrying `owners`. See the decline rule in
|
|
90
107
|
* `render()` and TIM-1356.
|
|
91
108
|
*/
|
|
92
109
|
segmentKey?: string;
|
|
110
|
+
/**
|
|
111
|
+
* Catch denies only; re-throw everything else. Set on the copy of a
|
|
112
|
+
* segment's boundaries placed outside its layout, which gives a deny thrown
|
|
113
|
+
* from that layout's own chrome a catcher keyed to the segment — without
|
|
114
|
+
* changing where a 5xx from the same chrome goes. See TIM-1450.
|
|
115
|
+
*/
|
|
116
|
+
denyOnly?: boolean;
|
|
117
|
+
/**
|
|
118
|
+
* The deny chain's terminal: the outermost framework boundary, which claims
|
|
119
|
+
* any deny that streamed past every segment boundary and renders the
|
|
120
|
+
* framework default page. Implies `denyOnly`. Only a deny thrown from a
|
|
121
|
+
* layout's chrome with no status file at or above that layout gets here —
|
|
122
|
+
* the segment copies (`denyOnly`) claim every other shape. See TIM-1450.
|
|
123
|
+
*/
|
|
124
|
+
lastResort?: boolean;
|
|
93
125
|
children: ReactNode;
|
|
94
126
|
}
|
|
95
127
|
|
|
@@ -105,6 +137,10 @@ interface TimberErrorBoundaryState {
|
|
|
105
137
|
// duplicate router.navigate() calls.
|
|
106
138
|
const _handledRedirects = new WeakSet<object>();
|
|
107
139
|
|
|
140
|
+
// The same guard for the terminal's exit: React may render the fallback
|
|
141
|
+
// path more than once for one error, and the document load must start once.
|
|
142
|
+
const _handledExits = new WeakSet<object>();
|
|
143
|
+
|
|
108
144
|
// Companion guard keyed by location: client-render redirect() (the
|
|
109
145
|
// next/navigation shim) throws a FRESH error instance on every render
|
|
110
146
|
// attempt, and React replays the errored render — so the WeakSet alone
|
|
@@ -162,9 +198,24 @@ export class TimberErrorBoundary extends Component<
|
|
|
162
198
|
this.setState({ hasError: false, error: null });
|
|
163
199
|
};
|
|
164
200
|
|
|
201
|
+
static override contextType = DenyAncestryContext;
|
|
202
|
+
declare context: readonly string[];
|
|
203
|
+
|
|
204
|
+
private get childAncestry(): readonly string[] {
|
|
205
|
+
const parent = this.context ?? [];
|
|
206
|
+
const key = this.props.segmentKey;
|
|
207
|
+
// A segment has two keyed boundary sets (outside and inside its layout);
|
|
208
|
+
// the inner one sees its own key already present and adds nothing.
|
|
209
|
+
return key && !parent.includes(key) ? [...parent, key] : parent;
|
|
210
|
+
}
|
|
211
|
+
|
|
165
212
|
render(): ReactNode {
|
|
166
213
|
if (!this.state.hasError || !this.state.error) {
|
|
167
|
-
return
|
|
214
|
+
return createElement(
|
|
215
|
+
DenyAncestryContext.Provider,
|
|
216
|
+
{ value: this.childAncestry },
|
|
217
|
+
this.props.children
|
|
218
|
+
);
|
|
168
219
|
}
|
|
169
220
|
|
|
170
221
|
const error = this.state.error;
|
|
@@ -218,36 +269,70 @@ export class TimberErrorBoundary extends Component<
|
|
|
218
269
|
window.location.href = parsed.location;
|
|
219
270
|
}
|
|
220
271
|
}
|
|
221
|
-
|
|
272
|
+
// Nothing to show, but still a subtree replaced: a layout that
|
|
273
|
+
// redirected after I/O never mounted its children, and the commit that
|
|
274
|
+
// renders this empty spot must not teach the cache otherwise — or the
|
|
275
|
+
// redirect's own navigation would advertise those layouts as mounted
|
|
276
|
+
// and the server would skip into an outlet the client does not have.
|
|
277
|
+
return this.markStatusPage(null);
|
|
222
278
|
}
|
|
223
279
|
|
|
224
|
-
// Owner-addressed denies:
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
// nesting is per-segment `{status} → category → error`; the 4xx chain
|
|
232
|
-
// runs status files across ALL segments before any error.tsx, so a
|
|
233
|
-
// leaf error.tsx would otherwise beat a root 404.tsx.)
|
|
280
|
+
// Owner-addressed denies: the digest carries an ordered `owners` list
|
|
281
|
+
// of segment keys with matching deny pages. This boundary claims the
|
|
282
|
+
// deny only when it is the first *reachable* owner — i.e. its
|
|
283
|
+
// segmentKey appears in `owners` AND in its ancestry (the chain from
|
|
284
|
+
// root to here). Unreachable owners (below the throw site) are skipped,
|
|
285
|
+
// and the first ancestor that IS reachable claims. This eliminates the
|
|
286
|
+
// owner-below-throw-site escape (TIM-1450).
|
|
234
287
|
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
288
|
+
// Unkeyed boundaries (slots, or boundaries without deny chains) never
|
|
289
|
+
// claim an addressed deny. See TIM-1356.
|
|
290
|
+
if (parsed?.type === 'deny' && Array.isArray(parsed.owners) && parsed.owners.length > 0) {
|
|
291
|
+
// Slot boundaries always claim addressed denies — graceful degradation
|
|
292
|
+
// within the slot is correct regardless of which segment the owner
|
|
293
|
+
// chain resolved to. Without this, the deny escapes the slot and
|
|
294
|
+
// becomes a page-level 4xx. See TIM-1450.
|
|
295
|
+
if (!this.props.lastResort && !this.props.isSlotBoundary) {
|
|
296
|
+
const reachable = parsed.owners.find((k: string) => this.childAncestry.includes(k));
|
|
297
|
+
if (!this.props.segmentKey || reachable !== this.props.segmentKey) {
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// Deny-only boundaries (the outer segment copies) re-throw everything
|
|
303
|
+
// else so a crash in a layout's chrome still reaches the documented 5xx
|
|
304
|
+
// flow instead of a status page for an unrelated error.
|
|
305
|
+
if (this.props.denyOnly && !this.props.lastResort && parsed?.type !== 'deny') {
|
|
249
306
|
throw error;
|
|
250
307
|
}
|
|
308
|
+
// The terminal is where that flow ends on the client. A non-deny row
|
|
309
|
+
// nothing claimed — a RenderError or a plain crash on a route with no
|
|
310
|
+
// matching `error.tsx` — has no page to render in-SPA; React would
|
|
311
|
+
// unmount the root. So the terminal does what the router does for an
|
|
312
|
+
// error envelope (design/10 §"Error Page Rendering for Client
|
|
313
|
+
// Navigation"): leave the SPA, and let the document request render the
|
|
314
|
+
// real error page with the real status. On the server it re-throws so
|
|
315
|
+
// the Fizz shell fails and the pipeline serves that page directly. The
|
|
316
|
+
// envelope still covers errors that land inside Flight's opening window;
|
|
317
|
+
// this covers the ones that land after it, which used to be held for
|
|
318
|
+
// (TIM-1495, TIM-1497).
|
|
319
|
+
if (this.props.lastResort && parsed?.type !== 'deny') {
|
|
320
|
+
if (typeof window === 'undefined') {
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
if (!_handledExits.has(error)) {
|
|
324
|
+
_handledExits.add(error);
|
|
325
|
+
const router = getRouterOrNull();
|
|
326
|
+
const current = window.location.pathname + window.location.search;
|
|
327
|
+
// The row arrived for the navigation in flight, whose URL is not on
|
|
328
|
+
// the address bar yet; fall back to the current document.
|
|
329
|
+
const target = router?.getPendingUrl() ?? current;
|
|
330
|
+
queueMicrotask(() => {
|
|
331
|
+
void leaveSpa(target, current);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
251
336
|
|
|
252
337
|
// If this boundary has a status filter, check whether the error matches.
|
|
253
338
|
// Non-matching errors re-throw so an outer boundary can catch them.
|
|
@@ -290,21 +375,25 @@ export class TimberErrorBoundary extends Component<
|
|
|
290
375
|
}
|
|
291
376
|
}
|
|
292
377
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
378
|
+
return this.markStatusPage(this.renderFallback(parsed, error));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The status page this boundary shows for a row it claimed. Its shape is
|
|
383
|
+
* the file's documented contract: TSX status files get `{ status,
|
|
384
|
+
* dangerouslyPassData }`, `error.tsx` gets `{ error, digest, reset }` (plus
|
|
385
|
+
* the deny extras for dual-shape implementations — without the error prop,
|
|
386
|
+
* an error.tsx reading error.message crashes on deny fall-through, TIM-1081),
|
|
387
|
+
* and an MDX status file is the element pre-rendered for it at tree-build
|
|
388
|
+
* time, since a server component cannot cross the RSC→client boundary as a
|
|
389
|
+
* function prop.
|
|
390
|
+
*/
|
|
391
|
+
private renderFallback(parsed: ParsedDigest | null, error: Error): ReactNode {
|
|
297
392
|
if (this.props.fallbackElement != null) {
|
|
298
393
|
return this.props.fallbackElement;
|
|
299
394
|
}
|
|
300
395
|
|
|
301
|
-
// Render the fallback component with the right props shape.
|
|
302
396
|
if (parsed?.type === 'deny') {
|
|
303
|
-
// Status-code file boundaries (404.tsx, 4xx.tsx, …) get the documented
|
|
304
|
-
// 4xx props. error.tsx boundaries (no status filter) get their
|
|
305
|
-
// documented { error, digest, reset } contract — plus the deny extras
|
|
306
|
-
// for dual-shape implementations. Without the error prop, an error.tsx
|
|
307
|
-
// reading error.message crashes on deny fall-through. See TIM-1081.
|
|
308
397
|
if (this.props.status != null) {
|
|
309
398
|
return createElement(this.props.fallbackComponent as never, {
|
|
310
399
|
status: parsed.status,
|
|
@@ -330,6 +419,19 @@ export class TimberErrorBoundary extends Component<
|
|
|
330
419
|
reset: this.reset,
|
|
331
420
|
});
|
|
332
421
|
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* A status page (or, for a redirect row, nothing at all) replaced part of
|
|
425
|
+
* the main route, and the segment cache must not learn a chain that never
|
|
426
|
+
* mounted; the marker tells the navigation that commits this tree. A boundary that declined in `render()` re-threw
|
|
427
|
+
* before getting here, so only the boundary that put a page on screen
|
|
428
|
+
* speaks. A slot degrading leaves the page and its chain intact, so slot
|
|
429
|
+
* boundaries stay silent. See `status-page-marker.tsx` (TIM-1495).
|
|
430
|
+
*/
|
|
431
|
+
private markStatusPage(page: ReactNode): ReactNode {
|
|
432
|
+
if (this.props.isSlotBoundary) return page;
|
|
433
|
+
return createElement(StatusPageMarker, null, page);
|
|
434
|
+
}
|
|
333
435
|
}
|
|
334
436
|
|
|
335
437
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
package/src/client/history.ts
CHANGED
|
@@ -48,21 +48,70 @@ export interface HistoryEntry {
|
|
|
48
48
|
* Scroll positions are stored in history.state or Navigation API entry
|
|
49
49
|
* state, not in this stack — see design/19-client-navigation.md §Scroll Restoration.
|
|
50
50
|
*
|
|
51
|
-
* Entries
|
|
52
|
-
*
|
|
51
|
+
* Entries have no expiry, but only the 50 most recently used URLs are kept
|
|
52
|
+
* by default. Traversing to an evicted entry fetches a fresh payload.
|
|
53
53
|
*/
|
|
54
54
|
export class HistoryStack {
|
|
55
55
|
private entries = new Map<string, HistoryEntry>();
|
|
56
|
+
private readonly maxEntries: number;
|
|
57
|
+
|
|
58
|
+
constructor({ maxEntries = 50 }: { maxEntries?: number } = {}) {
|
|
59
|
+
if (!Number.isInteger(maxEntries) || maxEntries < 1) {
|
|
60
|
+
throw new RangeError('HistoryStack maxEntries must be a positive integer');
|
|
61
|
+
}
|
|
62
|
+
this.maxEntries = maxEntries;
|
|
63
|
+
}
|
|
56
64
|
|
|
57
65
|
push(url: string, entry: HistoryEntry): void {
|
|
66
|
+
// Push runs when a page becomes current (or hydration seeds it). Promote
|
|
67
|
+
// replacements too, so the current page always survives overflow.
|
|
68
|
+
this.entries.delete(url);
|
|
58
69
|
this.entries.set(url, entry);
|
|
70
|
+
if (this.entries.size > this.maxEntries) {
|
|
71
|
+
const oldest = this.entries.keys().next();
|
|
72
|
+
if (!oldest.done) this.entries.delete(oldest.value);
|
|
73
|
+
}
|
|
59
74
|
}
|
|
60
75
|
|
|
61
76
|
get(url: string): HistoryEntry | undefined {
|
|
62
|
-
|
|
77
|
+
const entry = this.entries.get(url);
|
|
78
|
+
if (entry) {
|
|
79
|
+
this.entries.delete(url);
|
|
80
|
+
this.entries.set(url, entry);
|
|
81
|
+
}
|
|
82
|
+
return entry;
|
|
63
83
|
}
|
|
64
84
|
|
|
85
|
+
/** Presence checks (such as hover prefetch probes) do not promote entries. */
|
|
65
86
|
has(url: string): boolean {
|
|
66
87
|
return this.entries.has(url);
|
|
67
88
|
}
|
|
89
|
+
|
|
90
|
+
delete(url: string): boolean {
|
|
91
|
+
return this.entries.delete(url);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Evict all cached payloads (TIM-1476). Called after a server action
|
|
96
|
+
* that revalidated data — history entries are equally stale since they
|
|
97
|
+
* replay on back/forward without a server check. Clearing forces a
|
|
98
|
+
* fresh fetch on the next traversal.
|
|
99
|
+
*
|
|
100
|
+
* The current URL's `segmentInfo` is preserved (payload nulled) so
|
|
101
|
+
* `applyActionResult` can read it to maintain the segment cache
|
|
102
|
+
* across the piggybacked revalidation. Other branches (reval.paths-
|
|
103
|
+
* only, redirect) do not call `applyActionResult`, so their entries
|
|
104
|
+
* are fully removed (codex on #1131 round 2).
|
|
105
|
+
*/
|
|
106
|
+
clearExcept(currentUrl: string): void {
|
|
107
|
+
const currentEntry = this.entries.get(currentUrl);
|
|
108
|
+
this.entries.clear();
|
|
109
|
+
if (currentEntry) {
|
|
110
|
+
this.entries.set(currentUrl, {
|
|
111
|
+
payload: null,
|
|
112
|
+
params: currentEntry.params,
|
|
113
|
+
segmentInfo: currentEntry.segmentInfo,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
68
117
|
}
|
package/src/client/internal.ts
CHANGED
|
@@ -21,6 +21,7 @@ export type {
|
|
|
21
21
|
RootRenderer,
|
|
22
22
|
} from './router-types.ts';
|
|
23
23
|
export { getRouter, getRouterOrNull, setGlobalRouter } from './router-ref.ts';
|
|
24
|
+
export type { NavigationEpoch } from './router-lifecycle.ts';
|
|
24
25
|
|
|
25
26
|
// ── SSR bridge ───────────────────────────────────────────────────────────
|
|
26
27
|
export { setSsrData, clearSsrData, getSsrData } from './ssr-data.ts';
|
package/src/client/link.tsx
CHANGED
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
// - searchParams and inline query string are mutually exclusive
|
|
21
21
|
|
|
22
22
|
import {
|
|
23
|
-
startTransition,
|
|
24
23
|
useRef,
|
|
25
24
|
useState,
|
|
26
25
|
type AnchorHTMLAttributes,
|
|
@@ -60,6 +59,18 @@ function getCurrentSearch(): string {
|
|
|
60
59
|
return getSsrData()?.search ?? '';
|
|
61
60
|
}
|
|
62
61
|
|
|
62
|
+
/** Native in-page scrolling needs neither an RSC navigation nor a prefetch. */
|
|
63
|
+
function isNativeFragmentLink(anchor: HTMLAnchorElement): boolean {
|
|
64
|
+
// Default browser navigation follows the rendered anchor, which may differ
|
|
65
|
+
// from a freshly resolved preserveSearchParams URL after a shallow update.
|
|
66
|
+
const href = anchor.href;
|
|
67
|
+
const hashIndex = href.indexOf('#');
|
|
68
|
+
// Compare browser-serialized URLs, excluding only fragments. URL.hash and
|
|
69
|
+
// URL.search erase the distinction between absent and explicitly empty
|
|
70
|
+
// delimiters, but '/current' and '/current?' are different documents.
|
|
71
|
+
return hashIndex !== -1 && href.slice(0, hashIndex) === window.location.href.split('#', 1)[0];
|
|
72
|
+
}
|
|
73
|
+
|
|
63
74
|
// ─── Types ───────────────────────────────────────────────────────
|
|
64
75
|
|
|
65
76
|
export type OnNavigateEvent = {
|
|
@@ -496,21 +507,32 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
|
|
|
496
507
|
// `timber:navigation-end`) waited for full decode. Not opening an action
|
|
497
508
|
// scope is what collapses them into one.
|
|
498
509
|
//
|
|
499
|
-
// `isPending` runs from the click until `router.navigate()`
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
510
|
+
// `isPending` runs from the click until BOTH `router.navigate()` has settled
|
|
511
|
+
// (after `decodePromise` — the lifecycle the router's pending store and the
|
|
512
|
+
// TopLoader use) AND the navigation's `onCommit` has fired. The second
|
|
513
|
+
// condition is what keeps the flag honest: the promise resolves on decode,
|
|
514
|
+
// and a destination with a pending Suspense boundary is still off screen
|
|
515
|
+
// then. A clear scheduled at that point — urgent or in its own transition —
|
|
516
|
+
// can commit ahead of the suspended tree, so the link flashes idle for a
|
|
517
|
+
// frame while the old page is still showing (TIM-1418). Wrapping the clear
|
|
518
|
+
// in `startTransition` was tried: it only holds when both updates share a
|
|
519
|
+
// lane, and React assigns lanes per event, so the decode-time clear and the
|
|
520
|
+
// click-time `root.render` never do. (Next.js gets the shared lane because
|
|
521
|
+
// it hands React the tree in the click event; timber fetches first.) The
|
|
522
|
+
// commit itself is the only signal that cannot beat the commit, and it is
|
|
523
|
+
// the router's to give: `onCommit` fires exactly once, on the commit or
|
|
524
|
+
// when the navigation is abandoned — superseded or failed — so the link
|
|
525
|
+
// can never be left pending for a commit that will not come.
|
|
526
|
+
//
|
|
527
|
+
// Order is not fixed: a destination that does not suspend commits before
|
|
528
|
+
// decode finishes (streaming reveal), so whichever of settle/commit comes
|
|
529
|
+
// second clears.
|
|
507
530
|
//
|
|
508
|
-
// `clickSeq`
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
// and nowhere else, so there is no reset for a settle handler to race.
|
|
531
|
+
// `clickSeq` guards the same-link double click: the first navigation is
|
|
532
|
+
// superseded (its promise RESOLVES — `runNavigation` swallows AbortErrors —
|
|
533
|
+
// and its `onCommit` fires) while the second is still in flight, and only
|
|
534
|
+
// the newest click for this link may clear its flag. Written on click and
|
|
535
|
+
// nowhere else, so there is no reset for a settle handler to race.
|
|
514
536
|
const [isPending, setIsPending] = useState(false);
|
|
515
537
|
const clickSeq = useRef(0);
|
|
516
538
|
const linkStatus = isPending ? LINK_PENDING : LINK_IDLE;
|
|
@@ -529,6 +551,15 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
|
|
|
529
551
|
? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams)
|
|
530
552
|
: baseHref;
|
|
531
553
|
|
|
554
|
+
// Event callbacks may update the URL, so resolve preserved params at use time.
|
|
555
|
+
const resolveEventUrl = () =>
|
|
556
|
+
new URL(
|
|
557
|
+
preserveSearchParams
|
|
558
|
+
? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams)
|
|
559
|
+
: resolvedHref,
|
|
560
|
+
window.location.href
|
|
561
|
+
);
|
|
562
|
+
|
|
532
563
|
// ─── Click handler ───────────────────────────────────────────
|
|
533
564
|
// Each Link component owns its click handling. The router is
|
|
534
565
|
// accessed via the singleton ref — during SSR, getRouterOrNull()
|
|
@@ -540,6 +571,10 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
|
|
|
540
571
|
|
|
541
572
|
if (!shouldInterceptClick(event, resolvedHref)) return;
|
|
542
573
|
|
|
574
|
+
// Native anchor scrolling is not an SPA navigation. Decide before
|
|
575
|
+
// onNavigate can cancel it, just as we do before hover prefetching.
|
|
576
|
+
if (isNativeFragmentLink(event.currentTarget)) return;
|
|
577
|
+
|
|
543
578
|
// Call onNavigate if provided — allows caller to cancel
|
|
544
579
|
if (onNavigate) {
|
|
545
580
|
let prevented = false;
|
|
@@ -557,19 +592,10 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
|
|
|
557
592
|
const router = getRouterOrNull();
|
|
558
593
|
if (!router) return;
|
|
559
594
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
const resolved = new URL(navHref, window.location.href);
|
|
565
|
-
|
|
566
|
-
if (
|
|
567
|
-
resolved.pathname === window.location.pathname &&
|
|
568
|
-
stripRscCacheKey(resolved.search) === locationSearch() &&
|
|
569
|
-
resolved.hash
|
|
570
|
-
) {
|
|
571
|
-
return;
|
|
572
|
-
}
|
|
595
|
+
// Keep the post-callback read: onNavigate may change the current URL
|
|
596
|
+
// and therefore the search params to preserve or fragment locality.
|
|
597
|
+
const resolved = resolveEventUrl();
|
|
598
|
+
if (isNativeFragmentLink(event.currentTarget)) return;
|
|
573
599
|
|
|
574
600
|
event.preventDefault();
|
|
575
601
|
|
|
@@ -580,12 +606,23 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
|
|
|
580
606
|
const absoluteHref = resolved.pathname + stripRscCacheKey(resolved.search) + resolved.hash;
|
|
581
607
|
|
|
582
608
|
const seq = ++clickSeq.current;
|
|
609
|
+
let settled = false;
|
|
610
|
+
let committed = false;
|
|
611
|
+
const clear = () => {
|
|
612
|
+
if (clickSeq.current === seq) setIsPending(false);
|
|
613
|
+
};
|
|
614
|
+
const onCommit = () => {
|
|
615
|
+
committed = true;
|
|
616
|
+
if (settled) clear();
|
|
617
|
+
};
|
|
583
618
|
const settle = () => {
|
|
584
|
-
|
|
619
|
+
settled = true;
|
|
620
|
+
if (committed) clear();
|
|
585
621
|
};
|
|
586
622
|
setIsPending(true);
|
|
587
|
-
router.navigate(absoluteHref, { scroll: shouldScroll
|
|
588
|
-
|
|
623
|
+
const navigation = router.navigate(absoluteHref, { scroll: shouldScroll, onCommit });
|
|
624
|
+
navigation.then(settle, (error: unknown) => {
|
|
625
|
+
clear();
|
|
589
626
|
// Rethrow, so a navigation error that the router did not already
|
|
590
627
|
// recover from surfaces as an unhandled rejection — the same
|
|
591
628
|
// regime as `useRouter().push()`'s `void router.navigate(...)`.
|
|
@@ -611,10 +648,8 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
|
|
|
611
648
|
userOnMouseEnter?.(event);
|
|
612
649
|
const router = getRouterOrNull();
|
|
613
650
|
if (router) {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
: resolvedHref;
|
|
617
|
-
const resolved = new URL(prefetchHref, window.location.href);
|
|
651
|
+
if (isNativeFragmentLink(event.currentTarget)) return;
|
|
652
|
+
const resolved = resolveEventUrl();
|
|
618
653
|
router.prefetch(resolved.pathname + stripRscCacheKey(resolved.search));
|
|
619
654
|
}
|
|
620
655
|
}
|