@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
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
* The RSC plugin delegates to `globalThis.__viteRscCallServer` which is
|
|
7
7
|
* set by `setServerCallback`.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Actions are dispatched sequentially through a client-wide queue
|
|
10
|
+
* (TIM-1475): each action's POST starts only after the previous one has
|
|
11
|
+
* settled and its result has been applied. Navigations do not enter the
|
|
12
|
+
* queue — they preempt via the NavigationEpoch, marking any running
|
|
13
|
+
* action as discarded (TIM-1474). Queries (TIM-1415) will bypass the
|
|
14
|
+
* queue entirely.
|
|
13
15
|
*
|
|
14
16
|
* See design/08-forms-and-actions.md §"Client-Side Form Mechanics"
|
|
15
17
|
*/
|
|
@@ -20,111 +22,176 @@ import { setHardNavigating } from '../navigation-root.tsx';
|
|
|
20
22
|
import { getClientDeploymentId, DEPLOYMENT_ID_HEADER, RELOAD_HEADER } from '../rsc-fetch.ts';
|
|
21
23
|
import { markClientStale } from '../stale-client.ts';
|
|
22
24
|
import { RSC_CONTENT_TYPE } from '../../shared/rsc-media-type.ts';
|
|
25
|
+
import { createActionQueue } from './action-queue.ts';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Wait for a navigation or refresh to both settle (promise resolved) AND
|
|
29
|
+
* commit (pushState / renderPayload's commit thunk ran). The queue must
|
|
30
|
+
* not release the next action until the URL is correct in the address bar
|
|
31
|
+
* — same rule `<Link>` uses for `isPending` (design/19 §Per-Link Pending
|
|
32
|
+
* State). `onCommit` fires exactly once: on commit, supersession, or
|
|
33
|
+
* failure, so nothing awaiting this can hang (codex on #1125).
|
|
34
|
+
*/
|
|
35
|
+
function settledAndCommitted(start: (onCommit: () => void) => Promise<void>): Promise<void> {
|
|
36
|
+
let commitResolve!: () => void;
|
|
37
|
+
const committed = new Promise<void>((r) => (commitResolve = r));
|
|
38
|
+
return Promise.all([start(commitResolve).catch(() => {}), committed]).then(() => {});
|
|
39
|
+
}
|
|
40
|
+
|
|
23
41
|
export function setupServerActions(): void {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
// typed into the form. Their next navigation recovers (TIM-1275).
|
|
56
|
-
markClientStale();
|
|
57
|
-
throw new Error('Version skew detected — this client is out of date');
|
|
42
|
+
const queue = createActionQueue({
|
|
43
|
+
refreshWhenIdle(): void {
|
|
44
|
+
const router = getRouterOrNull();
|
|
45
|
+
if (!router) return;
|
|
46
|
+
router.runWhenIdle(() => void router.refresh());
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
setServerCallback((id: string, args: unknown[]) => {
|
|
51
|
+
return queue.run(async ({ hold }) => {
|
|
52
|
+
// encodeReply inside the queue — preserves call order when two
|
|
53
|
+
// calls' args serialize in different numbers of microtasks.
|
|
54
|
+
const body = await encodeReply(args);
|
|
55
|
+
|
|
56
|
+
// Capture the epoch AFTER the previous action has settled and
|
|
57
|
+
// applied, so it reflects the router's real state — not stale
|
|
58
|
+
// from a predecessor's refresh bumping the seq (TIM-1474).
|
|
59
|
+
const router = getRouterOrNull();
|
|
60
|
+
const epoch = router?.epoch();
|
|
61
|
+
|
|
62
|
+
let hasRevalidation = false;
|
|
63
|
+
let hasRedirect = false;
|
|
64
|
+
const reval: { paths: string | null } = { paths: null };
|
|
65
|
+
|
|
66
|
+
const actionHeaders: Record<string, string> = {
|
|
67
|
+
'Accept': RSC_CONTENT_TYPE,
|
|
68
|
+
'x-rsc-action': id,
|
|
69
|
+
};
|
|
70
|
+
const actionDeploymentId = getClientDeploymentId();
|
|
71
|
+
if (actionDeploymentId) {
|
|
72
|
+
actionHeaders[DEPLOYMENT_ID_HEADER] = actionDeploymentId;
|
|
58
73
|
}
|
|
59
|
-
hasRevalidation = res.headers.get('X-Timber-Revalidation') === '1';
|
|
60
|
-
hasRedirect = res.headers.get('X-Timber-Redirect') != null;
|
|
61
|
-
return res;
|
|
62
|
-
});
|
|
63
74
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
75
|
+
const response = fetch(window.location.href, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: actionHeaders,
|
|
78
|
+
body,
|
|
79
|
+
}).then((res) => {
|
|
80
|
+
if (res.headers.get(RELOAD_HEADER) === '1') {
|
|
81
|
+
markClientStale();
|
|
82
|
+
throw new Error('Version skew detected — this client is out of date');
|
|
83
|
+
}
|
|
84
|
+
hasRevalidation = res.headers.get('X-Timber-Revalidation') === '1';
|
|
85
|
+
hasRedirect = res.headers.get('X-Timber-Redirect') != null;
|
|
86
|
+
reval.paths = res.headers.get('X-Timber-Revalidation-Path');
|
|
87
|
+
return res;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const decoded = await createFromFetch(response);
|
|
91
|
+
|
|
92
|
+
// TIM-1454: Invalidate non-matching paths unconditionally — the
|
|
93
|
+
// mutation happened server-side regardless of navigation state.
|
|
94
|
+
if (reval.paths) {
|
|
95
|
+
if (router) {
|
|
96
|
+
for (const p of reval.paths.split(' ')) {
|
|
97
|
+
router.invalidatePath(p);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
82
100
|
}
|
|
83
101
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
e instanceof Error ? e.message : e
|
|
94
|
-
);
|
|
95
|
-
setHardNavigating(true);
|
|
96
|
-
window.location.href = wrapper._redirect;
|
|
102
|
+
// TIM-1476: When the server reports any revalidation, evict all
|
|
103
|
+
// prefetch cache entries and history stack payloads — the mutation
|
|
104
|
+
// may affect data any route reads, not just the paths the server
|
|
105
|
+
// named. Placed before the redirect branch so an internal redirect
|
|
106
|
+
// via router.navigate() cannot consume a stale prefetch (codex on
|
|
107
|
+
// #1131). In-flight prefetch singleflights are fenced by the
|
|
108
|
+
// eviction generation and will not repopulate the cache.
|
|
109
|
+
if (router && (hasRevalidation || reval.paths)) {
|
|
110
|
+
router.evictStaleCaches();
|
|
97
111
|
}
|
|
98
|
-
return undefined;
|
|
99
|
-
}
|
|
100
112
|
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
113
|
+
// Redirects apply unconditionally — the server mutated and told
|
|
114
|
+
// the client where to go. Return undefined to React NOW so the
|
|
115
|
+
// action scope settles — awaiting the commit inline would deadlock:
|
|
116
|
+
// React entangles the commit lane with the action scope, so the
|
|
117
|
+
// commit waits on the action while the action waits on the commit.
|
|
118
|
+
// The navigate is fire-and-forget: unlike revalidation (where the
|
|
119
|
+
// next action must capture the refreshed epoch), a redirect leaves
|
|
120
|
+
// the page entirely — no subsequent action runs on this URL.
|
|
121
|
+
if (hasRedirect) {
|
|
122
|
+
const wrapper = decoded as { _redirect: string; _status: number };
|
|
123
|
+
const isExternal =
|
|
124
|
+
wrapper._redirect.startsWith('https://') || wrapper._redirect.startsWith('http://');
|
|
125
|
+
if (isExternal) {
|
|
126
|
+
setHardNavigating(true);
|
|
127
|
+
window.location.href = wrapper._redirect;
|
|
128
|
+
// Never settle — the document is going away and queued actions
|
|
129
|
+
// must not resume during page teardown (codex on #1125).
|
|
130
|
+
return new Promise<undefined>(() => {});
|
|
131
|
+
}
|
|
107
132
|
try {
|
|
108
|
-
|
|
133
|
+
const r = getRouter();
|
|
134
|
+
void r.navigate(wrapper._redirect);
|
|
109
135
|
} catch (e) {
|
|
110
|
-
console.
|
|
136
|
+
console.debug(
|
|
137
|
+
'[timber] action redirect: router not ready, using full navigation',
|
|
138
|
+
e instanceof Error ? e.message : e
|
|
139
|
+
);
|
|
140
|
+
setHardNavigating(true);
|
|
141
|
+
window.location.href = wrapper._redirect;
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (hasRevalidation) {
|
|
147
|
+
const wrapper = decoded as { _action: unknown; _tree: unknown };
|
|
148
|
+
// Return the action result to React NOW — it may be validation errors
|
|
149
|
+
// the form needs immediately — and hold the queue until the piggybacked
|
|
150
|
+
// tree commits. Awaiting applyActionResult inline would deadlock: the
|
|
151
|
+
// action promise settles only after the commit, but React entangles the
|
|
152
|
+
// commit lane with the action scope, so the commit waits on the action.
|
|
153
|
+
// Same pattern as the no-tree branch below (codex on #1126).
|
|
154
|
+
if (router && epoch) {
|
|
155
|
+
hold(
|
|
156
|
+
Promise.resolve()
|
|
157
|
+
.then(() => router.applyActionResult(epoch, wrapper._tree))
|
|
158
|
+
.then(
|
|
159
|
+
(applied) => {
|
|
160
|
+
if (!applied) queue.markNeedsRefresh();
|
|
161
|
+
},
|
|
162
|
+
(e: unknown) => {
|
|
163
|
+
console.error('[timber] applyActionResult failed after server action', e);
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
);
|
|
111
167
|
}
|
|
168
|
+
return wrapper._action;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// revalidatePath was called but only for non-current paths —
|
|
172
|
+
// caches were already invalidated above, no refresh needed.
|
|
173
|
+
if (reval.paths) {
|
|
174
|
+
return decoded;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// No piggybacked revalidation — return the result to React NOW
|
|
178
|
+
// (it may be validation errors the form needs immediately) and hold
|
|
179
|
+
// the queue until the refresh commits so the next action captures a
|
|
180
|
+
// current epoch (codex on #1125).
|
|
181
|
+
if (router && epoch) {
|
|
182
|
+
hold(
|
|
183
|
+
router.applyActionResult(epoch).then(
|
|
184
|
+
(applied) => {
|
|
185
|
+
if (!applied) queue.markNeedsRefresh();
|
|
186
|
+
},
|
|
187
|
+
(e: unknown) => {
|
|
188
|
+
console.debug('[timber] action refresh failed', e instanceof Error ? e.message : e);
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
);
|
|
112
192
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
// No piggybacked revalidation — refresh to pick up any mutations.
|
|
117
|
-
// This covers actions that don't call revalidatePath().
|
|
118
|
-
try {
|
|
119
|
-
const router = getRouter();
|
|
120
|
-
void router.refresh();
|
|
121
|
-
} catch (e) {
|
|
122
|
-
console.debug(
|
|
123
|
-
'[timber] action refresh: router not ready',
|
|
124
|
-
e instanceof Error ? e.message : e
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return decoded;
|
|
193
|
+
|
|
194
|
+
return decoded;
|
|
195
|
+
});
|
|
129
196
|
});
|
|
130
197
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sequential Action Queue — serialises server action dispatch.
|
|
3
|
+
*
|
|
4
|
+
* One action at a time. The next action's POST starts only after the
|
|
5
|
+
* previous one has settled AND its result has been applied (revalidation
|
|
6
|
+
* rendered or refresh awaited). Navigations do not enter the queue —
|
|
7
|
+
* they preempt via the NavigationEpoch. Queries (TIM-1415) bypass it
|
|
8
|
+
* entirely.
|
|
9
|
+
*
|
|
10
|
+
* See design/08-forms-and-actions.md §"Client-Side Form Mechanics"
|
|
11
|
+
* and design/19-client-navigation.md §State Update Invariants (TIM-1475).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface ActionQueueDeps {
|
|
15
|
+
refreshWhenIdle: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const noop = (): void => {};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Passed into `work` so it can return a result to React immediately
|
|
22
|
+
* while keeping the queue blocked on a side effect (e.g. a refresh
|
|
23
|
+
* that must commit before the next action captures its epoch).
|
|
24
|
+
*/
|
|
25
|
+
export interface ActionQueueRun {
|
|
26
|
+
hold: (p: Promise<unknown>) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ActionQueue {
|
|
30
|
+
/**
|
|
31
|
+
* Enqueue `work` to run after all preceding actions have settled.
|
|
32
|
+
* Returns `work`'s own promise so the caller (React) gets the action
|
|
33
|
+
* result or rejection. If the preceding action rejected, `work` still
|
|
34
|
+
* runs — errors do not break the chain.
|
|
35
|
+
*
|
|
36
|
+
* `work` receives a `hold` handle: calling `hold(promise)` keeps the
|
|
37
|
+
* queue blocked until that promise settles, independent of `work`'s
|
|
38
|
+
* return value. Use it for "return the result NOW, await the refresh
|
|
39
|
+
* commit for sequencing."
|
|
40
|
+
*/
|
|
41
|
+
run: <T>(work: (q: ActionQueueRun) => Promise<T>) => Promise<T>;
|
|
42
|
+
/**
|
|
43
|
+
* Mark that a discarded action would have revalidated. When the queue
|
|
44
|
+
* drains, one refresh fires via `deps.refreshWhenIdle`.
|
|
45
|
+
*/
|
|
46
|
+
markNeedsRefresh: () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createActionQueue(deps: ActionQueueDeps): ActionQueue {
|
|
50
|
+
let tail: Promise<void> = Promise.resolve();
|
|
51
|
+
let needsRefresh = false;
|
|
52
|
+
|
|
53
|
+
function drain(): void {
|
|
54
|
+
if (!needsRefresh) return;
|
|
55
|
+
needsRefresh = false;
|
|
56
|
+
deps.refreshWhenIdle();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function run<T>(work: (q: ActionQueueRun) => Promise<T>): Promise<T> {
|
|
60
|
+
const holds: Promise<unknown>[] = [];
|
|
61
|
+
const hold = (p: Promise<unknown>): void => {
|
|
62
|
+
holds.push(p.then(noop, noop));
|
|
63
|
+
};
|
|
64
|
+
// `p` is the caller-visible promise — it resolves/rejects with work's
|
|
65
|
+
// result (React sees this). `settled` is the chain link: work settled
|
|
66
|
+
// AND every held promise settled. It never rejects, so the chain never
|
|
67
|
+
// breaks and the next action always runs.
|
|
68
|
+
const p = tail.then(
|
|
69
|
+
() => work({ hold }),
|
|
70
|
+
() => work({ hold })
|
|
71
|
+
);
|
|
72
|
+
const settled = p
|
|
73
|
+
.then(noop, noop)
|
|
74
|
+
.then(() => Promise.all(holds))
|
|
75
|
+
.then(noop);
|
|
76
|
+
tail = settled;
|
|
77
|
+
// Drain when the queue empties: tail identity means nobody enqueued
|
|
78
|
+
// behind this action between its start and its settlement.
|
|
79
|
+
void settled.then(() => {
|
|
80
|
+
if (tail === settled) drain();
|
|
81
|
+
});
|
|
82
|
+
return p;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function markNeedsRefresh(): void {
|
|
86
|
+
needsRefresh = true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { run, markNeedsRefresh };
|
|
90
|
+
}
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
getNavigationState,
|
|
21
21
|
setNavigationState,
|
|
22
22
|
} from '../navigation-context.ts';
|
|
23
|
-
import { navigateTransition } from '../navigation-transition.ts';
|
|
23
|
+
import { navigateTransition, type RenderOwner } from '../navigation-transition.ts';
|
|
24
24
|
import type { NavigationRender } from '../navigation-root.tsx';
|
|
25
25
|
import { SegmentUpdateContext, EMPTY_SEGMENT_UPDATES } from '../segment-update-context.ts';
|
|
26
26
|
import { SlotContentCacheContext, type SlotContentCache } from '../slot-content-cache-context.ts';
|
|
@@ -156,6 +156,11 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
|
|
|
156
156
|
currentParams = params;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// Late-bound: syncShallowSearch is defined before createRouter, but only
|
|
160
|
+
// called after the router exists (from patched pushState/replaceState or
|
|
161
|
+
// the Navigation API handler, both of which fire from user code).
|
|
162
|
+
let router: RouterInstance | null = null;
|
|
163
|
+
|
|
159
164
|
// ─── History API Patch ──────────────────────────────────────────
|
|
160
165
|
// Patch pushState/replaceState to detect external URL changes (nuqs
|
|
161
166
|
// shallow updates, replaceUrl, third-party libraries). When the router
|
|
@@ -203,6 +208,21 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
|
|
|
203
208
|
};
|
|
204
209
|
setNavigationState(newNavState);
|
|
205
210
|
|
|
211
|
+
// A shallow search update displaces whatever React is rendering. If a
|
|
212
|
+
// handed-off tree is still suspended (e.g. a cached popstate replay whose
|
|
213
|
+
// client component is lazy-loading), this render replaces it and React
|
|
214
|
+
// will never commit it — so forgetOlderHandoffs never fires and
|
|
215
|
+
// hasUncommittedNav stays true forever, blocking runWhenIdle tasks.
|
|
216
|
+
//
|
|
217
|
+
// Deferred to a microtask so that when a child layout effect fires a
|
|
218
|
+
// pushState during the same commit (e.g. nuqs syncing on mount),
|
|
219
|
+
// forgetOlderHandoffs — which runs in the parent layout effect, i.e.
|
|
220
|
+
// synchronously before the microtask — clears pendingCommit first,
|
|
221
|
+
// and this settle is a no-op. A genuinely displaced suspended tree
|
|
222
|
+
// never fires forgetOlderHandoffs, so the microtask settles it
|
|
223
|
+
// (TIM-1480).
|
|
224
|
+
queueMicrotask(() => router?.settleHandoffs());
|
|
225
|
+
|
|
206
226
|
publishTree(currentPayload, currentParams);
|
|
207
227
|
render(renderTree(currentPayload, newNavState, currentParams), null);
|
|
208
228
|
}
|
|
@@ -268,7 +288,7 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
|
|
|
268
288
|
},
|
|
269
289
|
|
|
270
290
|
// Render a decoded RSC tree through the router's React root. Used for
|
|
271
|
-
// non-navigation renders (popstate cached replay,
|
|
291
|
+
// non-navigation renders (popstate cached replay, applyActionResult).
|
|
272
292
|
// Wraps with NavigationProvider + TimberNuqsAdapter.
|
|
273
293
|
//
|
|
274
294
|
// For navigation renders (navigate, refresh, popstate-with-fetch),
|
|
@@ -307,38 +327,43 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
|
|
|
307
327
|
//
|
|
308
328
|
// `_url` names the pending URL the router already published to its own
|
|
309
329
|
// pending store before calling here; the transition has no use for it.
|
|
310
|
-
navigateTransition: (_url: string, perform) => {
|
|
311
|
-
return navigateTransition(
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
rawPayload,
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
commit()
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
330
|
+
navigateTransition: (_url: string, owner: RenderOwner, perform, onCommit) => {
|
|
331
|
+
return navigateTransition(
|
|
332
|
+
owner,
|
|
333
|
+
async () => {
|
|
334
|
+
// What wrapPayload built, held until the transition wins. The client's
|
|
335
|
+
// record of the displayed tree is navigation state like any other, so
|
|
336
|
+
// it rides the same commit as the segment cache rather than being
|
|
337
|
+
// written as a side effect of building the element (TIM-1301).
|
|
338
|
+
let displayed: { tree: unknown; params: ParamsSource } | null = null;
|
|
339
|
+
const { element, decodePromise, commit } = await perform(
|
|
340
|
+
(
|
|
341
|
+
rawPayload: unknown,
|
|
342
|
+
navState: NavigationState,
|
|
343
|
+
params: ParamsSource,
|
|
344
|
+
segmentUpdates?: Map<string, unknown>
|
|
345
|
+
) => {
|
|
346
|
+
displayed = { tree: rawPayload, params };
|
|
347
|
+
return renderTree(
|
|
348
|
+
rawPayload,
|
|
349
|
+
navState,
|
|
350
|
+
params,
|
|
351
|
+
segmentUpdates as Map<string, React.ReactNode> | undefined
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
);
|
|
355
|
+
return {
|
|
356
|
+
element: element as React.ReactNode,
|
|
357
|
+
decodePromise,
|
|
358
|
+
commit: () => {
|
|
359
|
+
if (displayed) publishTree(displayed.tree, displayed.params);
|
|
360
|
+
commit();
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
},
|
|
364
|
+
render,
|
|
365
|
+
onCommit
|
|
366
|
+
);
|
|
342
367
|
},
|
|
343
368
|
|
|
344
369
|
_getCurrentPayload: () => currentPayload,
|
|
@@ -353,7 +378,7 @@ export function createTimberRouter(options: RouterInitOptions): RouterInitResult
|
|
|
353
378
|
},
|
|
354
379
|
};
|
|
355
380
|
|
|
356
|
-
|
|
381
|
+
router = createRouter(deps);
|
|
357
382
|
setGlobalRouter(router);
|
|
358
383
|
|
|
359
384
|
// Set up Navigation API integration after router is created.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { statusReasonPhrase } from '../shared/status-reason-phrase.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The deny chain's terminal, in client form.
|
|
7
|
+
*
|
|
8
|
+
* design/10-error-handling.md §"Fallback Chain" ends every chain in the
|
|
9
|
+
* framework default page. On a document load that page is the HTML string in
|
|
10
|
+
* `server/default-status-page.ts`. This is the same terminal for a deny that
|
|
11
|
+
* streamed to the client as an error row and reached the root boundary with
|
|
12
|
+
* nothing in between able to render it — which happens in exactly one shape:
|
|
13
|
+
* a server component rendered by a layout's own chrome denied, and no status
|
|
14
|
+
* file exists at or above that layout. Files *below* the denying layout are
|
|
15
|
+
* not candidates: rendering any of them means executing that layout again,
|
|
16
|
+
* which denies again. So there is no user file to show, and this renders the
|
|
17
|
+
* default one. See TIM-1450, design/04-authorization.md §"Where a Deny Page
|
|
18
|
+
* Renders".
|
|
19
|
+
*
|
|
20
|
+
* A plain reload button, not an automatic navigation: the status and phrase
|
|
21
|
+
* are already on screen and tell the reader what happened; the button hands
|
|
22
|
+
* them the one recovery the framework can offer (a document request, which
|
|
23
|
+
* takes the re-render fallback) without doing it behind their back. An
|
|
24
|
+
* effect-driven reload could not be told apart from a loop.
|
|
25
|
+
*
|
|
26
|
+
* Same content rules as the HTML page: the status code is the only thing
|
|
27
|
+
* interpolated, and it is clamped by `statusReasonPhrase`. No URL, no error
|
|
28
|
+
* message, no stack. See design/13-security.md principle 4.
|
|
29
|
+
*/
|
|
30
|
+
export function DenyLastResort({ status }: { status: number }) {
|
|
31
|
+
const phrase = statusReasonPhrase(status);
|
|
32
|
+
return (
|
|
33
|
+
<main
|
|
34
|
+
data-timber-deny-terminal=""
|
|
35
|
+
style={{
|
|
36
|
+
fontFamily: 'system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
37
|
+
textAlign: 'center',
|
|
38
|
+
padding: '2rem',
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
<h1 style={{ fontSize: '3rem', fontWeight: 600, margin: 0, letterSpacing: '-0.02em' }}>
|
|
42
|
+
{status}
|
|
43
|
+
</h1>
|
|
44
|
+
<p style={{ margin: '0.5rem 0 0', opacity: 0.7 }}>{phrase}</p>
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
onClick={() => window.location.reload()}
|
|
48
|
+
style={{ marginTop: '1.5rem', padding: '0.5rem 1rem', font: 'inherit', cursor: 'pointer' }}
|
|
49
|
+
>
|
|
50
|
+
Reload
|
|
51
|
+
</button>
|
|
52
|
+
</main>
|
|
53
|
+
);
|
|
54
|
+
}
|