@rangojs/router 0.5.2 → 0.6.0
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 +343 -125
- package/dist/types/browser/react/use-router.d.ts +10 -3
- package/dist/types/browser/react/use-search-params.d.ts +57 -10
- package/dist/types/browser/types.d.ts +22 -0
- package/dist/types/build/merge-full-manifests.d.ts +3 -0
- package/dist/types/build/route-trie.d.ts +4 -73
- package/dist/types/build/route-types/per-module-writer.d.ts +6 -4
- package/dist/types/build/route-types/router-processing.d.ts +2 -3
- package/dist/types/cache/cache-exec-scope.d.ts +31 -0
- package/dist/types/cache/taint.d.ts +12 -6
- package/dist/types/client-urls/client-root.d.ts +38 -0
- package/dist/types/client-urls/client-urls.d.ts +5 -0
- package/dist/types/client-urls/navigation.d.ts +38 -0
- package/dist/types/client-urls/revalidation-protocol.d.ts +25 -0
- package/dist/types/client-urls/server-projection.d.ts +62 -0
- package/dist/types/client-urls/types.d.ts +144 -0
- package/dist/types/client.d.ts +12 -4
- package/dist/types/client.rsc.d.ts +4 -1
- package/dist/types/decode-loader-results.d.ts +37 -0
- package/dist/types/errors.d.ts +1 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/loader-redirect.d.ts +27 -0
- package/dist/types/outlet-context.d.ts +12 -0
- package/dist/types/outlet-provider.d.ts +3 -1
- package/dist/types/redirect-origin.d.ts +4 -0
- package/dist/types/route-content-wrapper.d.ts +42 -1
- package/dist/types/route-definition/helpers-types.d.ts +13 -2
- package/dist/types/router/error-handling.d.ts +35 -1
- package/dist/types/router/intercept-resolution.d.ts +12 -0
- package/dist/types/router/loader-resolution.d.ts +24 -2
- package/dist/types/router/revalidation.d.ts +7 -0
- package/dist/types/router/route-trie-builder.d.ts +77 -0
- package/dist/types/router/router-interfaces.d.ts +20 -0
- package/dist/types/router/segment-resolution/helpers.d.ts +1 -1
- package/dist/types/router/trie-matching.d.ts +1 -1
- package/dist/types/rsc/manifest-init.d.ts +5 -5
- package/dist/types/rsc/shell-capture.d.ts +9 -0
- package/dist/types/rsc/shell-serve.d.ts +11 -0
- package/dist/types/rsc/types.d.ts +30 -0
- package/dist/types/segment-system.d.ts +2 -0
- package/dist/types/server/context.d.ts +10 -0
- package/dist/types/server/handle-store.d.ts +34 -3
- package/dist/types/server/request-context.d.ts +11 -1
- package/dist/types/server.d.ts +1 -0
- package/dist/types/ssr/index.d.ts +22 -0
- package/dist/types/ssr/ssr-root.d.ts +10 -0
- package/dist/types/testing/dom.entry.d.ts +1 -1
- package/dist/types/testing/render-route.d.ts +16 -6
- package/dist/types/testing/run-loader.d.ts +9 -0
- package/dist/types/types/boundaries.d.ts +22 -0
- package/dist/types/types/index.d.ts +1 -1
- package/dist/types/types/loader-types.d.ts +57 -5
- package/dist/types/types/segments.d.ts +7 -0
- package/dist/types/urls/path-helper-types.d.ts +10 -4
- package/dist/types/vite/discovery/client-urls-projection.d.ts +53 -0
- package/dist/types/vite/discovery/discover-routers.d.ts +1 -1
- package/dist/types/vite/discovery/state.d.ts +8 -1
- package/dist/vite/index.js +5313 -2365
- package/package.json +1 -1
- package/skills/breadcrumbs/SKILL.md +39 -9
- package/skills/catalog.json +7 -1
- package/skills/client-urls/SKILL.md +338 -0
- package/skills/comparison/references/framework-comparison.md +23 -9
- package/skills/hooks/SKILL.md +2 -2
- package/skills/hooks/data.md +11 -2
- package/skills/hooks/handle-and-actions.md +7 -0
- package/skills/hooks/outlets.md +26 -5
- package/skills/hooks/urls.md +40 -3
- package/skills/loader/SKILL.md +132 -20
- package/skills/migrate-nextjs/SKILL.md +70 -10
- package/skills/migrate-react-router/SKILL.md +49 -13
- package/skills/migrate-react-router/component-migration.md +18 -13
- package/skills/migrate-react-router/data-and-actions.md +14 -3
- package/skills/migrate-react-router/route-mapping.md +15 -2
- package/skills/parallel/SKILL.md +32 -1
- package/skills/ppr/SKILL.md +16 -6
- package/skills/prerender/SKILL.md +8 -4
- package/skills/rango/SKILL.md +21 -17
- package/skills/react-compiler/SKILL.md +3 -3
- package/skills/route/SKILL.md +5 -2
- package/skills/router-setup/SKILL.md +16 -2
- package/skills/scripts/SKILL.md +16 -6
- package/skills/shell-manifest/SKILL.md +16 -7
- package/skills/testing/SKILL.md +2 -2
- package/skills/testing/client-components.md +6 -0
- package/skills/testing/handles.md +30 -8
- package/skills/testing/loader.md +51 -49
- package/skills/testing/middleware.md +1 -1
- package/skills/theme/SKILL.md +8 -5
- package/src/bin/rango.ts +7 -3
- package/src/browser/navigation-bridge.ts +6 -0
- package/src/browser/navigation-client.ts +5 -0
- package/src/browser/partial-update.ts +65 -13
- package/src/browser/react/use-router.ts +40 -11
- package/src/browser/react/use-search-params.ts +140 -17
- package/src/browser/rsc-router.tsx +59 -0
- package/src/browser/server-action-bridge.ts +26 -0
- package/src/browser/types.ts +22 -0
- package/src/build/merge-full-manifests.ts +161 -0
- package/src/build/route-trie.ts +9 -332
- package/src/build/route-types/include-resolution.ts +66 -11
- package/src/build/route-types/per-module-writer.ts +11 -6
- package/src/build/route-types/router-processing.ts +184 -153
- package/src/build/runtime-discovery.ts +23 -12
- package/src/cache/cache-exec-scope.ts +47 -0
- package/src/cache/cache-runtime.ts +24 -25
- package/src/cache/taint.ts +28 -9
- package/src/client-urls/client-root.tsx +168 -0
- package/src/client-urls/client-urls.ts +698 -0
- package/src/client-urls/navigation.ts +237 -0
- package/src/client-urls/revalidation-protocol.ts +56 -0
- package/src/client-urls/server-projection.ts +579 -0
- package/src/client-urls/types.ts +195 -0
- package/src/client.rsc.tsx +12 -0
- package/src/client.tsx +49 -6
- package/src/decode-loader-results.ts +113 -0
- package/src/errors.ts +14 -0
- package/src/handles/deferred-resolution.ts +14 -7
- package/src/index.ts +1 -0
- package/src/loader-redirect.tsx +64 -0
- package/src/outlet-context.ts +12 -0
- package/src/outlet-provider.tsx +15 -1
- package/src/redirect-origin.ts +29 -0
- package/src/route-content-wrapper.tsx +96 -3
- package/src/route-definition/dsl-helpers.ts +28 -3
- package/src/route-definition/helpers-types.ts +13 -0
- package/src/route-definition/redirect.ts +17 -18
- package/src/router/error-handling.ts +65 -11
- package/src/router/intercept-resolution.ts +29 -0
- package/src/router/loader-resolution.ts +261 -28
- package/src/router/match-result.ts +7 -0
- package/src/router/revalidation.ts +24 -11
- package/src/router/route-trie-builder.ts +334 -0
- package/src/router/router-interfaces.ts +38 -0
- package/src/router/segment-resolution/fresh.ts +47 -0
- package/src/router/segment-resolution/helpers.ts +9 -11
- package/src/router/segment-resolution/loader-cache.ts +14 -24
- package/src/router/segment-resolution/revalidation.ts +20 -1
- package/src/router/trie-matching.ts +3 -3
- package/src/router.ts +46 -1
- package/src/rsc/full-payload.ts +6 -0
- package/src/rsc/handler.ts +10 -7
- package/src/rsc/loader-fetch.ts +2 -2
- package/src/rsc/manifest-init.ts +28 -9
- package/src/rsc/rsc-rendering.ts +15 -1
- package/src/rsc/shell-capture.ts +12 -0
- package/src/rsc/shell-serve.ts +15 -2
- package/src/rsc/ssr-setup.ts +10 -1
- package/src/rsc/types.ts +31 -2
- package/src/segment-system.tsx +83 -26
- package/src/server/context.ts +10 -0
- package/src/server/cookie-store.ts +19 -19
- package/src/server/handle-store.ts +185 -48
- package/src/server/request-context.ts +30 -6
- package/src/server.ts +7 -0
- package/src/ssr/index.tsx +37 -2
- package/src/ssr/ssr-root.tsx +29 -2
- package/src/testing/dom.entry.ts +1 -1
- package/src/testing/render-route.tsx +22 -8
- package/src/testing/run-loader.ts +51 -13
- package/src/types/boundaries.ts +19 -0
- package/src/types/index.ts +1 -0
- package/src/types/loader-types.ts +60 -5
- package/src/types/segments.ts +7 -0
- package/src/urls/include-helper.ts +22 -4
- package/src/urls/path-helper-types.ts +14 -1
- package/src/use-loader.tsx +67 -6
- package/src/vite/discovery/client-urls-projection.ts +322 -0
- package/src/vite/discovery/discover-routers.ts +43 -17
- package/src/vite/discovery/state.ts +11 -1
- package/src/vite/discovery/virtual-module-codegen.ts +20 -0
- package/src/vite/plugins/virtual-entries.ts +12 -3
- package/src/vite/router-discovery.ts +163 -12
|
@@ -19,18 +19,19 @@
|
|
|
19
19
|
* props crossing the RSC boundary), loader execution on the server,
|
|
20
20
|
* middleware, or handler ordering. Those are renderServerTree / renderHandler
|
|
21
21
|
* / e2e territory.
|
|
22
|
-
* - Loader data, location state,
|
|
23
|
-
* client context (see the `loaders` / `locationState` /
|
|
24
|
-
* nothing is executed on the server.
|
|
25
|
-
*
|
|
22
|
+
* - Loader data, location state, handle output, and outlet pending state are
|
|
23
|
+
* SEEDED directly into client context (see the `loaders` / `locationState` /
|
|
24
|
+
* `handles` / `outletPending` options) — nothing is executed on the server.
|
|
25
|
+
* This exercises the context read path, not the run path.
|
|
26
26
|
* - navigate() commits synchronously, so it does NOT drive the navigation
|
|
27
27
|
* lifecycle: useNavigation().state, useLinkStatus().pending, and
|
|
28
28
|
* useAction().state stay "idle". Assert pending/loading/submitting transition
|
|
29
29
|
* states with renderServerTree / e2e instead (navigate() warns once if used).
|
|
30
30
|
* What it DOES cover: client hooks that read NavigationProvider /
|
|
31
31
|
* OutletContext — useParams, useReverse, useHref, useMount, useNavigation,
|
|
32
|
-
* useRouter, usePathname, useSearchParams, Outlet nesting
|
|
33
|
-
*
|
|
32
|
+
* useRouter, usePathname, useSearchParams, Outlet/useOutlet nesting and seeded
|
|
33
|
+
* descendant pending state, useLoader/useFetchLoader (seeded data),
|
|
34
|
+
* useLocationState (seeded), and useHandle (seeded).
|
|
34
35
|
* Basename-mounted apps: pass the `basename` option so useRouter().basename,
|
|
35
36
|
* <Link> prefixing, and useMount/useHref resolve against the mount prefix
|
|
36
37
|
* (without it they resolve at the root "/"). For an include("/shop", ...)
|
|
@@ -149,6 +150,15 @@ export interface RenderRouteOptions {
|
|
|
149
150
|
* the read path is exercised without executing any loader.
|
|
150
151
|
*/
|
|
151
152
|
loaderData?: Record<string, unknown>;
|
|
153
|
+
/**
|
|
154
|
+
* Descendant client-route pending state to seed into each synthetic segment's
|
|
155
|
+
* production OutletProvider, so `useOutlet().pending` can be tested alongside
|
|
156
|
+
* `useOutlet().content`. Defaults to false.
|
|
157
|
+
*
|
|
158
|
+
* This is a seeded outlet-context value only. It does not model arbitrary
|
|
159
|
+
* Suspense, navigation, or action pending state.
|
|
160
|
+
*/
|
|
161
|
+
outletPending?: boolean;
|
|
152
162
|
/**
|
|
153
163
|
* Loaders to seed by REFERENCE — the robust way to test a component that calls
|
|
154
164
|
* `useLoader(loader)`. A real `createLoader()` handle has an empty `$$id` in a
|
|
@@ -560,7 +570,9 @@ export async function renderRoute(
|
|
|
560
570
|
const match = resolve(nextUrl.pathname);
|
|
561
571
|
const segments = buildSegments(routes, match.params, loaderData, mount);
|
|
562
572
|
const metadata = makeMetadata(nextUrl.pathname, segments, match.params);
|
|
563
|
-
const root = await renderSegments(segments
|
|
573
|
+
const root = await renderSegments(segments, {
|
|
574
|
+
outletPending: options.outletPending,
|
|
575
|
+
});
|
|
564
576
|
eventController.setLocation(nextUrl);
|
|
565
577
|
eventController.setParams(match.params);
|
|
566
578
|
store.setCurrentUrl(nextUrl.href);
|
|
@@ -595,7 +607,9 @@ export async function renderRoute(
|
|
|
595
607
|
...makeMetadata(url.pathname, initialSegments, initialMatch.params),
|
|
596
608
|
defaultPrefetch: options.defaultPrefetch,
|
|
597
609
|
};
|
|
598
|
-
const initialTree = await renderSegments(initialSegments
|
|
610
|
+
const initialTree = await renderSegments(initialSegments, {
|
|
611
|
+
outletPending: options.outletPending,
|
|
612
|
+
});
|
|
599
613
|
|
|
600
614
|
// Wrap render in an awaited async act so a tree that suspends (async loaders,
|
|
601
615
|
// loading states, deferred handle entries that arrive as a Promise) settles its
|
|
@@ -42,6 +42,7 @@ import { getFetchableLoader } from "../server/fetchable-loader-store.js";
|
|
|
42
42
|
import type { LoaderContext, LoaderDefinition } from "../types.js";
|
|
43
43
|
import type { ContextVar } from "../context-var.js";
|
|
44
44
|
import { isHandle, type Handle } from "../handle.js";
|
|
45
|
+
import { withDefer } from "../defer.js";
|
|
45
46
|
import { collectHandle } from "./collect-handle.js";
|
|
46
47
|
import type { ThemeConfig } from "../theme/types.js";
|
|
47
48
|
import type { SegmentCacheStore } from "../cache/types.js";
|
|
@@ -74,6 +75,9 @@ export type TestLoaderContext<TEnv = any> = Omit<
|
|
|
74
75
|
) => string;
|
|
75
76
|
get: {
|
|
76
77
|
<T>(contextVar: ContextVar<T>): T | undefined;
|
|
78
|
+
<TData, TAccumulated = TData[]>(
|
|
79
|
+
handle: Handle<TData, TAccumulated>,
|
|
80
|
+
): TAccumulated;
|
|
77
81
|
<T = unknown>(key: string): T | undefined;
|
|
78
82
|
};
|
|
79
83
|
};
|
|
@@ -248,6 +252,7 @@ function runWithLoaderContext<R>(
|
|
|
248
252
|
reqCtx: RequestContext<any>,
|
|
249
253
|
opts: RunLoaderOptions,
|
|
250
254
|
fn: (ctx: TestLoaderContext) => R,
|
|
255
|
+
pushRecorder?: Array<{ handle: Handle<any, any>; value: unknown }>,
|
|
251
256
|
): R {
|
|
252
257
|
const handleSeeds = new Map<unknown, unknown>(opts.handles ?? []);
|
|
253
258
|
const loaderSeeds = new Map<unknown, unknown>(opts.loaders ?? []);
|
|
@@ -275,17 +280,37 @@ function runWithLoaderContext<R>(
|
|
|
275
280
|
env: reqCtx.env,
|
|
276
281
|
waitUntil: reqCtx.waitUntil.bind(reqCtx),
|
|
277
282
|
executionContext: reqCtx.executionContext,
|
|
278
|
-
get:
|
|
283
|
+
get: ((keyOrVar: any) => {
|
|
284
|
+
// Handle READ (mirrors production's ctx.get(handle)): rendered-gated,
|
|
285
|
+
// seeded via the `handles` option.
|
|
286
|
+
if (isHandle(keyOrVar)) {
|
|
287
|
+
if (!renderedResolved) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`ctx.get(handle) in a loader requires "await ctx.rendered()" first. ` +
|
|
290
|
+
`Handle "${(keyOrVar as Handle<any, any>).$$id}" cannot be read until ` +
|
|
291
|
+
`the render tree has settled.`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (handleSeeds.has(keyOrVar)) return handleSeeds.get(keyOrVar);
|
|
295
|
+
return collectHandle(keyOrVar, []);
|
|
296
|
+
}
|
|
297
|
+
return (reqCtx.get as any)(keyOrVar);
|
|
298
|
+
}) as TestLoaderContext["get"],
|
|
279
299
|
use: ((dep: LoaderDefinition<any, any> | Handle<any, any>) => {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
300
|
+
// Handle WRITE (mirrors production's ctx.use(Meta)({...}) push): the
|
|
301
|
+
// same withDefer wrapper shape, recording into the result envelope's
|
|
302
|
+
// `handlePushes` so tests assert what the loader wrote. Deferred
|
|
303
|
+
// resolvers record their resolved value when called.
|
|
304
|
+
if (isHandle(dep)) {
|
|
305
|
+
const handleDef = dep as Handle<any, any>;
|
|
306
|
+
return withDefer((dataOrFn: unknown) => {
|
|
307
|
+
const value =
|
|
308
|
+
typeof dataOrFn === "function"
|
|
309
|
+
? (dataOrFn as () => unknown)()
|
|
310
|
+
: dataOrFn;
|
|
311
|
+
pushRecorder?.push({ handle: handleDef, value });
|
|
312
|
+
});
|
|
286
313
|
}
|
|
287
|
-
if (handleSeeds.has(dep)) return handleSeeds.get(dep);
|
|
288
|
-
if (isHandle(dep)) return collectHandle(dep, []);
|
|
289
314
|
// Production ctx.use(Loader) ALWAYS returns a Promise (the cached loader
|
|
290
315
|
// promise). The seeded path must match, so a consumer composing on the
|
|
291
316
|
// result (ctx.use(Dep).then(...), Promise.race, etc.) works the same as
|
|
@@ -313,7 +338,7 @@ function runWithLoaderContext<R>(
|
|
|
313
338
|
"requires the DSL render barrier, which only exists during a " +
|
|
314
339
|
"full route match. To unit-test a loader's post-barrier logic, " +
|
|
315
340
|
"pass { rendered: true } to mock the barrier and { handles: " +
|
|
316
|
-
"[[SomeHandle, accumulatedData]] } to seed ctx.
|
|
341
|
+
"[[SomeHandle, accumulatedData]] } to seed ctx.get(SomeHandle). " +
|
|
317
342
|
"For the real push/accumulate/barrier wiring, use an e2e test.",
|
|
318
343
|
);
|
|
319
344
|
},
|
|
@@ -361,6 +386,11 @@ export interface RunLoaderResult<T> {
|
|
|
361
386
|
locationState: Record<string, unknown>;
|
|
362
387
|
/** The resolved rango state cookie name seeded for the run (default `rango-state_router_0`). */
|
|
363
388
|
stateCookieName: string;
|
|
389
|
+
/**
|
|
390
|
+
* Handle writes the loader made via `ctx.use(SomeHandle)({...})`, in push
|
|
391
|
+
* order. A `.defer()` resolver's value is recorded when the resolver runs.
|
|
392
|
+
*/
|
|
393
|
+
handlePushes: Array<{ handle: Handle<any, any>; value: unknown }>;
|
|
364
394
|
}
|
|
365
395
|
|
|
366
396
|
export async function runLoaderResult<T>(
|
|
@@ -372,14 +402,22 @@ export async function runLoaderResult<T>(
|
|
|
372
402
|
buildLoaderCtxOpts(opts),
|
|
373
403
|
);
|
|
374
404
|
const reqCtx = ctx as RequestContext<any>;
|
|
405
|
+
const handlePushes: Array<{ handle: Handle<any, any>; value: unknown }> = [];
|
|
375
406
|
let result: T | undefined;
|
|
376
407
|
let thrown: unknown;
|
|
377
408
|
try {
|
|
378
|
-
result = await runWithLoaderContext(
|
|
379
|
-
|
|
409
|
+
result = await runWithLoaderContext(
|
|
410
|
+
reqCtx,
|
|
411
|
+
opts,
|
|
412
|
+
(loaderCtx) => Promise.resolve(loaderFn(loaderCtx)),
|
|
413
|
+
handlePushes,
|
|
380
414
|
);
|
|
381
415
|
} catch (error) {
|
|
382
416
|
thrown = error;
|
|
383
417
|
}
|
|
384
|
-
return {
|
|
418
|
+
return {
|
|
419
|
+
result,
|
|
420
|
+
handlePushes,
|
|
421
|
+
...buildRunSnapshot(reqCtx, thrown, stateCookieName),
|
|
422
|
+
};
|
|
385
423
|
}
|
package/src/types/boundaries.ts
CHANGED
|
@@ -74,6 +74,25 @@ export type LoaderDataResult<T = unknown> =
|
|
|
74
74
|
ok: false;
|
|
75
75
|
error: ErrorInfo;
|
|
76
76
|
fallback: ReactNode | null;
|
|
77
|
+
/**
|
|
78
|
+
* Loader threw notFound() (DataNotFoundError). `fallback` carries the
|
|
79
|
+
* SERVER-RENDERED not-found UI (nearest notFoundBoundary → router
|
|
80
|
+
* notFound option → default), so the client swaps to 404 presentation
|
|
81
|
+
* with zero extra round trips. Routed by decodeLoaderEntry via
|
|
82
|
+
* LOADER_NOT_FOUND_FALLBACK, not the error-fallback marker.
|
|
83
|
+
*/
|
|
84
|
+
notFound?: true;
|
|
85
|
+
/**
|
|
86
|
+
* Loader threw redirect(...) (a 3xx Response). `to` is resolved through
|
|
87
|
+
* the soft-redirect same-origin rules BEFORE leaving the server
|
|
88
|
+
* (resolveSoftRedirectUrl), so unsafe targets never reach the wire. The
|
|
89
|
+
* client navigates (replace) when the entry decodes. `state` is the
|
|
90
|
+
* resolved `redirect(url, { state })` record (`__rsc_ls_*` keys) — it
|
|
91
|
+
* travels on the marker because a streaming loader settles after
|
|
92
|
+
* payload metadata flushed; the redirect navigation merges it at the
|
|
93
|
+
* target entry.
|
|
94
|
+
*/
|
|
95
|
+
redirect?: { to: string; state?: Record<string, unknown> };
|
|
77
96
|
};
|
|
78
97
|
|
|
79
98
|
export function isLoaderDataResult(value: unknown): value is LoaderDataResult {
|
package/src/types/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ContextVar } from "../context-var.js";
|
|
2
2
|
import type { Handle } from "../handle.js";
|
|
3
|
+
import type { HandlePush } from "../defer.js";
|
|
3
4
|
import type { MiddlewareFn } from "../router/middleware.js";
|
|
4
5
|
import type { ScopedReverseFunction } from "../reverse.js";
|
|
5
6
|
import type { SearchSchema, ResolveSearchSchema } from "../search-params.js";
|
|
@@ -51,14 +52,42 @@ export type LoaderContext<
|
|
|
51
52
|
*/
|
|
52
53
|
routeParams: Record<string, string>;
|
|
53
54
|
search: {} extends TSearch ? {} : ResolveSearchSchema<TSearch>;
|
|
55
|
+
/**
|
|
56
|
+
* Read a context variable — or READ collected handle data after
|
|
57
|
+
* `await ctx.rendered()` (the rendered-barrier contract; handle reads
|
|
58
|
+
* moved here from ctx.use(handle), which is now the write).
|
|
59
|
+
*/
|
|
54
60
|
get: {
|
|
55
61
|
<T>(contextVar: ContextVar<T>): T | undefined;
|
|
62
|
+
<TData, TAccumulated = TData[]>(
|
|
63
|
+
handle: Handle<TData, TAccumulated>,
|
|
64
|
+
): TAccumulated;
|
|
56
65
|
} & (<K extends keyof DefaultVars>(key: K) => DefaultVars[K]);
|
|
57
66
|
/**
|
|
58
|
-
* Access another loader's data, or
|
|
67
|
+
* Access another loader's data, or WRITE handle data (meta, breadcrumbs, …)
|
|
68
|
+
* — handler parity: `ctx.use(Meta)({ title })` pushes exactly like it does
|
|
69
|
+
* in a handler. Handle READS live on `ctx.get(handle)` (after rendered()).
|
|
59
70
|
*
|
|
60
71
|
* For loaders: returns a promise (loaders run in parallel).
|
|
61
|
-
* For handles: returns
|
|
72
|
+
* For handles: returns the push function, legal for the whole body,
|
|
73
|
+
* streaming loaders included. Delivery is async by the race model: pushes
|
|
74
|
+
* that settle before the handler barrier ride the SSR handle snapshot;
|
|
75
|
+
* later ones stream to the client and apply post-hydration (document lane)
|
|
76
|
+
* or progressively (navigation/action lanes). To guarantee a loader's
|
|
77
|
+
* handles are in the SSR'd document, register it as
|
|
78
|
+
* `loader(Def, { stream: "navigation" })` so the document render awaits it
|
|
79
|
+
* (see {@link LoaderOptions}).
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```typescript
|
|
83
|
+
* export const ProductLoader = createLoader(async (ctx) => {
|
|
84
|
+
* "use server";
|
|
85
|
+
* const product = await getProduct(ctx.params.slug);
|
|
86
|
+
* ctx.use(Meta)({ title: product.name });
|
|
87
|
+
* ctx.use(Breadcrumbs)({ label: product.name });
|
|
88
|
+
* return product;
|
|
89
|
+
* });
|
|
90
|
+
* ```
|
|
62
91
|
*/
|
|
63
92
|
use: {
|
|
64
93
|
<T, TLoaderParams = any>(
|
|
@@ -66,13 +95,13 @@ export type LoaderContext<
|
|
|
66
95
|
): Promise<T>;
|
|
67
96
|
<TData, TAccumulated = TData[]>(
|
|
68
97
|
handle: Handle<TData, TAccumulated>,
|
|
69
|
-
):
|
|
98
|
+
): HandlePush<TData>;
|
|
70
99
|
};
|
|
71
100
|
/**
|
|
72
101
|
* **Experimental.** Wait for all non-loader segments to settle.
|
|
73
102
|
*
|
|
74
103
|
* After the returned promise resolves, handle data is available via
|
|
75
|
-
* `ctx.
|
|
104
|
+
* `ctx.get(handle)`. Supported in DSL loaders, including on streaming
|
|
76
105
|
* trees that use `loading()` — the barrier waits for the streaming
|
|
77
106
|
* handlers to finish pushing before it resolves. Throws if called from a
|
|
78
107
|
* handler-invoked loader, or if a handler is already awaiting this loader
|
|
@@ -84,7 +113,7 @@ export type LoaderContext<
|
|
|
84
113
|
* const PricesLoader = createLoader(async (ctx) => {
|
|
85
114
|
* "use server";
|
|
86
115
|
* await ctx.rendered();
|
|
87
|
-
* const products = ctx.
|
|
116
|
+
* const products = ctx.get(Products); // reads handle data
|
|
88
117
|
* return pricing.getLive(products.map(p => p.id));
|
|
89
118
|
* });
|
|
90
119
|
* ```
|
|
@@ -144,6 +173,32 @@ export type LoaderFn<
|
|
|
144
173
|
TEnv = DefaultEnv,
|
|
145
174
|
> = (ctx: LoaderContext<TParams, TEnv>) => Promise<T> | T;
|
|
146
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Delivery mode for a DSL-registered loader: `loader(Def, { stream })`.
|
|
178
|
+
*
|
|
179
|
+
* Default (omitted): the loader streams on every render. Its data, its
|
|
180
|
+
* `ctx.use(Handle)` pushes, and any `notFound()`/`redirect()` it throws may land
|
|
181
|
+
* AFTER the document Response is constructed, so none of them are guaranteed to
|
|
182
|
+
* be in the SSR'd HTML.
|
|
183
|
+
*
|
|
184
|
+
* `"navigation"` narrows streaming to client navigations only: on a DOCUMENT
|
|
185
|
+
* request the loader is awaited before first flush. `useLoader` still suspends,
|
|
186
|
+
* but on an already-settled promise, so no fallback paints. This is the
|
|
187
|
+
* SSR-completeness opt-in — the name is about WHERE streaming still applies, not
|
|
188
|
+
* about disabling it. Choose it when the loader feeds something that must exist
|
|
189
|
+
* in the document: `<head>` meta via a handle, or a real 404 status (an awaited
|
|
190
|
+
* `notFound()` deterministically precedes Response construction, where the
|
|
191
|
+
* streamed default only wins that race opportunistically). It does NOT change
|
|
192
|
+
* PPR capture behavior: capture renders mask loaders and skip this await.
|
|
193
|
+
*
|
|
194
|
+
* Scoped per LOADER, not per segment: a baked loader alongside a deliberately
|
|
195
|
+
* dynamic sibling awaits only itself, and the sibling keeps streaming behind its
|
|
196
|
+
* `loading()`/Suspense boundary.
|
|
197
|
+
*/
|
|
198
|
+
export type LoaderOptions = {
|
|
199
|
+
stream?: "navigation";
|
|
200
|
+
};
|
|
201
|
+
|
|
147
202
|
/**
|
|
148
203
|
* Options for fetchable loaders
|
|
149
204
|
*
|
package/src/types/segments.ts
CHANGED
|
@@ -258,6 +258,13 @@ export interface MatchResult {
|
|
|
258
258
|
* Slots are used for intercepting routes during soft navigation
|
|
259
259
|
*/
|
|
260
260
|
slots?: Record<string, SlotState>;
|
|
261
|
+
/**
|
|
262
|
+
* Intercept TARGET route names reachable when this location is a navigation
|
|
263
|
+
* origin (chain walk of the matched entry, when-conditionals included).
|
|
264
|
+
* Shipped in payload metadata so the browser-local clientUrls matcher can
|
|
265
|
+
* decline its optimistic presentation for targets an intercept would claim.
|
|
266
|
+
*/
|
|
267
|
+
interceptTargets?: string[];
|
|
261
268
|
/**
|
|
262
269
|
* Redirect URL for trailing slash normalization.
|
|
263
270
|
* When set, the RSC handler should return a 308 redirect to this URL
|
|
@@ -11,6 +11,12 @@ import {
|
|
|
11
11
|
import type { UrlPatterns, IncludeOptions } from "./pattern-types.js";
|
|
12
12
|
import type { IncludeProvider } from "./include-provider.js";
|
|
13
13
|
import type { IncludeFn } from "./path-helper-types.js";
|
|
14
|
+
import {
|
|
15
|
+
clientUrlIncludePatterns,
|
|
16
|
+
isClientUrlPatterns,
|
|
17
|
+
isClientUrlReference,
|
|
18
|
+
} from "../client-urls/server-projection.js";
|
|
19
|
+
import type { ClientUrlPatterns } from "../client-urls/types.js";
|
|
14
20
|
|
|
15
21
|
function hasExplicitNameOption(options: IncludeOptions | undefined): boolean {
|
|
16
22
|
return !!options && Object.prototype.hasOwnProperty.call(options, "name");
|
|
@@ -61,15 +67,27 @@ export function processItems(items: readonly AllUseItems[]): AllUseItems[] {
|
|
|
61
67
|
export function createIncludeHelper<TEnv>(): IncludeFn<TEnv> {
|
|
62
68
|
return (
|
|
63
69
|
prefix: string,
|
|
64
|
-
// A `urls()` value (eager)
|
|
70
|
+
// A `urls()` value (eager), an async provider thunk
|
|
65
71
|
// (`() => import("./routes")`) whose evaluation is deferred to the first
|
|
66
|
-
// request matching `prefix
|
|
67
|
-
//
|
|
68
|
-
|
|
72
|
+
// request matching `prefix`, or a clientUrls() definition (object or
|
|
73
|
+
// client reference). Providers are stored unevaluated and resolved by the
|
|
74
|
+
// runtime lazy-include expansion / build-time discovery.
|
|
75
|
+
patterns: UrlPatterns<TEnv> | IncludeProvider<TEnv> | ClientUrlPatterns,
|
|
69
76
|
options?: IncludeOptions,
|
|
70
77
|
): IncludeItem => {
|
|
71
78
|
const { ctx } = requireDslContext("include() must be called inside urls()");
|
|
72
79
|
|
|
80
|
+
// clientUrls() sources mount through include() like any urls() module.
|
|
81
|
+
// Detect them FIRST: a client REFERENCE is a callable proxy, so the
|
|
82
|
+
// downstream provider check (`typeof === "function"`) would otherwise
|
|
83
|
+
// invoke it as an async include thunk. The substituted handler defers
|
|
84
|
+
// materialization to evaluation time, when the discovery-installed
|
|
85
|
+
// projection is available; the include machinery then applies URL and
|
|
86
|
+
// route-name prefixes exactly as for server modules.
|
|
87
|
+
if (isClientUrlPatterns(patterns) || isClientUrlReference(patterns)) {
|
|
88
|
+
patterns = clientUrlIncludePatterns(patterns) as UrlPatterns<TEnv>;
|
|
89
|
+
}
|
|
90
|
+
|
|
73
91
|
const explicitName = options?.name;
|
|
74
92
|
const hasExplicitName = hasExplicitNameOption(options);
|
|
75
93
|
if (hasExplicitName && explicitName) {
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
Handler,
|
|
6
6
|
HandlerContext,
|
|
7
7
|
LoaderDefinition,
|
|
8
|
+
LoaderOptions,
|
|
8
9
|
MiddlewareFn,
|
|
9
10
|
NotFoundBoundaryHandler,
|
|
10
11
|
PartialCacheOptions,
|
|
@@ -57,6 +58,7 @@ import type {
|
|
|
57
58
|
IncludeOptions,
|
|
58
59
|
} from "./pattern-types.js";
|
|
59
60
|
import type { ExtractRoutes, ExtractResponses } from "./type-extraction.js";
|
|
61
|
+
import type { ClientUrlPatterns } from "../client-urls/types.js";
|
|
60
62
|
|
|
61
63
|
/**
|
|
62
64
|
* Base path function signature for defining routes with URL patterns.
|
|
@@ -175,6 +177,11 @@ export type IncludeArg<
|
|
|
175
177
|
TResponses extends Record<string, unknown>,
|
|
176
178
|
> =
|
|
177
179
|
| UrlPatterns<TEnv, TRoutes, TResponses>
|
|
180
|
+
// clientUrls() definitions mount through include() like any urls() module;
|
|
181
|
+
// on the server the runtime value is the module's client reference, but the
|
|
182
|
+
// TypeScript type of that default export IS ClientUrlPatterns, so route
|
|
183
|
+
// names flow into NamedRoutes through the same TRoutes inference.
|
|
184
|
+
| ClientUrlPatterns<TRoutes>
|
|
178
185
|
| (() =>
|
|
179
186
|
| IncludeResolved<TEnv, TRoutes, TResponses>
|
|
180
187
|
| Promise<IncludeResolved<TEnv, TRoutes, TResponses>>);
|
|
@@ -338,10 +345,16 @@ export type PathHelpers<TEnv> = {
|
|
|
338
345
|
revalidate: (fn: ShouldRevalidateFn<any, TEnv>) => RevalidateItem;
|
|
339
346
|
|
|
340
347
|
/**
|
|
341
|
-
* Attach a data loader to the current route/layout
|
|
348
|
+
* Attach a data loader to the current route/layout.
|
|
349
|
+
*
|
|
350
|
+
* Pass `{ stream: "navigation" }` to await this loader before first flush
|
|
351
|
+
* on DOCUMENT requests (see {@link LoaderOptions}) — the opt-in for loaders
|
|
352
|
+
* whose data, handle pushes, or thrown notFound()/redirect() must be in the
|
|
353
|
+
* SSR'd HTML. Per-loader: a dynamic sibling keeps streaming.
|
|
342
354
|
*/
|
|
343
355
|
loader: <TData>(
|
|
344
356
|
loaderDef: LoaderDefinition<TData>,
|
|
357
|
+
optionsOrUse?: LoaderOptions | (() => LoaderUseItem[]),
|
|
345
358
|
use?: () => LoaderUseItem[],
|
|
346
359
|
) => LoaderItem;
|
|
347
360
|
|
package/src/use-loader.tsx
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import {
|
|
4
4
|
isValidElement,
|
|
5
5
|
startTransition,
|
|
6
|
+
use,
|
|
6
7
|
useCallback,
|
|
7
8
|
useContext,
|
|
8
9
|
useEffect,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
} from "react";
|
|
14
15
|
import { OutletContext, type OutletContextValue } from "./outlet-context.js";
|
|
15
16
|
import { loaderStore, type LoaderEntry } from "./loader-store.js";
|
|
17
|
+
import { decodeLoaderEntry } from "./decode-loader-results.js";
|
|
16
18
|
import type { LoaderDefinition, LoadOptions } from "./types.js";
|
|
17
19
|
|
|
18
20
|
function isShareableGet(options: LoadOptions | undefined): boolean {
|
|
@@ -34,19 +36,47 @@ let privateGroupBucketSeq = 0;
|
|
|
34
36
|
|
|
35
37
|
const NOT_FOUND = Symbol("not-found");
|
|
36
38
|
|
|
39
|
+
/**
|
|
40
|
+
* SPIKE (streaming useLoader): lookup results distinguish a synchronously
|
|
41
|
+
* available value from a still-streaming per-loader promise. A pending stream
|
|
42
|
+
* is `use()`d at the read site (implicit suspension); the promise identity is
|
|
43
|
+
* stable per commit (it comes from the provider's memoized context value /
|
|
44
|
+
* element props), which use() requires across replays.
|
|
45
|
+
*/
|
|
46
|
+
type LoaderLookup =
|
|
47
|
+
| typeof NOT_FOUND
|
|
48
|
+
| { value: unknown }
|
|
49
|
+
| { stream: Promise<unknown> };
|
|
50
|
+
|
|
51
|
+
function lookupLoaderStreams(
|
|
52
|
+
loaderStreams: Record<string, unknown> | undefined,
|
|
53
|
+
loaderId: string,
|
|
54
|
+
): LoaderLookup {
|
|
55
|
+
if (!loaderStreams || !(loaderId in loaderStreams)) return NOT_FOUND;
|
|
56
|
+
const entry = loaderStreams[loaderId];
|
|
57
|
+
// Within the loaderStreams channel a Promise always means "still streaming";
|
|
58
|
+
// settled entries are undecoded result values, never promises.
|
|
59
|
+
return entry instanceof Promise
|
|
60
|
+
? { stream: entry }
|
|
61
|
+
: { value: decodeLoaderEntry(entry) };
|
|
62
|
+
}
|
|
63
|
+
|
|
37
64
|
function extractContentLoaderData(
|
|
38
65
|
node: ReactNode,
|
|
39
66
|
loaderId: string,
|
|
40
|
-
):
|
|
67
|
+
): LoaderLookup {
|
|
41
68
|
if (!isValidElement(node)) return NOT_FOUND;
|
|
42
69
|
const props = node.props as Record<string, any> | undefined;
|
|
43
70
|
if (!props) return NOT_FOUND;
|
|
44
71
|
|
|
45
72
|
// Direct OutletProvider with loaderData
|
|
46
73
|
if (props.loaderData && loaderId in props.loaderData) {
|
|
47
|
-
return props.loaderData[loaderId];
|
|
74
|
+
return { value: props.loaderData[loaderId] };
|
|
48
75
|
}
|
|
49
76
|
|
|
77
|
+
const streamed = lookupLoaderStreams(props.loaderStreams, loaderId);
|
|
78
|
+
if (streamed !== NOT_FOUND) return streamed;
|
|
79
|
+
|
|
50
80
|
if (
|
|
51
81
|
props.loaderIds &&
|
|
52
82
|
Array.isArray(props.loaderIds) &&
|
|
@@ -57,9 +87,9 @@ function extractContentLoaderData(
|
|
|
57
87
|
if (idx !== -1) {
|
|
58
88
|
const data = (props.loaderDataPromise as any[])[idx];
|
|
59
89
|
if (data && typeof data === "object" && "ok" in data) {
|
|
60
|
-
return data.ok ? data.data : NOT_FOUND;
|
|
90
|
+
return data.ok ? { value: data.data } : NOT_FOUND;
|
|
61
91
|
}
|
|
62
|
-
return data;
|
|
92
|
+
return { value: data };
|
|
63
93
|
}
|
|
64
94
|
}
|
|
65
95
|
|
|
@@ -102,9 +132,10 @@ function useLoaderInternal<T>(
|
|
|
102
132
|
): UseFetchLoaderResult<T> {
|
|
103
133
|
const context = useContext(OutletContext);
|
|
104
134
|
|
|
105
|
-
const
|
|
135
|
+
const walk = useMemo((): {
|
|
106
136
|
contextData: T | undefined;
|
|
107
137
|
hasContextData: boolean;
|
|
138
|
+
pendingStream?: Promise<unknown>;
|
|
108
139
|
} => {
|
|
109
140
|
let current: OutletContextValue | null | undefined = context;
|
|
110
141
|
while (current) {
|
|
@@ -114,18 +145,48 @@ function useLoaderInternal<T>(
|
|
|
114
145
|
hasContextData: true,
|
|
115
146
|
};
|
|
116
147
|
}
|
|
148
|
+
const streamed = lookupLoaderStreams(current.loaderStreams, loader.$$id);
|
|
149
|
+
if (streamed !== NOT_FOUND) {
|
|
150
|
+
if ("stream" in streamed) {
|
|
151
|
+
return {
|
|
152
|
+
contextData: undefined,
|
|
153
|
+
hasContextData: true,
|
|
154
|
+
pendingStream: streamed.stream,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return { contextData: streamed.value as T, hasContextData: true };
|
|
158
|
+
}
|
|
117
159
|
const contentData = extractContentLoaderData(
|
|
118
160
|
current.content,
|
|
119
161
|
loader.$$id,
|
|
120
162
|
);
|
|
121
163
|
if (contentData !== NOT_FOUND) {
|
|
122
|
-
|
|
164
|
+
if ("stream" in contentData) {
|
|
165
|
+
return {
|
|
166
|
+
contextData: undefined,
|
|
167
|
+
hasContextData: true,
|
|
168
|
+
pendingStream: contentData.stream,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return { contextData: contentData.value as T, hasContextData: true };
|
|
123
172
|
}
|
|
124
173
|
current = current.parent;
|
|
125
174
|
}
|
|
126
175
|
return { contextData: undefined, hasContextData: false };
|
|
127
176
|
}, [context, loader.$$id]);
|
|
128
177
|
|
|
178
|
+
// SPIKE (streaming useLoader): a pending per-loader stream suspends HERE —
|
|
179
|
+
// the implicit-suspense read. use() is exempt from hook-order rules, so the
|
|
180
|
+
// conditional call is legal; on replay after resolution it returns
|
|
181
|
+
// synchronously and the decoded value takes the contextData slot. Hooks
|
|
182
|
+
// below never run in a suspended render, so their order is stable across
|
|
183
|
+
// every COMPLETED render.
|
|
184
|
+
const { hasContextData } = walk;
|
|
185
|
+
let contextData = walk.contextData;
|
|
186
|
+
if (walk.pendingStream) {
|
|
187
|
+
contextData = decodeLoaderEntry(use(walk.pendingStream)) as T;
|
|
188
|
+
}
|
|
189
|
+
|
|
129
190
|
const loaderId = loader.$$id;
|
|
130
191
|
const key = options?.key;
|
|
131
192
|
const refreshGroupOption = options?.refreshGroup;
|