@rangojs/router 0.0.0-experimental.133 → 0.0.0-experimental.135
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/bin/rango.js +7 -2
- package/dist/vite/index.js +41 -27
- package/package.json +23 -24
- package/skills/composability/SKILL.md +0 -1
- package/skills/handler-use/SKILL.md +7 -7
- package/skills/intercept/SKILL.md +38 -13
- package/skills/loader/SKILL.md +10 -0
- package/skills/migrate-nextjs/SKILL.md +3 -3
- package/skills/migrate-react-router/SKILL.md +144 -1
- package/skills/prerender/SKILL.md +20 -17
- package/skills/router-setup/SKILL.md +1 -2
- package/skills/testing/SKILL.md +1 -0
- package/skills/testing/render-handler.md +15 -14
- package/skills/use-cache/SKILL.md +11 -0
- package/skills/view-transitions/SKILL.md +43 -0
- package/src/browser/navigation-bridge.ts +65 -16
- package/src/browser/navigation-client.ts +27 -1
- package/src/browser/navigation-store.ts +82 -8
- package/src/browser/network-error-handler.ts +34 -7
- package/src/browser/partial-update.ts +43 -3
- package/src/browser/prefetch/cache.ts +8 -0
- package/src/browser/prefetch/fetch.ts +32 -4
- package/src/browser/react/NavigationProvider.tsx +195 -4
- package/src/browser/react/deferred-handle-resolution.ts +75 -0
- package/src/browser/response-adapter.ts +38 -9
- package/src/browser/types.ts +32 -1
- package/src/cache/cache-runtime.ts +26 -5
- package/src/cache/document-cache.ts +17 -1
- package/src/cache/profile-registry.ts +15 -0
- package/src/cache/read-through-swr.ts +15 -1
- package/src/handles/MetaTags.tsx +6 -0
- package/src/index.rsc.ts +6 -1
- package/src/index.ts +6 -4
- package/src/internal-debug.ts +11 -8
- package/src/render-error-thrower.tsx +20 -0
- package/src/route-content-wrapper.tsx +12 -5
- package/src/route-definition/dsl-helpers.ts +21 -32
- package/src/route-definition/helper-factories.ts +0 -2
- package/src/route-definition/helpers-types.ts +38 -39
- package/src/route-definition/index.ts +1 -2
- package/src/route-definition/resolve-handler-use.ts +0 -1
- package/src/route-definition/use-item-types.ts +3 -6
- package/src/route-types.ts +0 -5
- package/src/router/match-api.ts +5 -1
- package/src/router/match-middleware/background-revalidation.ts +40 -23
- package/src/router/match-middleware/cache-store.ts +39 -24
- package/src/router/segment-resolution/fresh.ts +4 -0
- package/src/router/segment-resolution/loader-cache.ts +14 -2
- package/src/router/segment-resolution/revalidation.ts +3 -0
- package/src/router/segment-resolution/view-transition-default.ts +35 -15
- package/src/rsc/progressive-enhancement.ts +56 -2
- package/src/rsc/rsc-rendering.ts +7 -2
- package/src/rsc/server-action.ts +25 -2
- package/src/rsc/transition-gate.ts +89 -0
- package/src/segment-system.tsx +59 -8
- package/src/server/context.ts +13 -0
- package/src/server/loader-registry.ts +13 -1
- package/src/server/request-context.ts +52 -3
- package/src/testing/index.ts +6 -0
- package/src/testing/render-handler.ts +14 -0
- package/src/testing/run-transition-when.ts +164 -0
- package/src/types/handler-context.ts +1 -1
- package/src/types/index.ts +2 -0
- package/src/types/segments.ts +100 -0
- package/src/urls/path-helper-types.ts +10 -7
- package/src/urls/urls-function.ts +0 -1
- package/src/vite/inject-client-debug.ts +36 -0
- package/src/vite/plugins/version-injector.ts +22 -7
- package/src/vite/plugins/virtual-entries.ts +28 -9
- package/src/vite/router-discovery.ts +8 -13
- package/src/network-error-thrower.tsx +0 -18
|
@@ -105,6 +105,10 @@ import { getRouterContext } from "../router-context.js";
|
|
|
105
105
|
import type { GeneratorMiddleware } from "./cache-lookup.js";
|
|
106
106
|
import { debugLog, debugWarn, getOrCreateRequestId } from "../logging.js";
|
|
107
107
|
import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
|
|
108
|
+
import {
|
|
109
|
+
runWithRequestContext,
|
|
110
|
+
type RequestContext,
|
|
111
|
+
} from "../../server/request-context.js";
|
|
108
112
|
|
|
109
113
|
/**
|
|
110
114
|
* Creates background revalidation middleware
|
|
@@ -183,33 +187,46 @@ export function withBackgroundRevalidation<TEnv>(
|
|
|
183
187
|
const freshLoaderPromises = new Map<string, Promise<any>>();
|
|
184
188
|
setupLoaderAccess(freshHandlerContext, freshLoaderPromises);
|
|
185
189
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
190
|
+
// Re-establish the request-context ALS around the re-render. ctx.Store
|
|
191
|
+
// is a different ALS (DSL build context); on workerd a waitUntil task
|
|
192
|
+
// runs detached from the request's I/O context, so a handler/component
|
|
193
|
+
// that reads the ambient getRequestContext() during this background
|
|
194
|
+
// re-render would otherwise throw "called outside of a request context".
|
|
195
|
+
const freshSegments = await runWithRequestContext(
|
|
196
|
+
requestCtx as RequestContext<TEnv>,
|
|
197
|
+
() =>
|
|
198
|
+
ctx.Store.run(() =>
|
|
199
|
+
resolveAllSegments(
|
|
200
|
+
ctx.entries,
|
|
201
|
+
ctx.routeKey,
|
|
202
|
+
ctx.matched.params,
|
|
203
|
+
freshHandlerContext,
|
|
204
|
+
freshLoaderPromises,
|
|
205
|
+
{ skipLoaders: true },
|
|
206
|
+
),
|
|
207
|
+
),
|
|
195
208
|
);
|
|
196
209
|
|
|
197
210
|
let freshInterceptSegments: ResolvedSegment[] = [];
|
|
198
211
|
if (ctx.interceptResult) {
|
|
199
|
-
freshInterceptSegments = await
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
ctx.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
212
|
+
freshInterceptSegments = await runWithRequestContext(
|
|
213
|
+
requestCtx as RequestContext<TEnv>,
|
|
214
|
+
() =>
|
|
215
|
+
ctx.Store.run(() =>
|
|
216
|
+
resolveInterceptEntry(
|
|
217
|
+
ctx.interceptResult!.intercept,
|
|
218
|
+
ctx.interceptResult!.entry,
|
|
219
|
+
ctx.matched.params,
|
|
220
|
+
freshHandlerContext,
|
|
221
|
+
true,
|
|
222
|
+
undefined,
|
|
223
|
+
// Skip intercept middleware: this is a post-response background
|
|
224
|
+
// re-render to refresh a stale cached route. The foreground
|
|
225
|
+
// already ran the middleware; re-running it would double its
|
|
226
|
+
// side effects and a short-circuit Response would abort the write.
|
|
227
|
+
{ skipMiddleware: true },
|
|
228
|
+
),
|
|
229
|
+
),
|
|
213
230
|
);
|
|
214
231
|
}
|
|
215
232
|
|
|
@@ -101,7 +101,10 @@
|
|
|
101
101
|
* - Non-GET request (only GET requests are cacheable)
|
|
102
102
|
*/
|
|
103
103
|
import type { ResolvedSegment } from "../../types.js";
|
|
104
|
-
import {
|
|
104
|
+
import {
|
|
105
|
+
getRequestContext,
|
|
106
|
+
runWithRequestContext,
|
|
107
|
+
} from "../../server/request-context.js";
|
|
105
108
|
import type { MatchContext, MatchPipelineState } from "../match-context.js";
|
|
106
109
|
import { getRouterContext } from "../router-context.js";
|
|
107
110
|
import { debugLog, debugWarn, getOrCreateRequestId } from "../logging.js";
|
|
@@ -231,34 +234,46 @@ export function withCacheStore<TEnv>(
|
|
|
231
234
|
setupLoaderAccess(proactiveHandlerContext, proactiveLoaderPromises);
|
|
232
235
|
|
|
233
236
|
const Store = ctx.Store;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
237
|
+
// Re-establish the request-context ALS around the re-render. Store
|
|
238
|
+
// is a different ALS (DSL build context); on workerd a waitUntil
|
|
239
|
+
// task runs detached from the request's I/O context, so a handler/
|
|
240
|
+
// component that reads the ambient getRequestContext() during this
|
|
241
|
+
// background re-render would otherwise throw "called outside of a
|
|
242
|
+
// request context".
|
|
243
|
+
const freshSegments = await runWithRequestContext(requestCtx, () =>
|
|
244
|
+
Store.run(() =>
|
|
245
|
+
resolveAllSegments(
|
|
246
|
+
ctx.entries,
|
|
247
|
+
ctx.routeKey,
|
|
248
|
+
ctx.matched.params,
|
|
249
|
+
proactiveHandlerContext,
|
|
250
|
+
proactiveLoaderPromises,
|
|
251
|
+
{ skipLoaders: true },
|
|
252
|
+
),
|
|
242
253
|
),
|
|
243
254
|
);
|
|
244
255
|
|
|
245
256
|
let freshInterceptSegments: ResolvedSegment[] = [];
|
|
246
257
|
if (ctx.interceptResult) {
|
|
247
|
-
freshInterceptSegments = await
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
258
|
+
freshInterceptSegments = await runWithRequestContext(
|
|
259
|
+
requestCtx,
|
|
260
|
+
() =>
|
|
261
|
+
Store.run(() =>
|
|
262
|
+
resolveInterceptEntry(
|
|
263
|
+
ctx.interceptResult!.intercept,
|
|
264
|
+
ctx.interceptResult!.entry,
|
|
265
|
+
ctx.matched.params,
|
|
266
|
+
proactiveHandlerContext,
|
|
267
|
+
true, // belongsToRoute
|
|
268
|
+
// No revalidationContext = render fresh
|
|
269
|
+
undefined,
|
|
270
|
+
// Skip intercept middleware: the foreground already ran it
|
|
271
|
+
// before the response was sent. Re-running here (post-
|
|
272
|
+
// response, background) would fire side effects twice and a
|
|
273
|
+
// short-circuit Response would silently abort this write.
|
|
274
|
+
{ skipMiddleware: true },
|
|
275
|
+
),
|
|
276
|
+
),
|
|
262
277
|
);
|
|
263
278
|
}
|
|
264
279
|
|
|
@@ -202,6 +202,7 @@ export async function resolveSegment<TEnv>(
|
|
|
202
202
|
transition: applyViewTransitionDefault(
|
|
203
203
|
entry.transition,
|
|
204
204
|
deps.viewTransitionDefault,
|
|
205
|
+
entry.shortCode,
|
|
205
206
|
),
|
|
206
207
|
params,
|
|
207
208
|
belongsToRoute: false,
|
|
@@ -345,6 +346,7 @@ export async function resolveSegment<TEnv>(
|
|
|
345
346
|
transition: applyViewTransitionDefault(
|
|
346
347
|
entry.transition,
|
|
347
348
|
deps.viewTransitionDefault,
|
|
349
|
+
entry.shortCode,
|
|
348
350
|
),
|
|
349
351
|
params,
|
|
350
352
|
belongsToRoute: true,
|
|
@@ -432,6 +434,7 @@ export async function resolveOrphanLayout<TEnv>(
|
|
|
432
434
|
transition: applyViewTransitionDefault(
|
|
433
435
|
orphan.transition,
|
|
434
436
|
deps.viewTransitionDefault,
|
|
437
|
+
orphan.shortCode,
|
|
435
438
|
),
|
|
436
439
|
...(orphan.mountPath ? { mountPath: orphan.mountPath } : {}),
|
|
437
440
|
});
|
|
@@ -565,6 +568,7 @@ export async function resolveParallelEntry<TEnv>(
|
|
|
565
568
|
transition: applyViewTransitionDefault(
|
|
566
569
|
parallelEntry.transition,
|
|
567
570
|
deps.viewTransitionDefault,
|
|
571
|
+
`${parentShortCode}.${slot}`,
|
|
568
572
|
),
|
|
569
573
|
params,
|
|
570
574
|
slot,
|
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
import type { LoaderEntry } from "../../server/context.js";
|
|
22
22
|
import type { HandlerContext, InternalHandlerContext } from "../../types.js";
|
|
23
23
|
import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
getRequestContext,
|
|
26
|
+
runWithRequestContext,
|
|
27
|
+
} from "../../server/request-context.js";
|
|
25
28
|
import { sortedRouteParams } from "../../cache/cache-key-utils.js";
|
|
26
29
|
import {
|
|
27
30
|
resolveTtl,
|
|
@@ -202,11 +205,20 @@ export function resolveLoaderData<TEnv>(
|
|
|
202
205
|
ctx.params,
|
|
203
206
|
);
|
|
204
207
|
|
|
208
|
+
// Capture the request context up front (foreground, ALS present) so the
|
|
209
|
+
// background stale revalidation can re-establish it. On workerd a waitUntil
|
|
210
|
+
// task runs detached from the request's I/O context, so a loader body that
|
|
211
|
+
// reads the ambient getRequestContext() would otherwise throw "called
|
|
212
|
+
// outside of a request context" and the revalidation would fail silently.
|
|
213
|
+
// The wrap is applied via wrapBackground (background path only); the
|
|
214
|
+
// foreground miss runs execute() directly since its context is present.
|
|
215
|
+
const requestCtxForExecute = getRequestContext();
|
|
205
216
|
return readThroughItem({
|
|
206
217
|
getItem: (k) => store.getItem!(k),
|
|
207
218
|
setItem: (k, v, o) => store.setItem!(k, v, o),
|
|
208
219
|
key,
|
|
209
220
|
execute: () => runMiss(loaderEntry.loader),
|
|
221
|
+
wrapBackground: (run) => runWithRequestContext(requestCtxForExecute, run),
|
|
210
222
|
serialize: (d) => codec.serializeResult(d),
|
|
211
223
|
deserialize: (v) => codec.deserializeResult(v),
|
|
212
224
|
storeOptions: { ttl, swr, tags },
|
|
@@ -214,7 +226,7 @@ export function resolveLoaderData<TEnv>(
|
|
|
214
226
|
onStale: () => debugLoaderCacheLog(`[LoaderCache] STALE: ${key}`),
|
|
215
227
|
onMiss: () => debugLoaderCacheLog(`[LoaderCache] MISS: ${key}`),
|
|
216
228
|
onCached: () => debugLoaderCacheLog(`[LoaderCache] Cached: ${key}`),
|
|
217
|
-
host:
|
|
229
|
+
host: requestCtxForExecute,
|
|
218
230
|
});
|
|
219
231
|
})();
|
|
220
232
|
|
|
@@ -660,6 +660,7 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
|
|
|
660
660
|
transition: applyViewTransitionDefault(
|
|
661
661
|
parallelEntry.transition,
|
|
662
662
|
deps.viewTransitionDefault,
|
|
663
|
+
parallelId,
|
|
663
664
|
),
|
|
664
665
|
params,
|
|
665
666
|
slot,
|
|
@@ -861,6 +862,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
|
|
|
861
862
|
transition: applyViewTransitionDefault(
|
|
862
863
|
entry.transition,
|
|
863
864
|
deps.viewTransitionDefault,
|
|
865
|
+
entry.shortCode,
|
|
864
866
|
),
|
|
865
867
|
params,
|
|
866
868
|
belongsToRoute,
|
|
@@ -1197,6 +1199,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
|
|
|
1197
1199
|
transition: applyViewTransitionDefault(
|
|
1198
1200
|
orphan.transition,
|
|
1199
1201
|
deps.viewTransitionDefault,
|
|
1202
|
+
orphan.shortCode,
|
|
1200
1203
|
),
|
|
1201
1204
|
...(orphan.mountPath ? { mountPath: orphan.mountPath } : {}),
|
|
1202
1205
|
});
|
|
@@ -8,29 +8,49 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { EntryData } from "../../server/context";
|
|
11
|
+
import { getRequestContext } from "../../server/request-context.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
|
-
* Resolve
|
|
14
|
+
* Resolve a segment's transition config: stamp the `viewTransition` default and
|
|
15
|
+
* peel off a transition({ when }) predicate.
|
|
14
16
|
*
|
|
15
|
-
*
|
|
16
|
-
* unset, the router-level createRouter({ viewTransition })
|
|
17
|
-
* in so the render gate reads the boundary decision off the
|
|
18
|
-
* and client, via the serialized segment — without the router
|
|
19
|
-
* threaded to the client. Only `false` is ever stamped; an unset
|
|
20
|
-
* value is left untouched because it already means "wrap" at the
|
|
21
|
-
* also avoids needless object allocation and payload growth.
|
|
22
|
-
*
|
|
17
|
+
* `viewTransition`: the per-segment value (set via the transition() DSL) always
|
|
18
|
+
* wins. When it is unset, the router-level createRouter({ viewTransition })
|
|
19
|
+
* default is stamped in so the render gate reads the boundary decision off the
|
|
20
|
+
* segment — server and client, via the serialized segment — without the router
|
|
21
|
+
* option being threaded to the client. Only `false` is ever stamped; an unset
|
|
22
|
+
* (or "auto") value is left untouched because it already means "wrap" at the
|
|
23
|
+
* gate, which also avoids needless object allocation and payload growth.
|
|
24
|
+
*
|
|
25
|
+
* `when`: a server-only predicate. It is STRIPPED from the returned config (a
|
|
26
|
+
* function cannot cross Flight or the segment cache) and recorded on the request
|
|
27
|
+
* context keyed by `segmentId`, so rsc-rendering can evaluate it post-handler —
|
|
28
|
+
* outside any cache scope — and drop this segment's transition when it returns
|
|
29
|
+
* false. Used by both the fresh and revalidation resolution paths.
|
|
23
30
|
*/
|
|
24
31
|
export function applyViewTransitionDefault(
|
|
25
32
|
transition: EntryData["transition"],
|
|
26
33
|
viewTransitionDefault: "auto" | false | undefined,
|
|
34
|
+
segmentId?: string,
|
|
27
35
|
): EntryData["transition"] {
|
|
28
36
|
if (!transition) return transition;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
let result = transition;
|
|
38
|
+
if (result.when) {
|
|
39
|
+
if (segmentId !== undefined) {
|
|
40
|
+
try {
|
|
41
|
+
const ctx = getRequestContext();
|
|
42
|
+
(ctx._transitionWhen ??= []).push({ id: segmentId, when: result.when });
|
|
43
|
+
} catch {
|
|
44
|
+
// No active request context (e.g. a unit test calling this util
|
|
45
|
+
// directly). Skip collection; the strip below still applies so the
|
|
46
|
+
// serialized config never carries the function.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const { when: _when, ...rest } = result;
|
|
50
|
+
result = rest;
|
|
51
|
+
}
|
|
52
|
+
if (result.viewTransition === undefined && viewTransitionDefault === false) {
|
|
53
|
+
return { ...result, viewTransition: false };
|
|
34
54
|
}
|
|
35
|
-
return
|
|
55
|
+
return result;
|
|
36
56
|
}
|
|
@@ -14,6 +14,7 @@ import { getSSRSetup } from "./ssr-setup.js";
|
|
|
14
14
|
import type { MiddlewareFn } from "../router/middleware.js";
|
|
15
15
|
import { executeMiddleware } from "../router/middleware.js";
|
|
16
16
|
import { observePhase, PHASES } from "../router/instrument.js";
|
|
17
|
+
import { gateTransitions } from "./transition-gate.js";
|
|
17
18
|
import type { RscPayload, ReactFormState } from "./types.js";
|
|
18
19
|
import {
|
|
19
20
|
createResponseWithMergedHeaders,
|
|
@@ -152,6 +153,7 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
152
153
|
handleStore,
|
|
153
154
|
nonce,
|
|
154
155
|
useActionStateId,
|
|
156
|
+
true, // an action ran and threw
|
|
155
157
|
);
|
|
156
158
|
if (errorHtml) return errorHtml;
|
|
157
159
|
|
|
@@ -205,6 +207,7 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
205
207
|
handleStore,
|
|
206
208
|
nonce,
|
|
207
209
|
directActionId,
|
|
210
|
+
true, // an action ran and threw
|
|
208
211
|
);
|
|
209
212
|
if (errorHtml) return errorHtml;
|
|
210
213
|
|
|
@@ -269,6 +272,14 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
269
272
|
headers,
|
|
270
273
|
});
|
|
271
274
|
|
|
275
|
+
// JS/PE parity: this is an action's revalidation render, so mark it BEFORE
|
|
276
|
+
// matching — a stale `foregroundOnAction` cache entry must re-execute in the
|
|
277
|
+
// foreground during the re-render, exactly as the JS path's
|
|
278
|
+
// revalidateAfterAction does. The transition({ when }) gate fields below are
|
|
279
|
+
// set post-match (the gate reads them after rendering); foregroundOnAction
|
|
280
|
+
// reads _inActionRevalidation during the match, so it must be set here.
|
|
281
|
+
getRequestContext()._inActionRevalidation = true;
|
|
282
|
+
|
|
272
283
|
const match = await ctx.router.match(renderRequest, { env });
|
|
273
284
|
|
|
274
285
|
if (match.redirect) {
|
|
@@ -278,12 +289,26 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
278
289
|
});
|
|
279
290
|
}
|
|
280
291
|
|
|
292
|
+
// Expose the no-JS action to the transition({ when }) gate. currentUrl/Params
|
|
293
|
+
// are absent on this full-render path (no navigation snapshot); useActionState
|
|
294
|
+
// ids are block-scoped, so only a direct action id is available here.
|
|
295
|
+
// actionUrl is the page the action was submitted from (this request's url).
|
|
296
|
+
const peReqCtx = getRequestContext();
|
|
297
|
+
peReqCtx._gateActionId = directActionId ?? undefined;
|
|
298
|
+
peReqCtx._gateActionUrl = new URL(url);
|
|
299
|
+
peReqCtx._gateActionResult = actionResult;
|
|
300
|
+
peReqCtx._gateFormData = formData;
|
|
301
|
+
|
|
281
302
|
const payload: RscPayload = {
|
|
282
303
|
metadata: {
|
|
283
304
|
pathname: url.pathname,
|
|
284
305
|
routerId: ctx.router.id,
|
|
285
306
|
basename: ctx.router.basename,
|
|
286
|
-
segments:
|
|
307
|
+
segments: gateTransitions(
|
|
308
|
+
match.segments,
|
|
309
|
+
getRequestContext(),
|
|
310
|
+
ctx.router.onError,
|
|
311
|
+
),
|
|
287
312
|
matched: match.matched,
|
|
288
313
|
diff: match.diff,
|
|
289
314
|
resolvedIds: match.resolvedIds,
|
|
@@ -368,7 +393,22 @@ async function renderPeErrorBoundary<TEnv>(
|
|
|
368
393
|
handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
|
|
369
394
|
nonce: string | undefined,
|
|
370
395
|
actionId?: string | null,
|
|
396
|
+
// True when an action actually ran and threw (vs a malformed form body, where
|
|
397
|
+
// no action executed). Drives _inActionRevalidation for JS/PE parity — it must
|
|
398
|
+
// NOT be inferred from actionId, since a useActionState bound action can run
|
|
399
|
+
// and throw with no $$id (actionId === undefined) yet still be an action error.
|
|
400
|
+
actionRan = false,
|
|
371
401
|
): Promise<Response | null> {
|
|
402
|
+
// JS/PE parity for an action-triggered error re-render: a stale
|
|
403
|
+
// `foregroundOnAction` cache entry inside the error boundary must foreground
|
|
404
|
+
// too, exactly as the JS path (revalidateAfterAction sets this unconditionally
|
|
405
|
+
// before rendering the error boundary). Set BEFORE matchError (the cached fn
|
|
406
|
+
// runs during it). Gated on actionRan, NOT actionId — a malformed form body
|
|
407
|
+
// (actionRan=false) ran no action and must keep SWR.
|
|
408
|
+
if (actionRan) {
|
|
409
|
+
getRequestContext()._inActionRevalidation = true;
|
|
410
|
+
}
|
|
411
|
+
|
|
372
412
|
let errorResult;
|
|
373
413
|
try {
|
|
374
414
|
errorResult = await ctx.router.matchError(request, { env }, error, "route");
|
|
@@ -395,12 +435,26 @@ async function renderPeErrorBoundary<TEnv>(
|
|
|
395
435
|
|
|
396
436
|
setRequestContextParams(errorResult.params, errorResult.routeName);
|
|
397
437
|
|
|
438
|
+
// Only the failing action id + URL are in scope here (no formData/actionResult
|
|
439
|
+
// thread into this helper). Expose the URL only when the action id is known:
|
|
440
|
+
// this helper also handles malformed form bodies before action detection, and
|
|
441
|
+
// those should not look like action-triggered renders to transition({ when }).
|
|
442
|
+
if (actionId != null) {
|
|
443
|
+
const peErrCtx = getRequestContext();
|
|
444
|
+
peErrCtx._gateActionId = actionId;
|
|
445
|
+
peErrCtx._gateActionUrl = new URL(url);
|
|
446
|
+
}
|
|
447
|
+
|
|
398
448
|
const payload: RscPayload = {
|
|
399
449
|
metadata: {
|
|
400
450
|
pathname: url.pathname,
|
|
401
451
|
routerId: ctx.router.id,
|
|
402
452
|
basename: ctx.router.basename,
|
|
403
|
-
segments:
|
|
453
|
+
segments: gateTransitions(
|
|
454
|
+
errorResult.segments,
|
|
455
|
+
getRequestContext(),
|
|
456
|
+
ctx.router.onError,
|
|
457
|
+
),
|
|
404
458
|
matched: errorResult.matched,
|
|
405
459
|
diff: errorResult.diff,
|
|
406
460
|
resolvedIds: errorResult.resolvedIds,
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
attachLocationStateIfPresent,
|
|
22
22
|
} from "./helpers.js";
|
|
23
23
|
import type { HandlerContext } from "./handler-context.js";
|
|
24
|
+
import { gateTransitions } from "./transition-gate.js";
|
|
24
25
|
|
|
25
26
|
export function handleRscRendering<TEnv>(
|
|
26
27
|
ctx: HandlerContext<TEnv>,
|
|
@@ -70,7 +71,7 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
70
71
|
pathname: url.pathname,
|
|
71
72
|
routerId: ctx.router.id,
|
|
72
73
|
basename: ctx.router.basename,
|
|
73
|
-
segments: m.segments,
|
|
74
|
+
segments: gateTransitions(m.segments, reqCtx, ctx.router.onError),
|
|
74
75
|
matched: m.matched,
|
|
75
76
|
diff: m.diff,
|
|
76
77
|
resolvedIds: m.resolvedIds,
|
|
@@ -126,7 +127,11 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
126
127
|
// intercepted server-side (X-RSC-Reload) and never delivers a
|
|
127
128
|
// different-router payload to the client.
|
|
128
129
|
routerId: ctx.router.id,
|
|
129
|
-
segments:
|
|
130
|
+
segments: gateTransitions(
|
|
131
|
+
result.segments,
|
|
132
|
+
reqCtx,
|
|
133
|
+
ctx.router.onError,
|
|
134
|
+
),
|
|
130
135
|
matched: result.matched,
|
|
131
136
|
diff: result.diff,
|
|
132
137
|
resolvedIds: result.resolvedIds,
|
package/src/rsc/server-action.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "../server/request-context.js";
|
|
22
22
|
import { appendMetric } from "../router/metrics.js";
|
|
23
23
|
import { observePhase, PHASES } from "../router/instrument.js";
|
|
24
|
+
import { gateTransitions } from "./transition-gate.js";
|
|
24
25
|
import type { RscPayload } from "./types.js";
|
|
25
26
|
import {
|
|
26
27
|
hasBodyContent,
|
|
@@ -363,6 +364,20 @@ async function revalidateAfterActionInner<TEnv>(
|
|
|
363
364
|
const reqCtx = getRequestContext();
|
|
364
365
|
const metricsStore = reqCtx._metricsStore;
|
|
365
366
|
|
|
367
|
+
// Expose the action that triggered this revalidation to the transition({ when })
|
|
368
|
+
// gate (covers both the error-boundary and success gate calls below). Mirrors
|
|
369
|
+
// the action fields a revalidate() predicate sees.
|
|
370
|
+
reqCtx._gateActionId = actionContext?.actionId;
|
|
371
|
+
reqCtx._gateActionUrl = actionContext?.actionUrl;
|
|
372
|
+
reqCtx._gateActionResult = actionContext?.actionResult;
|
|
373
|
+
reqCtx._gateFormData = actionContext?.formData;
|
|
374
|
+
|
|
375
|
+
// Mark the rest of this request as an action revalidation render. The "use
|
|
376
|
+
// cache" runtime reads this to re-execute a stale entry in the foreground
|
|
377
|
+
// (fresh data in the action response) rather than serving stale + revalidating
|
|
378
|
+
// in the background. See registerCachedFunction in cache/cache-runtime.ts.
|
|
379
|
+
reqCtx._inActionRevalidation = true;
|
|
380
|
+
|
|
366
381
|
// Action threw and a boundary matched: render the (already-matched) error
|
|
367
382
|
// boundary here so it runs inside the route-middleware wrapper, exactly like
|
|
368
383
|
// the success branch below. setRequestContextParams + the payload mirror the
|
|
@@ -376,7 +391,11 @@ async function revalidateAfterActionInner<TEnv>(
|
|
|
376
391
|
// routerId exposed for the frontend (current app identity); see
|
|
377
392
|
// rsc-rendering.ts partial branch.
|
|
378
393
|
routerId: ctx.router.id,
|
|
379
|
-
segments:
|
|
394
|
+
segments: gateTransitions(
|
|
395
|
+
errorBoundary.segments,
|
|
396
|
+
reqCtx,
|
|
397
|
+
ctx.router.onError,
|
|
398
|
+
),
|
|
380
399
|
isPartial: true,
|
|
381
400
|
matched: errorBoundary.matched,
|
|
382
401
|
diff: errorBoundary.diff,
|
|
@@ -460,7 +479,11 @@ async function revalidateAfterActionInner<TEnv>(
|
|
|
460
479
|
// routerId exposed for the frontend (current app identity); see
|
|
461
480
|
// rsc-rendering.ts partial branch.
|
|
462
481
|
routerId: ctx.router.id,
|
|
463
|
-
segments:
|
|
482
|
+
segments: gateTransitions(
|
|
483
|
+
matchResult.segments,
|
|
484
|
+
reqCtx,
|
|
485
|
+
ctx.router.onError,
|
|
486
|
+
),
|
|
464
487
|
isPartial: true,
|
|
465
488
|
matched: matchResult.matched,
|
|
466
489
|
diff: matchResult.diff,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { MatchResult } from "../types.js";
|
|
2
|
+
import type { TransitionWhenContext } from "../types/segments.js";
|
|
3
|
+
import type { getRequestContext } from "../server/request-context.js";
|
|
4
|
+
import { invokeOnError } from "../router/error-handling.js";
|
|
5
|
+
import type { OnErrorCallback } from "../types/error-types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Apply transition({ when }) gates to a payload's segments.
|
|
9
|
+
*
|
|
10
|
+
* The predicates were collected during resolution (keyed by segment id) and
|
|
11
|
+
* stripped from the serialized config; here — after handlers ran and outside any
|
|
12
|
+
* cache scope — we evaluate each and drop the segment's transition when the
|
|
13
|
+
* predicate does not hold, so the navigation streams its loading fallback
|
|
14
|
+
* instead of holding the previous content. A predicate that throws is reported
|
|
15
|
+
* to the router's onError (phase "rendering") and then treated as "do not hold"
|
|
16
|
+
* (conservative), so a buggy predicate degrades to no transition rather than
|
|
17
|
+
* failing the response.
|
|
18
|
+
*
|
|
19
|
+
* Mutating the segments here is safe: the segment cache stores a serialized copy
|
|
20
|
+
* (segment-codec), written during match() BEFORE this gate runs, so dropping a
|
|
21
|
+
* transition never corrupts a cache entry. The flip side is that a cache hit
|
|
22
|
+
* skips resolution, collects no predicate, and replays the cached transition
|
|
23
|
+
* as-is (it was serialized before the gate) — combining transition({ when })
|
|
24
|
+
* with cache() on the same segment freezes the gate to its cached state, so
|
|
25
|
+
* avoid caching a route whose transition decision is request-dependent.
|
|
26
|
+
*
|
|
27
|
+
* Returns the same array (mutated) for inline use at the payload's `segments`
|
|
28
|
+
* field.
|
|
29
|
+
*/
|
|
30
|
+
export function gateTransitions(
|
|
31
|
+
segments: MatchResult["segments"],
|
|
32
|
+
ctx: ReturnType<typeof getRequestContext>,
|
|
33
|
+
onError?: OnErrorCallback,
|
|
34
|
+
): MatchResult["segments"] {
|
|
35
|
+
const predicates = ctx._transitionWhen;
|
|
36
|
+
if (predicates && predicates.length) {
|
|
37
|
+
for (const { id, when } of predicates) {
|
|
38
|
+
let drop: boolean;
|
|
39
|
+
try {
|
|
40
|
+
// Assemble the ShouldRevalidateFn-shaped predicate context from the
|
|
41
|
+
// request context. Source fields (currentUrl/currentParams/fromRouteName)
|
|
42
|
+
// were stashed at match time from the navigation snapshot; action fields
|
|
43
|
+
// at the action-bearing gate call sites. nextUrl/nextParams/toRouteName/
|
|
44
|
+
// method/get/env come straight off ctx (setRequestContextParams ran
|
|
45
|
+
// before the gate). Source/action fields are undefined when absent —
|
|
46
|
+
// never fabricated (see TransitionWhenContext).
|
|
47
|
+
const whenCtx: TransitionWhenContext = {
|
|
48
|
+
currentUrl: ctx._gateCurrentUrl,
|
|
49
|
+
currentParams: ctx._gateCurrentParams,
|
|
50
|
+
fromRouteName:
|
|
51
|
+
ctx._prevRouteKey as TransitionWhenContext["fromRouteName"],
|
|
52
|
+
nextUrl: ctx.url,
|
|
53
|
+
nextParams: ctx.params,
|
|
54
|
+
toRouteName: ctx.routeName,
|
|
55
|
+
actionId: ctx._gateActionId,
|
|
56
|
+
actionUrl: ctx._gateActionUrl,
|
|
57
|
+
actionResult: ctx._gateActionResult,
|
|
58
|
+
formData: ctx._gateFormData,
|
|
59
|
+
method: ctx.request.method,
|
|
60
|
+
get: ctx.get,
|
|
61
|
+
env: ctx.env,
|
|
62
|
+
};
|
|
63
|
+
drop = when(whenCtx) === false;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
// A throwing predicate must not fail the response: report it and treat
|
|
66
|
+
// the transition as gated off (do not hold). invokeOnError no-ops when
|
|
67
|
+
// onError is undefined.
|
|
68
|
+
drop = true;
|
|
69
|
+
invokeOnError(
|
|
70
|
+
onError,
|
|
71
|
+
error,
|
|
72
|
+
"rendering",
|
|
73
|
+
{
|
|
74
|
+
request: ctx.request,
|
|
75
|
+
url: ctx.url,
|
|
76
|
+
params: ctx.params,
|
|
77
|
+
segmentId: id,
|
|
78
|
+
},
|
|
79
|
+
"RSC",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (drop) {
|
|
83
|
+
const seg = segments.find((s) => s.id === id);
|
|
84
|
+
if (seg) seg.transition = undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return segments;
|
|
89
|
+
}
|