@rangojs/router 0.0.0-experimental.88a3b2f7 → 0.0.0-experimental.8bd1b239
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/vite/index.js +307 -128
- package/package.json +3 -3
- package/skills/handler-use/SKILL.md +362 -0
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/middleware/SKILL.md +32 -3
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +764 -0
- package/skills/parallel/SKILL.md +59 -0
- package/skills/rango/SKILL.md +24 -22
- package/skills/route/SKILL.md +24 -0
- package/src/browser/navigation-bridge.ts +21 -2
- package/src/browser/navigation-client.ts +34 -6
- package/src/browser/partial-update.ts +14 -2
- package/src/browser/prefetch/cache.ts +16 -6
- package/src/browser/prefetch/fetch.ts +60 -4
- package/src/browser/react/Link.tsx +25 -2
- package/src/browser/react/use-navigation.ts +11 -10
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/build/route-trie.ts +50 -24
- package/src/client.tsx +84 -230
- package/src/index.ts +41 -9
- package/src/reverse.ts +4 -1
- package/src/route-definition/dsl-helpers.ts +164 -21
- package/src/route-definition/helpers-types.ts +61 -14
- package/src/router/handler-context.ts +4 -1
- package/src/router/loader-resolution.ts +70 -46
- package/src/router/match-middleware/cache-lookup.ts +10 -5
- package/src/router/match-middleware/segment-resolution.ts +1 -1
- package/src/router/match-result.ts +82 -4
- package/src/router/segment-resolution/fresh.ts +52 -0
- package/src/router/segment-resolution/revalidation.ts +69 -1
- package/src/rsc/handler.ts +13 -5
- package/src/rsc/loader-fetch.ts +23 -3
- package/src/rsc/progressive-enhancement.ts +10 -2
- package/src/rsc/rsc-rendering.ts +5 -1
- package/src/rsc/server-action.ts +6 -0
- package/src/rsc/types.ts +1 -0
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +11 -61
- package/src/server/handle-store.ts +19 -0
- package/src/server/request-context.ts +54 -14
- package/src/types/segments.ts +1 -1
- package/src/urls/path-helper-types.ts +34 -5
- package/src/use-loader.tsx +77 -5
- package/src/vite/discovery/prerender-collection.ts +124 -83
- package/src/vite/plugins/expose-id-utils.ts +12 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
- package/src/vite/plugins/expose-internal-ids.ts +257 -40
- package/src/vite/router-discovery.ts +25 -3
- package/src/vite/utils/prerender-utils.ts +20 -6
|
@@ -125,6 +125,69 @@ export async function collectSegments(
|
|
|
125
125
|
return segments;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Deduplicate inherited loader segments by loaderId.
|
|
130
|
+
*
|
|
131
|
+
* When a route has loaders and a child layout has parallel slots, the same
|
|
132
|
+
* loader is resolved twice: once for the route and once inherited into the
|
|
133
|
+
* layout (tagged with `_inherited`). The inherited copy is only needed when
|
|
134
|
+
* the route uses `loading()` — in that case, the loader data is inside a
|
|
135
|
+
* LoaderBoundary/Suspense that parallel slots can't reach through. Without
|
|
136
|
+
* loading(), useLoader() traverses parent contexts and finds the data.
|
|
137
|
+
*/
|
|
138
|
+
function deduplicateLoaderSegments(
|
|
139
|
+
segments: ResolvedSegment[],
|
|
140
|
+
logPrefix: string,
|
|
141
|
+
): ResolvedSegment[] {
|
|
142
|
+
// First pass: collect loaderIds of original (non-inherited) segments
|
|
143
|
+
// and whether their parent entry uses loading()
|
|
144
|
+
const originalLoaders = new Set<string>();
|
|
145
|
+
const loadersWithLoading = new Set<string>();
|
|
146
|
+
for (const s of segments) {
|
|
147
|
+
if (s.type === "loader" && s.loaderId && !s._inherited) {
|
|
148
|
+
originalLoaders.add(s.loaderId);
|
|
149
|
+
// If the segment has a sibling with loading, the parent uses loading()
|
|
150
|
+
// We detect this by checking if any non-loader segment in the same
|
|
151
|
+
// namespace has loading defined
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Check if any layout/route segment has loading — if a loader's namespace
|
|
155
|
+
// matches a segment with loading, the inherited copy is needed
|
|
156
|
+
for (const s of segments) {
|
|
157
|
+
if (s.type !== "loader" && s.loading !== undefined && s.loading !== false) {
|
|
158
|
+
// Find loaders in this namespace
|
|
159
|
+
for (const l of segments) {
|
|
160
|
+
if (l.type === "loader" && l.namespace === s.namespace && l.loaderId) {
|
|
161
|
+
loadersWithLoading.add(l.loaderId);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const result: ResolvedSegment[] = [];
|
|
168
|
+
let dedupCount = 0;
|
|
169
|
+
|
|
170
|
+
for (const s of segments) {
|
|
171
|
+
if (
|
|
172
|
+
s.type === "loader" &&
|
|
173
|
+
s.loaderId &&
|
|
174
|
+
s._inherited &&
|
|
175
|
+
originalLoaders.has(s.loaderId) &&
|
|
176
|
+
!loadersWithLoading.has(s.loaderId)
|
|
177
|
+
) {
|
|
178
|
+
dedupCount++;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
result.push(s);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (dedupCount > 0) {
|
|
185
|
+
debugLog(logPrefix, `deduped ${dedupCount} inherited loader segment(s)`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
|
|
128
191
|
/**
|
|
129
192
|
* Build the final MatchResult from collected segments and context
|
|
130
193
|
*/
|
|
@@ -181,6 +244,11 @@ export function buildMatchResult<TEnv>(
|
|
|
181
244
|
);
|
|
182
245
|
}
|
|
183
246
|
|
|
247
|
+
const dedupedSegments = deduplicateLoaderSegments(
|
|
248
|
+
segmentsToRender,
|
|
249
|
+
logPrefix,
|
|
250
|
+
);
|
|
251
|
+
|
|
184
252
|
debugLog(logPrefix, "all segments", {
|
|
185
253
|
segments: allSegments.map((s) => ({
|
|
186
254
|
id: s.id,
|
|
@@ -189,13 +257,23 @@ export function buildMatchResult<TEnv>(
|
|
|
189
257
|
})),
|
|
190
258
|
});
|
|
191
259
|
debugLog(logPrefix, "segments to render", {
|
|
192
|
-
segmentIds:
|
|
260
|
+
segmentIds: dedupedSegments.map((s) => s.id),
|
|
193
261
|
});
|
|
194
262
|
|
|
263
|
+
// Remove deduped loader IDs from matched so the client doesn't treat
|
|
264
|
+
// them as missing segments and trigger a fallback refetch.
|
|
265
|
+
const removedIds = new Set(
|
|
266
|
+
segmentsToRender
|
|
267
|
+
.filter((s) => !dedupedSegments.includes(s))
|
|
268
|
+
.map((s) => s.id),
|
|
269
|
+
);
|
|
270
|
+
const matchedIds =
|
|
271
|
+
removedIds.size > 0 ? allIds.filter((id) => !removedIds.has(id)) : allIds;
|
|
272
|
+
|
|
195
273
|
return {
|
|
196
|
-
segments:
|
|
197
|
-
matched:
|
|
198
|
-
diff:
|
|
274
|
+
segments: dedupedSegments,
|
|
275
|
+
matched: matchedIds,
|
|
276
|
+
diff: dedupedSegments.map((s) => s.id),
|
|
199
277
|
params: ctx.matched.params,
|
|
200
278
|
routeName: ctx.routeKey,
|
|
201
279
|
slots: Object.keys(state.slots).length > 0 ? state.slots : undefined,
|
|
@@ -327,6 +327,7 @@ export async function resolveSegment<TEnv>(
|
|
|
327
327
|
deps,
|
|
328
328
|
options,
|
|
329
329
|
routeKey,
|
|
330
|
+
entry,
|
|
330
331
|
);
|
|
331
332
|
segments.push(...orphanSegments);
|
|
332
333
|
}
|
|
@@ -382,6 +383,9 @@ export async function resolveOrphanLayout<TEnv>(
|
|
|
382
383
|
deps: SegmentResolutionDeps<TEnv>,
|
|
383
384
|
options?: ResolveSegmentOptions,
|
|
384
385
|
routeKey?: string,
|
|
386
|
+
/** Parent route entry — its loaders are inherited by the layout so
|
|
387
|
+
* parallel slots inside this layout can access them via useLoader(). */
|
|
388
|
+
parentRouteEntry?: EntryData,
|
|
385
389
|
): Promise<ResolvedSegment[]> {
|
|
386
390
|
invariant(
|
|
387
391
|
orphan.type === "layout" || orphan.type === "cache",
|
|
@@ -397,6 +401,30 @@ export async function resolveOrphanLayout<TEnv>(
|
|
|
397
401
|
deps,
|
|
398
402
|
);
|
|
399
403
|
segments.push(...loaderSegments);
|
|
404
|
+
|
|
405
|
+
// Inherit parent route's loaders so parallel slots inside this layout
|
|
406
|
+
// can access them via useLoader(). Without this, the route's loaders
|
|
407
|
+
// are only in the route's OutletProvider (rendered as <Outlet /> content),
|
|
408
|
+
// which is a child — not a parent — of the layout's context.
|
|
409
|
+
if (
|
|
410
|
+
parentRouteEntry &&
|
|
411
|
+
parentRouteEntry.loader &&
|
|
412
|
+
parentRouteEntry.loader.length > 0 &&
|
|
413
|
+
Object.keys(orphan.parallel).length > 0
|
|
414
|
+
) {
|
|
415
|
+
const inheritedLoaders = await resolveLoaders(
|
|
416
|
+
parentRouteEntry,
|
|
417
|
+
context,
|
|
418
|
+
belongsToRoute,
|
|
419
|
+
deps,
|
|
420
|
+
orphan.shortCode,
|
|
421
|
+
);
|
|
422
|
+
// Tag as inherited so buildMatchResult can deduplicate when safe
|
|
423
|
+
for (const s of inheritedLoaders) {
|
|
424
|
+
s._inherited = true;
|
|
425
|
+
}
|
|
426
|
+
segments.push(...inheritedLoaders);
|
|
427
|
+
}
|
|
400
428
|
}
|
|
401
429
|
|
|
402
430
|
// Handler-first: orphan layout handler executes before its parallels
|
|
@@ -685,6 +713,30 @@ export async function resolveLoadersOnly<TEnv>(
|
|
|
685
713
|
const childBelongsToRoute = belongsToRoute || entry.type === "route";
|
|
686
714
|
for (const layoutEntry of entry.layout) {
|
|
687
715
|
await collectEntryLoaders(layoutEntry, childBelongsToRoute);
|
|
716
|
+
// Inherit route loaders for orphan layouts with parallels.
|
|
717
|
+
// Resolve directly — do NOT re-enter collectEntryLoaders with the
|
|
718
|
+
// route entry, as that would re-iterate route.layout and loop.
|
|
719
|
+
if (
|
|
720
|
+
entry.type === "route" &&
|
|
721
|
+
entry.loader &&
|
|
722
|
+
entry.loader.length > 0 &&
|
|
723
|
+
Object.keys(layoutEntry.parallel).length > 0
|
|
724
|
+
) {
|
|
725
|
+
const inherited = await resolveLoaders(
|
|
726
|
+
entry,
|
|
727
|
+
context,
|
|
728
|
+
childBelongsToRoute,
|
|
729
|
+
deps,
|
|
730
|
+
layoutEntry.shortCode,
|
|
731
|
+
);
|
|
732
|
+
for (const seg of inherited) {
|
|
733
|
+
if (!seenIds.has(seg.id)) {
|
|
734
|
+
seenIds.add(seg.id);
|
|
735
|
+
seg._inherited = true;
|
|
736
|
+
loaderSegments.push(seg);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
688
740
|
}
|
|
689
741
|
}
|
|
690
742
|
|
|
@@ -319,6 +319,39 @@ export async function resolveLoadersOnlyWithRevalidation<TEnv>(
|
|
|
319
319
|
const childBelongsToRoute = belongsToRoute || entry.type === "route";
|
|
320
320
|
for (const layoutEntry of entry.layout) {
|
|
321
321
|
await collectEntryLoaders(layoutEntry, childBelongsToRoute);
|
|
322
|
+
// Inherit route loaders for orphan layouts with parallels.
|
|
323
|
+
// Resolve directly — do NOT re-enter collectEntryLoaders with the
|
|
324
|
+
// route entry, as that would re-iterate route.layout and loop.
|
|
325
|
+
if (
|
|
326
|
+
entry.type === "route" &&
|
|
327
|
+
entry.loader &&
|
|
328
|
+
entry.loader.length > 0 &&
|
|
329
|
+
Object.keys(layoutEntry.parallel).length > 0
|
|
330
|
+
) {
|
|
331
|
+
const inherited = await resolveLoadersWithRevalidation(
|
|
332
|
+
entry,
|
|
333
|
+
context,
|
|
334
|
+
childBelongsToRoute,
|
|
335
|
+
clientSegmentIds,
|
|
336
|
+
prevParams,
|
|
337
|
+
request,
|
|
338
|
+
prevUrl,
|
|
339
|
+
nextUrl,
|
|
340
|
+
routeKey,
|
|
341
|
+
deps,
|
|
342
|
+
actionContext,
|
|
343
|
+
layoutEntry.shortCode,
|
|
344
|
+
stale,
|
|
345
|
+
);
|
|
346
|
+
for (const seg of inherited.segments) {
|
|
347
|
+
if (!seenIds.has(seg.id)) {
|
|
348
|
+
seenIds.add(seg.id);
|
|
349
|
+
seg._inherited = true;
|
|
350
|
+
allLoaderSegments.push(seg);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
allMatchedIds.push(...inherited.matchedIds);
|
|
354
|
+
}
|
|
322
355
|
}
|
|
323
356
|
}
|
|
324
357
|
|
|
@@ -840,6 +873,7 @@ export async function resolveSegmentWithRevalidation<TEnv>(
|
|
|
840
873
|
deps,
|
|
841
874
|
actionContext,
|
|
842
875
|
stale,
|
|
876
|
+
entry,
|
|
843
877
|
);
|
|
844
878
|
segments.push(...orphanResult.segments);
|
|
845
879
|
matchedIds.push(...orphanResult.matchedIds);
|
|
@@ -951,6 +985,8 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
|
|
|
951
985
|
deps: SegmentResolutionDeps<TEnv>,
|
|
952
986
|
actionContext?: ActionContext,
|
|
953
987
|
stale?: boolean,
|
|
988
|
+
/** Parent route entry — its loaders are inherited so parallel slots can access them. */
|
|
989
|
+
parentRouteEntry?: EntryData,
|
|
954
990
|
): Promise<SegmentRevalidationResult> {
|
|
955
991
|
invariant(
|
|
956
992
|
orphan.type === "layout" || orphan.type === "cache",
|
|
@@ -978,6 +1014,37 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
|
|
|
978
1014
|
segments.push(...loaderResult.segments);
|
|
979
1015
|
matchedIds.push(...loaderResult.matchedIds);
|
|
980
1016
|
|
|
1017
|
+
// Inherit parent route's loaders so parallel slots inside this layout
|
|
1018
|
+
// can access them via useLoader(). See resolveOrphanLayout in fresh.ts.
|
|
1019
|
+
if (
|
|
1020
|
+
parentRouteEntry &&
|
|
1021
|
+
parentRouteEntry.loader &&
|
|
1022
|
+
parentRouteEntry.loader.length > 0 &&
|
|
1023
|
+
Object.keys(orphan.parallel).length > 0
|
|
1024
|
+
) {
|
|
1025
|
+
const inheritedResult = await resolveLoadersWithRevalidation(
|
|
1026
|
+
parentRouteEntry,
|
|
1027
|
+
context,
|
|
1028
|
+
belongsToRoute,
|
|
1029
|
+
clientSegmentIds,
|
|
1030
|
+
prevParams,
|
|
1031
|
+
request,
|
|
1032
|
+
prevUrl,
|
|
1033
|
+
nextUrl,
|
|
1034
|
+
routeKey,
|
|
1035
|
+
deps,
|
|
1036
|
+
actionContext,
|
|
1037
|
+
orphan.shortCode,
|
|
1038
|
+
stale,
|
|
1039
|
+
);
|
|
1040
|
+
// Tag as inherited so buildMatchResult can deduplicate when safe
|
|
1041
|
+
for (const s of inheritedResult.segments) {
|
|
1042
|
+
s._inherited = true;
|
|
1043
|
+
}
|
|
1044
|
+
segments.push(...inheritedResult.segments);
|
|
1045
|
+
matchedIds.push(...inheritedResult.matchedIds);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
981
1048
|
// Handler-first: resolve orphan layout handler before its parallels
|
|
982
1049
|
// so ctx.set() values are visible to parallel children.
|
|
983
1050
|
matchedIds.push(orphan.shortCode);
|
|
@@ -1064,6 +1131,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
|
|
|
1064
1131
|
);
|
|
1065
1132
|
|
|
1066
1133
|
if (!resolvedParallelEntries.has(parallelEntry.id)) {
|
|
1134
|
+
// shortCodeOverride must match the parent layout, not the parallel entry.
|
|
1067
1135
|
const loaderResult = await resolveLoadersWithRevalidation(
|
|
1068
1136
|
parallelEntry,
|
|
1069
1137
|
context,
|
|
@@ -1076,7 +1144,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
|
|
|
1076
1144
|
routeKey,
|
|
1077
1145
|
deps,
|
|
1078
1146
|
actionContext,
|
|
1079
|
-
|
|
1147
|
+
orphan.shortCode,
|
|
1080
1148
|
stale,
|
|
1081
1149
|
);
|
|
1082
1150
|
segments.push(...loaderResult.segments);
|
package/src/rsc/handler.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
setRequestContextParams,
|
|
16
16
|
requireRequestContext,
|
|
17
17
|
getRequestContext,
|
|
18
|
+
_getRequestContext,
|
|
18
19
|
createRequestContext,
|
|
19
20
|
} from "../server/request-context.js";
|
|
20
21
|
import * as rscDeps from "@vitejs/plugin-rsc/rsc";
|
|
@@ -166,10 +167,13 @@ export function createRSCHandler<
|
|
|
166
167
|
phase: ErrorPhase,
|
|
167
168
|
context: Parameters<typeof invokeOnError<TEnv>>[3],
|
|
168
169
|
): void {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
170
|
+
// Guard: abort signal handlers fire asynchronously outside the ALS
|
|
171
|
+
// request scope, so the context may be gone. Skip dedup in that
|
|
172
|
+
// case — the error is from a cancelled stream, not a real failure.
|
|
173
|
+
const reqCtx = _getRequestContext();
|
|
174
|
+
if (error != null && typeof error === "object" && reqCtx) {
|
|
175
|
+
if (reqCtx._reportedErrors.has(error)) return;
|
|
176
|
+
reqCtx._reportedErrors.add(error);
|
|
173
177
|
}
|
|
174
178
|
invokeOnError(router.onError, error, phase, context, "RSC");
|
|
175
179
|
}
|
|
@@ -1090,7 +1094,11 @@ export function createRSCHandler<
|
|
|
1090
1094
|
},
|
|
1091
1095
|
};
|
|
1092
1096
|
|
|
1093
|
-
const rscStream = renderToReadableStream(payload
|
|
1097
|
+
const rscStream = renderToReadableStream(payload, {
|
|
1098
|
+
onError: (error: unknown) => {
|
|
1099
|
+
callOnError(error, "rendering", { request, url, env });
|
|
1100
|
+
},
|
|
1101
|
+
});
|
|
1094
1102
|
|
|
1095
1103
|
const isRscRequest =
|
|
1096
1104
|
isPartial ||
|
package/src/rsc/loader-fetch.ts
CHANGED
|
@@ -168,8 +168,19 @@ export async function handleLoaderFetch<TEnv>(
|
|
|
168
168
|
loaderResult: unknown;
|
|
169
169
|
}
|
|
170
170
|
const loaderPayload: LoaderPayload = { loaderResult: result };
|
|
171
|
-
const rscStream =
|
|
172
|
-
|
|
171
|
+
const rscStream = ctx.renderToReadableStream<LoaderPayload>(
|
|
172
|
+
loaderPayload,
|
|
173
|
+
{
|
|
174
|
+
onError: (error: unknown) => {
|
|
175
|
+
ctx.callOnError(error, "rendering", {
|
|
176
|
+
request,
|
|
177
|
+
url,
|
|
178
|
+
env,
|
|
179
|
+
loaderName: loaderId,
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
);
|
|
173
184
|
|
|
174
185
|
return createResponseWithMergedHeaders(rscStream, {
|
|
175
186
|
headers: { "content-type": "text/x-component;charset=utf-8" },
|
|
@@ -199,7 +210,16 @@ export async function handleLoaderFetch<TEnv>(
|
|
|
199
210
|
name: err.name,
|
|
200
211
|
},
|
|
201
212
|
};
|
|
202
|
-
const rscStream = ctx.renderToReadableStream(errorPayload
|
|
213
|
+
const rscStream = ctx.renderToReadableStream(errorPayload, {
|
|
214
|
+
onError: (error: unknown) => {
|
|
215
|
+
ctx.callOnError(error, "rendering", {
|
|
216
|
+
request,
|
|
217
|
+
url,
|
|
218
|
+
env,
|
|
219
|
+
loaderName: loaderId,
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
});
|
|
203
223
|
|
|
204
224
|
return createResponseWithMergedHeaders(rscStream, {
|
|
205
225
|
status: 500,
|
|
@@ -259,7 +259,11 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
259
259
|
formState: actionResult,
|
|
260
260
|
};
|
|
261
261
|
|
|
262
|
-
const rscStream = ctx.renderToReadableStream<RscPayload>(payload
|
|
262
|
+
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
263
|
+
onError: (error: unknown) => {
|
|
264
|
+
ctx.callOnError(error, "rendering", { request, url, env });
|
|
265
|
+
},
|
|
266
|
+
});
|
|
263
267
|
// metricsStore=undefined is safe: the handler already stashed the early
|
|
264
268
|
// SSR setup promise on request variables, so getSSRSetup returns it
|
|
265
269
|
// without falling back to a fresh startSSRSetup.
|
|
@@ -360,7 +364,11 @@ async function renderPeErrorBoundary<TEnv>(
|
|
|
360
364
|
},
|
|
361
365
|
};
|
|
362
366
|
|
|
363
|
-
const rscStream = ctx.renderToReadableStream<RscPayload>(payload
|
|
367
|
+
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
368
|
+
onError: (error: unknown) => {
|
|
369
|
+
ctx.callOnError(error, "rendering", { request, url, env });
|
|
370
|
+
},
|
|
371
|
+
});
|
|
364
372
|
// metricsStore=undefined is safe: the handler already stashed the early
|
|
365
373
|
// SSR setup promise on request variables, so getSSRSetup returns it
|
|
366
374
|
// without falling back to a fresh startSSRSetup.
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -173,7 +173,11 @@ export async function handleRscRendering<TEnv>(
|
|
|
173
173
|
|
|
174
174
|
// Serialize to RSC stream
|
|
175
175
|
const rscSerializeStart = performance.now();
|
|
176
|
-
const rscStream = ctx.renderToReadableStream<RscPayload>(payload
|
|
176
|
+
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
177
|
+
onError: (error: unknown) => {
|
|
178
|
+
ctx.callOnError(error, "rendering", { request, url, env });
|
|
179
|
+
},
|
|
180
|
+
});
|
|
177
181
|
const rscSerializeDur = performance.now() - rscSerializeStart;
|
|
178
182
|
// This measures synchronous stream creation, not end-to-end stream consumption.
|
|
179
183
|
appendMetric(
|
package/src/rsc/server-action.ts
CHANGED
|
@@ -226,6 +226,9 @@ export async function executeServerAction<TEnv>(
|
|
|
226
226
|
|
|
227
227
|
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
228
228
|
temporaryReferences,
|
|
229
|
+
onError: (error: unknown) => {
|
|
230
|
+
ctx.callOnError(error, "rendering", { request, url, env });
|
|
231
|
+
},
|
|
229
232
|
});
|
|
230
233
|
|
|
231
234
|
return createResponseWithMergedHeaders(rscStream, {
|
|
@@ -332,6 +335,9 @@ export async function revalidateAfterAction<TEnv>(
|
|
|
332
335
|
const renderStart = performance.now();
|
|
333
336
|
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
334
337
|
temporaryReferences,
|
|
338
|
+
onError: (error: unknown) => {
|
|
339
|
+
ctx.callOnError(error, "rendering", { request, url, env });
|
|
340
|
+
},
|
|
335
341
|
});
|
|
336
342
|
const rscSerializeDur = performance.now() - renderStart;
|
|
337
343
|
// This measures synchronous stream creation, not end-to-end stream consumption.
|
package/src/rsc/types.ts
CHANGED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stable Promise wrappers keyed on the component itself. Objects (React
|
|
5
|
+
* elements, functions, lazy payloads) land in a WeakMap so entries GC when
|
|
6
|
+
* the underlying component is released; primitives (string, number, boolean,
|
|
7
|
+
* null) land in a Map so memoization still applies to text-/null-backed
|
|
8
|
+
* segments like those in partial-update flows. Keeping this cache outside
|
|
9
|
+
* the segment eliminates preservation fields on ResolvedSegment — it survives
|
|
10
|
+
* reconciliation naturally because the component ref is what's stable.
|
|
11
|
+
*
|
|
12
|
+
* Browser-only. On the server each SSR render needs a fresh pending promise
|
|
13
|
+
* so Suspense can emit the loading fallback HTML before content streams. A
|
|
14
|
+
* shared already-resolved promise has `.status === "fulfilled"` attached by
|
|
15
|
+
* React on its first observation — subsequent `use()` calls return
|
|
16
|
+
* synchronously without suspending, so the Suspense fallback never makes it
|
|
17
|
+
* into the initial HTML. Route-definition components share refs across
|
|
18
|
+
* requests, so a global cache would leak tracked state between renders.
|
|
19
|
+
*/
|
|
20
|
+
const IS_BROWSER = typeof window !== "undefined";
|
|
21
|
+
const objectContentCache = IS_BROWSER
|
|
22
|
+
? new WeakMap<object, Promise<ReactNode>>()
|
|
23
|
+
: null;
|
|
24
|
+
const primitiveContentCache = IS_BROWSER
|
|
25
|
+
? new Map<unknown, Promise<ReactNode>>()
|
|
26
|
+
: null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Return a stable Promise wrapping `component`, memoized on the component ref.
|
|
30
|
+
*
|
|
31
|
+
* A fresh `Promise.resolve(component)` each render would suspend for one
|
|
32
|
+
* microtask and briefly commit the loading fallback inside Suspender — the
|
|
33
|
+
* intercept / parallel-slot flicker this indirection prevents. Reusing the
|
|
34
|
+
* same Promise ref keeps React's `use()` in "known fulfilled" state after
|
|
35
|
+
* the first observation.
|
|
36
|
+
*
|
|
37
|
+
* @internal
|
|
38
|
+
*/
|
|
39
|
+
export function getMemoizedContentPromise(
|
|
40
|
+
component: ReactNode,
|
|
41
|
+
): Promise<ReactNode> {
|
|
42
|
+
if (component instanceof Promise) {
|
|
43
|
+
return component as Promise<ReactNode>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!objectContentCache || !primitiveContentCache) {
|
|
47
|
+
return Promise.resolve(component);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (component !== null && typeof component === "object") {
|
|
51
|
+
const cached = objectContentCache.get(component);
|
|
52
|
+
if (cached) {
|
|
53
|
+
return cached;
|
|
54
|
+
}
|
|
55
|
+
const promise = Promise.resolve(component);
|
|
56
|
+
objectContentCache.set(component, promise);
|
|
57
|
+
return promise;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const cached = primitiveContentCache.get(component);
|
|
61
|
+
if (cached) {
|
|
62
|
+
return cached;
|
|
63
|
+
}
|
|
64
|
+
const promise = Promise.resolve(component);
|
|
65
|
+
primitiveContentCache.set(component, promise);
|
|
66
|
+
return promise;
|
|
67
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { ResolvedSegment } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cache of aggregate Promise.all results keyed on the first loader's
|
|
5
|
+
* `loaderData` reference. Each entry holds the source refs it was built from
|
|
6
|
+
* plus the resulting Promise/array; lookup scans entries for the matching
|
|
7
|
+
* source array (typically a single entry, since distinct loader groups rarely
|
|
8
|
+
* share a first source). Object first-refs live in a WeakMap (auto-GC);
|
|
9
|
+
* primitive first-refs (strings/numbers/booleans/null) live in a Map so
|
|
10
|
+
* loaders that resolve to primitive data are memoized too — bounded in
|
|
11
|
+
* practice by the application's loader set.
|
|
12
|
+
*
|
|
13
|
+
* Keying externally means reconciliation's fresh segment objects no longer
|
|
14
|
+
* drop memoization — the cache survives as long as the underlying loader
|
|
15
|
+
* segments do, and GC collects entries when those loaders are released
|
|
16
|
+
* (object keys only).
|
|
17
|
+
*
|
|
18
|
+
* Browser-only. On the server each SSR render needs a fresh Promise so
|
|
19
|
+
* Suspense can actually suspend and emit the loading fallback HTML before
|
|
20
|
+
* content streams. A shared already-resolved promise has `.status` attached
|
|
21
|
+
* by React on first `use()`; subsequent observations return synchronously
|
|
22
|
+
* and skip the fallback. The zero-loader case is especially prone because
|
|
23
|
+
* every empty-loader site would otherwise share one promise across requests.
|
|
24
|
+
*/
|
|
25
|
+
const IS_BROWSER = typeof window !== "undefined";
|
|
26
|
+
|
|
27
|
+
interface LoaderCacheEntry {
|
|
28
|
+
sources: any[];
|
|
29
|
+
promise: Promise<any[]> | any[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const objectLoaderCache = IS_BROWSER
|
|
33
|
+
? new WeakMap<object, LoaderCacheEntry[]>()
|
|
34
|
+
: null;
|
|
35
|
+
const primitiveLoaderCache = IS_BROWSER
|
|
36
|
+
? new Map<unknown, LoaderCacheEntry[]>()
|
|
37
|
+
: null;
|
|
38
|
+
|
|
39
|
+
// In the browser, a single shared empty aggregate is safe (and desirable) —
|
|
40
|
+
// reusing the same resolved promise keeps React's `use()` in a known-fulfilled
|
|
41
|
+
// state across renders. On the server it would leak `.status = "fulfilled"`
|
|
42
|
+
// across requests and skip the Suspense fallback, so we rebuild on each call.
|
|
43
|
+
const SHARED_EMPTY_LOADER_PROMISE: Promise<any[]> | null = IS_BROWSER
|
|
44
|
+
? Promise.resolve([])
|
|
45
|
+
: null;
|
|
46
|
+
|
|
47
|
+
function hasSameReferences(a: any[], b: any[]): boolean {
|
|
48
|
+
if (a.length !== b.length) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
for (let i = 0; i < a.length; i++) {
|
|
52
|
+
if (a[i] !== b[i]) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildLoaderPromise(loaders: ResolvedSegment[]): Promise<any[]> {
|
|
60
|
+
if (loaders.length === 0) {
|
|
61
|
+
return Promise.resolve([]);
|
|
62
|
+
}
|
|
63
|
+
return Promise.all(
|
|
64
|
+
loaders.map((loader) =>
|
|
65
|
+
loader.loaderData instanceof Promise
|
|
66
|
+
? loader.loaderData
|
|
67
|
+
: Promise.resolve(loader.loaderData),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isObjectLike(value: unknown): value is object {
|
|
73
|
+
return (
|
|
74
|
+
value !== null && (typeof value === "object" || typeof value === "function")
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Memoize an aggregate Promise.all for a set of loader segments. Reusing the
|
|
80
|
+
* same aggregate across renders — invalidated only when any underlying
|
|
81
|
+
* loader.loaderData ref changes — keeps React's `use()` in "known fulfilled"
|
|
82
|
+
* state and prevents a fresh Promise.all from suspending (and briefly
|
|
83
|
+
* committing the Suspense fallback) on every partial update that doesn't
|
|
84
|
+
* actually change loader data.
|
|
85
|
+
*
|
|
86
|
+
* @internal
|
|
87
|
+
*/
|
|
88
|
+
export function getMemoizedLoaderPromise(
|
|
89
|
+
loaders: ResolvedSegment[],
|
|
90
|
+
): Promise<any[]> | any[] {
|
|
91
|
+
if (loaders.length === 0) {
|
|
92
|
+
return SHARED_EMPTY_LOADER_PROMISE ?? buildLoaderPromise(loaders);
|
|
93
|
+
}
|
|
94
|
+
if (!objectLoaderCache || !primitiveLoaderCache) {
|
|
95
|
+
return buildLoaderPromise(loaders);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const sources = loaders.map((loader) => loader.loaderData);
|
|
99
|
+
const first = sources[0];
|
|
100
|
+
const entries = isObjectLike(first)
|
|
101
|
+
? objectLoaderCache.get(first)
|
|
102
|
+
: primitiveLoaderCache.get(first);
|
|
103
|
+
|
|
104
|
+
if (entries) {
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
if (hasSameReferences(entry.sources, sources)) {
|
|
107
|
+
return entry.promise;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const promise = buildLoaderPromise(loaders);
|
|
113
|
+
const newEntry: LoaderCacheEntry = { sources, promise };
|
|
114
|
+
if (entries) {
|
|
115
|
+
entries.push(newEntry);
|
|
116
|
+
} else if (isObjectLike(first)) {
|
|
117
|
+
objectLoaderCache.set(first, [newEntry]);
|
|
118
|
+
} else {
|
|
119
|
+
primitiveLoaderCache.set(first, [newEntry]);
|
|
120
|
+
}
|
|
121
|
+
return promise;
|
|
122
|
+
}
|