@rangojs/router 0.10.1 → 0.12.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/testing/vitest.js +1 -1
- package/dist/types/browser/partial-update.d.ts +1 -0
- package/dist/types/client-urls/navigation.d.ts +11 -0
- package/dist/types/client-urls/revalidate-chain.d.ts +33 -0
- package/dist/types/client-urls/types.d.ts +41 -14
- package/dist/types/client.d.ts +2 -0
- package/dist/types/deps/rsc-client.d.ts +1 -0
- package/dist/types/deps/rsc.d.ts +1 -1
- package/dist/types/deps/ssr.d.ts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.rsc.d.ts +1 -1
- package/dist/types/router/is-action.d.ts +34 -0
- package/dist/types/rsc/types.d.ts +9 -9
- package/dist/types/ssr/index.d.ts +27 -4
- package/dist/types/testing/flight.d.ts +4 -3
- package/dist/types/testing/index.d.ts +3 -1
- package/dist/types/testing/run-client-revalidate.d.ts +43 -0
- package/dist/types/testing/to-url.d.ts +2 -0
- package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +2 -0
- package/dist/types/testing/vitest.d.ts +2 -1
- package/dist/types/types/handler-context.d.ts +12 -5
- package/dist/types/types/index.d.ts +1 -1
- package/dist/types/vite/plugins/expose-action-id.d.ts +14 -0
- package/dist/types/vite/plugins/virtual-entries.d.ts +3 -2
- package/dist/vite/index.js +66 -13
- package/package.json +8 -3
- package/skills/client-urls/SKILL.md +26 -4
- package/skills/loader/SKILL.md +1 -0
- package/skills/testing/SKILL.md +1 -0
- package/skills/testing/setup.md +6 -6
- package/skills/typesafety/route-types.md +1 -0
- package/src/browser/partial-update.ts +11 -2
- package/src/browser/server-action-bridge.ts +9 -2
- package/src/cache/cache-runtime.ts +1 -1
- package/src/cache/segment-codec.ts +2 -2
- package/src/client-urls/navigation.ts +40 -42
- package/src/client-urls/revalidate-chain.ts +83 -0
- package/src/client-urls/types.ts +41 -13
- package/src/client.tsx +5 -0
- package/src/deps/rsc-client.ts +8 -0
- package/src/deps/rsc.ts +4 -2
- package/src/deps/ssr.ts +1 -0
- package/src/index.rsc.ts +1 -0
- package/src/index.ts +1 -0
- package/src/router/is-action.ts +100 -0
- package/src/router/revalidation.ts +5 -48
- package/src/rsc/handler.ts +3 -3
- package/src/rsc/server-action.ts +2 -4
- package/src/rsc/types.ts +9 -9
- package/src/ssr/index.tsx +132 -51
- package/src/testing/flight.ts +4 -3
- package/src/testing/index.ts +4 -1
- package/src/testing/run-client-revalidate.ts +108 -0
- package/src/testing/run-transition-when.ts +1 -3
- package/src/testing/to-url.ts +5 -0
- package/src/testing/vitest-stubs/plugin-rsc.ts +13 -5
- package/src/testing/vitest.ts +3 -2
- package/src/types/handler-context.ts +13 -5
- package/src/types/index.ts +1 -0
- package/src/vite/plugins/expose-action-id.ts +29 -1
- package/src/vite/plugins/use-cache-transform.ts +65 -1
- package/src/vite/plugins/virtual-entries.ts +14 -11
package/src/ssr/index.tsx
CHANGED
|
@@ -154,10 +154,20 @@ export interface SSRDependencies<TEnv = unknown> {
|
|
|
154
154
|
) => TransformStream<Uint8Array, Uint8Array>;
|
|
155
155
|
|
|
156
156
|
/**
|
|
157
|
-
* Function to load bootstrap script content
|
|
158
|
-
*
|
|
157
|
+
* Function to load bootstrap script content.
|
|
158
|
+
* Required unless `getClientEntryUrl` is provided with `headScripts: "preinit"`.
|
|
159
|
+
* Custom SSR entries typically: `() => import.meta.viteRsc.loadBootstrapScriptContent("index")`
|
|
160
|
+
* (deprecated in `@vitejs/plugin-rsc` 0.5.33 in favor of `getClientEntryUrl`).
|
|
159
161
|
*/
|
|
160
|
-
loadBootstrapScriptContent
|
|
162
|
+
loadBootstrapScriptContent?: () => Promise<string>;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Client entry URL from `@vitejs/plugin-rsc/ssr` `getClientEntryUrl()`.
|
|
166
|
+
* Preferred when `headScripts` is `"preinit"`: Fizz receives `bootstrapModules`
|
|
167
|
+
* without the deprecated `loadBootstrapScriptContent` round-trip. Custom SSR
|
|
168
|
+
* entries can omit this and keep the inline bootstrap path.
|
|
169
|
+
*/
|
|
170
|
+
getClientEntryUrl?: () => string;
|
|
161
171
|
|
|
162
172
|
/**
|
|
163
173
|
* Document script strategy; the generated virtual SSR entry threads the
|
|
@@ -438,36 +448,105 @@ interface ShellResumeOptions {
|
|
|
438
448
|
const BOOTSTRAP_IMPORT_ONLY_RE =
|
|
439
449
|
/^\s*import\(\s*(["'])([^"'\\]+)\1\s*\)\s*;?\s*$/;
|
|
440
450
|
|
|
451
|
+
const MISSING_BOOTSTRAP_MSG =
|
|
452
|
+
"[ssr] Missing bootstrap dependency: provide loadBootstrapScriptContent(), " +
|
|
453
|
+
'or getClientEntryUrl with headScripts: "preinit".';
|
|
454
|
+
|
|
441
455
|
/**
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
* reaches an opaque inline script that only reveals the URL once executed.
|
|
448
|
-
* Fizz stamps the request nonce on both tags (the inline form needed that
|
|
449
|
-
* too), and under PPR both land in the stored prelude; on resume React has
|
|
450
|
-
* already cleared the bootstrap fields from the postponed state, so nothing
|
|
451
|
-
* re-emits.
|
|
456
|
+
* Construction-time guard for {@link resolveBootstrap}: a handler whose deps
|
|
457
|
+
* can never produce a bootstrap must fail at startup, not 500 per request.
|
|
458
|
+
* getClientEntryUrl only counts under an explicit `headScripts: "preinit"` —
|
|
459
|
+
* any other headScripts keeps the inline path, so its presence alone is a
|
|
460
|
+
* misconfiguration worth flagging rather than silently ignoring.
|
|
452
461
|
*/
|
|
453
|
-
function
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
462
|
+
function assertBootstrapDeps(deps: SSRDependencies): void {
|
|
463
|
+
const preinit = deps.headScripts === "preinit";
|
|
464
|
+
if (deps.getClientEntryUrl && !preinit) {
|
|
465
|
+
console.warn(
|
|
466
|
+
'[ssr] getClientEntryUrl is ignored without headScripts: "preinit"; ' +
|
|
467
|
+
"the inline loadBootstrapScriptContent path is used instead.",
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
if (
|
|
471
|
+
!(preinit && deps.getClientEntryUrl) &&
|
|
472
|
+
!deps.loadBootstrapScriptContent
|
|
473
|
+
) {
|
|
474
|
+
throw new Error(MISSING_BOOTSTRAP_MSG);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
type BootstrapOptions = Pick<
|
|
457
479
|
RenderToReadableStreamOptions,
|
|
458
480
|
"bootstrapScriptContent" | "bootstrapModules"
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
481
|
+
>;
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Resolve Fizz's bootstrap options from the deps.
|
|
485
|
+
*
|
|
486
|
+
* Prefer bootstrapModules over the inline import() bootstrap: with
|
|
487
|
+
* `headScripts: "preinit"`, getClientEntryUrl() (sync — nothing to race)
|
|
488
|
+
* short-circuits to bootstrapModules, and inline content that is exactly
|
|
489
|
+
* `import("<entry-url>")` converts to the URL. React then emits a
|
|
490
|
+
* `<link rel="modulepreload" fetchpriority="low">` hint in the head plus the
|
|
491
|
+
* executing `<script type="module" src async>` at end of shell — the entry
|
|
492
|
+
* fetch starts with the first flushed bytes instead of when the parser reaches
|
|
493
|
+
* an opaque inline script that only reveals the URL once executed. Fizz stamps
|
|
494
|
+
* the request nonce on both tags, and under PPR both land in the stored
|
|
495
|
+
* prelude; on resume React has already cleared the bootstrap fields from the
|
|
496
|
+
* postponed state, so nothing re-emits. The conversion is an explicit opt-in:
|
|
497
|
+
* undefined headScripts (a custom SSR entry that predates the option, which
|
|
498
|
+
* also never installed the preinit hook) keeps the inline bootstrap
|
|
499
|
+
* byte-for-byte — converting by default would break CSPs that allowlist the
|
|
500
|
+
* known inline import() via a script hash.
|
|
501
|
+
*
|
|
502
|
+
* With `deadline` (shell capture), the inline load races it: a load that never
|
|
503
|
+
* resolves within the deadline is the same bounded no-shell degrade as a shell
|
|
504
|
+
* that never goes quiet — resolves `null`, the caller's degrade sentinel
|
|
505
|
+
* (disjoint from the load's string). A load that REJECTS is a genuine error
|
|
506
|
+
* and still propagates. The no-op catch keeps a late rejection off the
|
|
507
|
+
* unhandledRejection path when the deadline already won; a rejection that
|
|
508
|
+
* lands first still propagates out.
|
|
509
|
+
*/
|
|
510
|
+
async function resolveBootstrap(
|
|
511
|
+
deps: SSRDependencies,
|
|
512
|
+
): Promise<BootstrapOptions>;
|
|
513
|
+
async function resolveBootstrap(
|
|
514
|
+
deps: SSRDependencies,
|
|
515
|
+
deadline: Promise<void>,
|
|
516
|
+
): Promise<BootstrapOptions | null>;
|
|
517
|
+
async function resolveBootstrap(
|
|
518
|
+
deps: SSRDependencies,
|
|
519
|
+
deadline?: Promise<void>,
|
|
520
|
+
): Promise<BootstrapOptions | null> {
|
|
521
|
+
const preinit = deps.headScripts === "preinit";
|
|
522
|
+
if (preinit) {
|
|
523
|
+
// Truthy on purpose, and the ONLY predicate on the URL: an empty string is
|
|
524
|
+
// an unusable entry URL and falls through to the inline path.
|
|
525
|
+
const url = deps.getClientEntryUrl?.();
|
|
526
|
+
if (url) {
|
|
527
|
+
return { bootstrapModules: [url] };
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (!deps.loadBootstrapScriptContent) {
|
|
531
|
+
throw new Error(MISSING_BOOTSTRAP_MSG);
|
|
532
|
+
}
|
|
533
|
+
let content: string;
|
|
534
|
+
if (deadline) {
|
|
535
|
+
const load = deps.loadBootstrapScriptContent();
|
|
536
|
+
load.catch(() => {});
|
|
537
|
+
const raced = await Promise.race([load, deadline.then(() => null)]);
|
|
538
|
+
if (raced === null) return null;
|
|
539
|
+
content = raced;
|
|
540
|
+
} else {
|
|
541
|
+
content = await deps.loadBootstrapScriptContent();
|
|
542
|
+
}
|
|
543
|
+
if (preinit) {
|
|
544
|
+
const match = BOOTSTRAP_IMPORT_ONLY_RE.exec(content);
|
|
545
|
+
return match
|
|
546
|
+
? { bootstrapModules: [match[2]!] }
|
|
547
|
+
: { bootstrapScriptContent: content };
|
|
466
548
|
}
|
|
467
|
-
|
|
468
|
-
return match
|
|
469
|
-
? { bootstrapModules: [match[2]!] }
|
|
470
|
-
: { bootstrapScriptContent: content };
|
|
549
|
+
return { bootstrapScriptContent: content };
|
|
471
550
|
}
|
|
472
551
|
|
|
473
552
|
/**
|
|
@@ -476,7 +555,10 @@ function resolveBootstrapOptions(
|
|
|
476
555
|
* @example
|
|
477
556
|
* ```tsx
|
|
478
557
|
* import { createSSRHandler } from "@rangojs/router/ssr";
|
|
479
|
-
* import {
|
|
558
|
+
* import {
|
|
559
|
+
* createFromReadableStream,
|
|
560
|
+
* getClientEntryUrl,
|
|
561
|
+
* } from "@rangojs/router/internal/deps/ssr";
|
|
480
562
|
* import { renderToReadableStream } from "react-dom/server.edge";
|
|
481
563
|
* import { injectRSCPayload } from "@rangojs/router/internal/deps/html-stream-server";
|
|
482
564
|
*
|
|
@@ -484,6 +566,17 @@ function resolveBootstrapOptions(
|
|
|
484
566
|
* createFromReadableStream,
|
|
485
567
|
* renderToReadableStream,
|
|
486
568
|
* injectRSCPayload,
|
|
569
|
+
* getClientEntryUrl,
|
|
570
|
+
* headScripts: "preinit", // getClientEntryUrl is only used under "preinit"
|
|
571
|
+
* });
|
|
572
|
+
* ```
|
|
573
|
+
*
|
|
574
|
+
* Custom SSR entries that still use the deprecated bootstrap helper:
|
|
575
|
+
* ```tsx
|
|
576
|
+
* export const renderHTML = createSSRHandler({
|
|
577
|
+
* createFromReadableStream,
|
|
578
|
+
* renderToReadableStream,
|
|
579
|
+
* injectRSCPayload,
|
|
487
580
|
* loadBootstrapScriptContent: () =>
|
|
488
581
|
* import.meta.viteRsc.loadBootstrapScriptContent("index"),
|
|
489
582
|
* });
|
|
@@ -494,9 +587,9 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
|
|
|
494
587
|
createFromReadableStream,
|
|
495
588
|
renderToReadableStream,
|
|
496
589
|
injectRSCPayload,
|
|
497
|
-
loadBootstrapScriptContent,
|
|
498
590
|
onError,
|
|
499
591
|
} = deps;
|
|
592
|
+
assertBootstrapDeps(deps);
|
|
500
593
|
|
|
501
594
|
/**
|
|
502
595
|
* Render RSC stream to HTML stream
|
|
@@ -541,8 +634,7 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
|
|
|
541
634
|
origin,
|
|
542
635
|
});
|
|
543
636
|
|
|
544
|
-
|
|
545
|
-
const bootstrapScriptContent = await loadBootstrapScriptContent();
|
|
637
|
+
const bootstrap = await resolveBootstrap(deps);
|
|
546
638
|
|
|
547
639
|
// ssr:false auto-raise (see SSRDependencies.progressiveChunkSize).
|
|
548
640
|
// Awaiting the payload here is latency-neutral: fizz cannot emit even
|
|
@@ -563,7 +655,7 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
|
|
|
563
655
|
// isolate-global, the nonce per request).
|
|
564
656
|
const htmlStream = await runWithPreinitNonce(nonce, () =>
|
|
565
657
|
renderToReadableStream(<SsrRoot />, {
|
|
566
|
-
...
|
|
658
|
+
...bootstrap,
|
|
567
659
|
formState,
|
|
568
660
|
nonce,
|
|
569
661
|
...(progressiveChunkSize !== undefined && { progressiveChunkSize }),
|
|
@@ -600,8 +692,7 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
|
|
|
600
692
|
export function createShellCaptureHandler<TEnv = unknown>(
|
|
601
693
|
deps: SSRDependencies<TEnv>,
|
|
602
694
|
) {
|
|
603
|
-
const { createFromReadableStream,
|
|
604
|
-
deps;
|
|
695
|
+
const { createFromReadableStream, prerender } = deps;
|
|
605
696
|
const onError = deps.onError;
|
|
606
697
|
|
|
607
698
|
if (!prerender) {
|
|
@@ -610,6 +701,7 @@ export function createShellCaptureHandler<TEnv = unknown>(
|
|
|
610
701
|
"PPR shell capture requires the prerender export; wire it in the SSR virtual entry.",
|
|
611
702
|
);
|
|
612
703
|
}
|
|
704
|
+
assertBootstrapDeps(deps);
|
|
613
705
|
|
|
614
706
|
/**
|
|
615
707
|
* Prerender the shell and return the stored artifacts, or null when the
|
|
@@ -659,21 +751,10 @@ export function createShellCaptureHandler<TEnv = unknown>(
|
|
|
659
751
|
origin: opts.origin,
|
|
660
752
|
});
|
|
661
753
|
|
|
662
|
-
// Bootstrap
|
|
663
|
-
//
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
// the deadline sentinel — disjoint from the load's `Promise<string>`, so
|
|
667
|
-
// the race narrows to `string | null` with no wrapper. The no-op catch
|
|
668
|
-
// keeps a late rejection off the unhandledRejection path when the deadline
|
|
669
|
-
// already won; a rejection that lands first still propagates out.
|
|
670
|
-
const load = loadBootstrapScriptContent();
|
|
671
|
-
load.catch(() => {});
|
|
672
|
-
const bootstrapScriptContent = await Promise.race([
|
|
673
|
-
load,
|
|
674
|
-
deadline.promise.then(() => null),
|
|
675
|
-
]);
|
|
676
|
-
if (bootstrapScriptContent === null) {
|
|
754
|
+
// Bootstrap resolution raced against the deadline (see resolveBootstrap):
|
|
755
|
+
// null means the deadline won — the bounded no-shell degrade.
|
|
756
|
+
const bootstrap = await resolveBootstrap(deps, deadline.promise);
|
|
757
|
+
if (bootstrap === null) {
|
|
677
758
|
return null;
|
|
678
759
|
}
|
|
679
760
|
|
|
@@ -689,7 +770,7 @@ export function createShellCaptureHandler<TEnv = unknown>(
|
|
|
689
770
|
const abortReason = { rangoShellCaptureAbort: true };
|
|
690
771
|
const prerenderPromise = prerender(<SsrRoot />, {
|
|
691
772
|
signal: controller.signal,
|
|
692
|
-
...
|
|
773
|
+
...bootstrap,
|
|
693
774
|
// Explicit option only — the ssr:false auto-raise is live-SSR scoped
|
|
694
775
|
// (RangoBaseOptions.progressiveChunkSize documents the contract); the
|
|
695
776
|
// capture handler starts prerender without deserializing the payload,
|
package/src/testing/flight.ts
CHANGED
|
@@ -7,14 +7,15 @@
|
|
|
7
7
|
* the same react-server-dom serializer the router uses at runtime. It runs in
|
|
8
8
|
* plain node (no Vite, no browser), but ONLY under the `react-server` export
|
|
9
9
|
* condition. The serializer is the VENDORED build shipped with
|
|
10
|
-
* @vitejs/plugin-rsc — the public `@vitejs/plugin-rsc/rsc` entry
|
|
11
|
-
* imports Vite virtual modules and is not usable outside a Vite
|
|
10
|
+
* @vitejs/plugin-rsc — the public `@vitejs/plugin-rsc/rsc/server` entry
|
|
11
|
+
* top-level imports Vite virtual modules and is not usable outside a Vite
|
|
12
|
+
* build.
|
|
12
13
|
*
|
|
13
14
|
* Run the example/tests for this module via the dedicated rsc vitest project
|
|
14
15
|
* (vitest.rsc.config.ts), which forces `--conditions=react-server` on the
|
|
15
16
|
* worker. The main vitest project must NOT use that condition (it would flip
|
|
16
17
|
* React to the no-hooks server build and break the ~50 tests that mock
|
|
17
|
-
* @vitejs/plugin-rsc/rsc).
|
|
18
|
+
* @vitejs/plugin-rsc/rsc/server).
|
|
18
19
|
*
|
|
19
20
|
* Scope / limitations (v1):
|
|
20
21
|
* - Server-only / leaf trees. A tree containing a CLIENT component emits an
|
package/src/testing/index.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* condition and would throw if pulled into this barrel.
|
|
27
27
|
*
|
|
28
28
|
* Layers:
|
|
29
|
-
* - Unit: runMiddleware, runLoader
|
|
29
|
+
* - Unit: runMiddleware, runLoader, runClientRevalidate
|
|
30
30
|
* - Integration: dispatch (request -> Response)
|
|
31
31
|
* - Cross-cut: assertCacheStatus, assertShellStatus, assertGeneratedRoutesMatch
|
|
32
32
|
* - Component: see @rangojs/router/testing/dom (renderRoute)
|
|
@@ -53,6 +53,9 @@ export type {
|
|
|
53
53
|
RunTransitionWhenResult,
|
|
54
54
|
} from "./run-transition-when.js";
|
|
55
55
|
|
|
56
|
+
export { runClientRevalidate } from "./run-client-revalidate.js";
|
|
57
|
+
export type { RunClientRevalidateOptions } from "./run-client-revalidate.js";
|
|
58
|
+
|
|
56
59
|
export { dispatch } from "./dispatch.js";
|
|
57
60
|
export type { DispatchOptions } from "./dispatch.js";
|
|
58
61
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runClientRevalidate — unit-test clientUrls() revalidate() predicates.
|
|
3
|
+
*
|
|
4
|
+
* Builds the same {@link ClientRevalidateArgs} the browser collector passes
|
|
5
|
+
* and evaluates the predicate(s) through the SAME chain evaluator production
|
|
6
|
+
* uses (client-urls/revalidate-chain.ts) — locked default, boolean
|
|
7
|
+
* short-circuit, soft-verdict threading, and fail-open are the production
|
|
8
|
+
* code paths, not a re-implementation. Pass an array to test a chain.
|
|
9
|
+
*
|
|
10
|
+
* Synchronous: client revalidate functions must be sync.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { makeIsAction, resolveActionRefId } from "../router/is-action.js";
|
|
14
|
+
import {
|
|
15
|
+
lockedClientDefault,
|
|
16
|
+
runClientRevalidateChain,
|
|
17
|
+
} from "../client-urls/revalidate-chain.js";
|
|
18
|
+
import { toURL } from "./to-url.js";
|
|
19
|
+
import type {
|
|
20
|
+
ClientRevalidateArgs,
|
|
21
|
+
ClientRevalidateFn,
|
|
22
|
+
} from "../client-urls/types.js";
|
|
23
|
+
|
|
24
|
+
const DEFAULT_URL = "http://localhost/";
|
|
25
|
+
|
|
26
|
+
function resolveActionId(
|
|
27
|
+
action: ((...args: never[]) => unknown) | string | undefined,
|
|
28
|
+
): string | undefined {
|
|
29
|
+
if (action === undefined) return undefined;
|
|
30
|
+
if (typeof action === "string") return action;
|
|
31
|
+
const id = resolveActionRefId(action);
|
|
32
|
+
if (id === undefined) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"runClientRevalidate: `action` must be a single imported server action " +
|
|
35
|
+
"(carrying its build-injected id) or an actionId string. The passed " +
|
|
36
|
+
"function has no $id/$$id — outside a built app, pass the id string " +
|
|
37
|
+
'your predicate should match (e.g. "src/actions/cart.ts#addToCart").',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return id;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Options for {@link runClientRevalidate}. Defaults model a same-URL
|
|
45
|
+
* navigation with no action (locked default `false`).
|
|
46
|
+
*/
|
|
47
|
+
export interface RunClientRevalidateOptions {
|
|
48
|
+
currentUrl?: string | URL;
|
|
49
|
+
nextUrl?: string | URL;
|
|
50
|
+
currentParams?: Record<string, string>;
|
|
51
|
+
nextParams?: Record<string, string>;
|
|
52
|
+
stale?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* The triggering action: a single imported reference (id resolved via
|
|
55
|
+
* `$id ?? $$id`; throws if the function carries neither) or a raw actionId
|
|
56
|
+
* string. A namespace/object is rejected — it cannot identify the ONE
|
|
57
|
+
* action that triggered the request. Omit for a plain navigation.
|
|
58
|
+
*/
|
|
59
|
+
action?: ((...args: never[]) => unknown) | string;
|
|
60
|
+
/**
|
|
61
|
+
* Model an action-triggered refetch GET: predicates see `isAction()` as
|
|
62
|
+
* true, but the locked default stays the navigation default, matching how
|
|
63
|
+
* the server evaluates that request (no actionContext). Defaults to
|
|
64
|
+
* treating a provided `action` as the action POST itself.
|
|
65
|
+
*/
|
|
66
|
+
actionRequest?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Run one clientUrls `revalidate()` predicate — or a chain, in declaration
|
|
71
|
+
* order — against production-built args. Returns the final boolean decision
|
|
72
|
+
* (locked default if every predicate defers or throws).
|
|
73
|
+
*/
|
|
74
|
+
export function runClientRevalidate(
|
|
75
|
+
fn: ClientRevalidateFn | readonly ClientRevalidateFn[],
|
|
76
|
+
opts: RunClientRevalidateOptions = {},
|
|
77
|
+
): boolean {
|
|
78
|
+
const currentUrl = toURL(opts.currentUrl, new URL(DEFAULT_URL));
|
|
79
|
+
const nextUrl = toURL(opts.nextUrl, currentUrl);
|
|
80
|
+
const currentParams = opts.currentParams ?? {};
|
|
81
|
+
const nextParams = opts.nextParams ?? currentParams;
|
|
82
|
+
const inAction = opts.action !== undefined;
|
|
83
|
+
const actionId = resolveActionId(opts.action);
|
|
84
|
+
const defaultShouldRevalidate = lockedClientDefault({
|
|
85
|
+
actionRequest: opts.actionRequest ?? inAction,
|
|
86
|
+
currentParams,
|
|
87
|
+
nextParams,
|
|
88
|
+
currentUrl,
|
|
89
|
+
nextUrl,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const baseArgs: Omit<ClientRevalidateArgs, "defaultShouldRevalidate"> = {
|
|
93
|
+
currentUrl,
|
|
94
|
+
nextUrl,
|
|
95
|
+
currentParams,
|
|
96
|
+
nextParams,
|
|
97
|
+
stale: opts.stale ?? false,
|
|
98
|
+
isAction: makeIsAction(actionId, inAction),
|
|
99
|
+
...(actionId !== undefined ? { actionId } : {}),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return runClientRevalidateChain(
|
|
103
|
+
Array.isArray(fn) ? fn : [fn],
|
|
104
|
+
baseArgs,
|
|
105
|
+
defaultShouldRevalidate,
|
|
106
|
+
"runClientRevalidate predicate",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -35,9 +35,7 @@ import type { OnErrorCallback } from "../types/error-types.js";
|
|
|
35
35
|
import type { EntryData } from "../server/context.js";
|
|
36
36
|
import { evaluatePprTransitionWhen } from "../router/transition-when.js";
|
|
37
37
|
import { invokeOnError } from "../router/error-handling.js";
|
|
38
|
-
|
|
39
|
-
const toURL = (v: string | URL, base: URL): URL =>
|
|
40
|
-
typeof v === "string" ? new URL(v, base.origin) : v;
|
|
38
|
+
import { toURL } from "./to-url.js";
|
|
41
39
|
|
|
42
40
|
/**
|
|
43
41
|
* Options for runTransitionWhen. All navigation/action fields are optional and
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// Stub for `@vitejs/plugin-rsc/rsc
|
|
2
|
-
// per-file `vi.mock(...)`.
|
|
3
|
-
//
|
|
4
|
-
// plain node. The
|
|
5
|
-
//
|
|
1
|
+
// Stub for `@vitejs/plugin-rsc/rsc` and the split `/rsc/server`, `/rsc/client`
|
|
2
|
+
// entries, shipped so consumers do not have to write a per-file `vi.mock(...)`.
|
|
3
|
+
// Importing a router internal transitively pulls this module, whose real
|
|
4
|
+
// top-level body imports Vite virtuals that do not resolve in plain node. The
|
|
5
|
+
// unit/integration primitives (dispatch/runLoader/runMiddleware) never render
|
|
6
|
+
// RSC, so empty fns suffice.
|
|
6
7
|
export const createFromReadableStream = (): never => {
|
|
7
8
|
throw new Error("plugin-rsc stub: createFromReadableStream not available");
|
|
8
9
|
};
|
|
@@ -14,3 +15,10 @@ export const decodeReply = (): undefined => undefined;
|
|
|
14
15
|
export const decodeAction = (): undefined => undefined;
|
|
15
16
|
export const decodeFormState = (): undefined => undefined;
|
|
16
17
|
export const createTemporaryReferenceSet = (): Record<string, never> => ({});
|
|
18
|
+
export const encodeReply = (): never => {
|
|
19
|
+
throw new Error("plugin-rsc stub: encodeReply not available");
|
|
20
|
+
};
|
|
21
|
+
export const createClientTemporaryReferenceSet = (): Record<
|
|
22
|
+
string,
|
|
23
|
+
never
|
|
24
|
+
> => ({});
|
package/src/testing/vitest.ts
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* `@rangojs/router` specifier to its react-server entry (real impls) while
|
|
19
19
|
* leaving React as the client build — which is exactly what this helper does.
|
|
20
20
|
* - The build-only `@rangojs/router:version` virtual and `@vitejs/plugin-rsc/rsc`
|
|
21
|
-
* (whose real body imports unresolvable
|
|
21
|
+
* plus `/rsc/server`, `/rsc/client` (whose real body imports unresolvable
|
|
22
|
+
* Vite virtuals) are stubbed.
|
|
22
23
|
* - Cloudflare apps additionally import the `cloudflare:workers` /
|
|
23
24
|
* `cloudflare:email` runtime virtuals; pass `{ preset: "cloudflare" }` to stub them.
|
|
24
25
|
*
|
|
@@ -126,7 +127,7 @@ export function rangoTestAliases(
|
|
|
126
127
|
replacement: here("src/testing/vitest-stubs/version.ts"),
|
|
127
128
|
},
|
|
128
129
|
{
|
|
129
|
-
find: /^@vitejs\/plugin-rsc\/rsc
|
|
130
|
+
find: /^@vitejs\/plugin-rsc\/rsc(\/(server|client))?$/,
|
|
130
131
|
replacement: here("src/testing/vitest-stubs/plugin-rsc.ts"),
|
|
131
132
|
},
|
|
132
133
|
];
|
|
@@ -541,8 +541,10 @@ export type RevalidateParams<TParams = GenericParams, TEnv = any> = Parameters<
|
|
|
541
541
|
/**
|
|
542
542
|
* A reference to a server action, used by `isAction()` in a revalidate predicate.
|
|
543
543
|
*
|
|
544
|
-
* Either a directly imported action (`import { addToCart }`)
|
|
545
|
-
* import of an action module (`import * as CartActions`)
|
|
544
|
+
* Either a directly imported action (`import { addToCart }`), a namespace
|
|
545
|
+
* import of an action module (`import * as CartActions`), an object
|
|
546
|
+
* literal of actions (`{ addToCart, removeFromCart }`), or a grouped
|
|
547
|
+
* namespace (`{ Cart: CartActions, Order: OrderActions }`). Matching resolves the
|
|
546
548
|
* action's build-injected id (`path#export`) — the same identity the router uses
|
|
547
549
|
* for `actionId` — so a renamed or moved action breaks at compile time instead
|
|
548
550
|
* of silently failing to match.
|
|
@@ -551,6 +553,9 @@ export type ActionRef =
|
|
|
551
553
|
| ((...args: never[]) => unknown)
|
|
552
554
|
| Record<string, unknown>;
|
|
553
555
|
|
|
556
|
+
/** The `isAction()` matcher passed to server and client `revalidate()` predicates. */
|
|
557
|
+
export type IsActionFn = (...actions: ActionRef[]) => boolean;
|
|
558
|
+
|
|
554
559
|
/**
|
|
555
560
|
* Revalidation function called during client-side navigation to decide whether
|
|
556
561
|
* a segment (layout, route, parallel slot, or loader) should be re-rendered.
|
|
@@ -643,8 +648,10 @@ export type ShouldRevalidateFn<TParams = GenericParams, TEnv = any> = (args: {
|
|
|
643
648
|
/**
|
|
644
649
|
* Typed, rename-safe action matching. Returns `true` when the action that
|
|
645
650
|
* triggered this revalidation is one of the given references — or, for a
|
|
646
|
-
* namespace import (`import * as CartActions`),
|
|
647
|
-
*
|
|
651
|
+
* namespace import (`import * as CartActions`), object literal
|
|
652
|
+
* (`{ addToCart, removeFromCart }`), or grouped namespaces
|
|
653
|
+
* (`{ Cart: CartActions }`), any of those exports — and `false`
|
|
654
|
+
* otherwise (including plain navigation with no action).
|
|
648
655
|
*
|
|
649
656
|
* Called with NO arguments it answers "is this request an action at all?":
|
|
650
657
|
* `true` for any action, `false` on plain navigation. Use the bare form when
|
|
@@ -668,9 +675,10 @@ export type ShouldRevalidateFn<TParams = GenericParams, TEnv = any> = (args: {
|
|
|
668
675
|
* revalidate((ctx) => ctx.isAction(addToCart) || undefined); // one action
|
|
669
676
|
* revalidate((ctx) => ctx.isAction(addToCart, removeFromCart) || undefined); // several
|
|
670
677
|
* revalidate((ctx) => ctx.isAction(CartActions) || undefined); // any in the module
|
|
678
|
+
* revalidate((ctx) => ctx.isAction({ addToCart, removeFromCart }) || undefined); // object form
|
|
671
679
|
* ```
|
|
672
680
|
*/
|
|
673
|
-
isAction:
|
|
681
|
+
isAction: IsActionFn;
|
|
674
682
|
/** URL where the action was executed (the page the user was on when they triggered the action). */
|
|
675
683
|
actionUrl?: URL;
|
|
676
684
|
/** Return value from the action execution. Can be used to conditionally revalidate based on the action's outcome. */
|
package/src/types/index.ts
CHANGED
|
@@ -92,6 +92,27 @@ function isUseServerModule(filePath: string): boolean {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Per-reference own `bind` injected next to `$$id`. React's client.browser
|
|
97
|
+
* build carries no server-reference metadata across `.bind()` (the
|
|
98
|
+
* edge/node/server builds install an own `bind` on each reference that
|
|
99
|
+
* does), so `isAction(boundStub)` would silently miss in the browser only.
|
|
100
|
+
* Installing the same per-reference own `bind` here — scoped to the stubs
|
|
101
|
+
* this plugin already wraps, guarded to never override an existing own
|
|
102
|
+
* `bind` — closes that without mutating the global Function.prototype
|
|
103
|
+
* (which would re-wrap once per Vite environment/HMR pass and break
|
|
104
|
+
* native-function detection for co-loaded code). The helper re-installs
|
|
105
|
+
* itself on the bound result so chained binds keep the metadata too.
|
|
106
|
+
*/
|
|
107
|
+
export const ACTION_BIND_HELPER_NAME: string = "__rangoActionBind";
|
|
108
|
+
export const ACTION_BIND_HELPER_SOURCE: string = `var ${ACTION_BIND_HELPER_NAME} = function () {
|
|
109
|
+
var bound = Function.prototype.bind.apply(this, arguments);
|
|
110
|
+
if (typeof this.$id === "string") bound.$id = this.$id;
|
|
111
|
+
if (typeof this.$$id === "string") bound.$$id = this.$$id;
|
|
112
|
+
bound.bind = ${ACTION_BIND_HELPER_NAME};
|
|
113
|
+
return bound;
|
|
114
|
+
};`;
|
|
115
|
+
|
|
95
116
|
function applyServerReferenceWrapping(
|
|
96
117
|
code: string,
|
|
97
118
|
s: MagicString,
|
|
@@ -126,10 +147,17 @@ function applyServerReferenceWrapping(
|
|
|
126
147
|
}
|
|
127
148
|
}
|
|
128
149
|
|
|
129
|
-
const replacement =
|
|
150
|
+
const replacement =
|
|
151
|
+
`(function(fn) { fn.$$id = ${finalIdArg}; ` +
|
|
152
|
+
`if (!Object.prototype.hasOwnProperty.call(fn, "bind")) fn.bind = ${ACTION_BIND_HELPER_NAME}; ` +
|
|
153
|
+
`return fn; })(${fnCall}(${idArg}${rest}))`;
|
|
130
154
|
s.overwrite(start, end, replacement);
|
|
131
155
|
}
|
|
132
156
|
|
|
157
|
+
if (hasChanges) {
|
|
158
|
+
s.prepend(`${ACTION_BIND_HELPER_SOURCE}\n`);
|
|
159
|
+
}
|
|
160
|
+
|
|
133
161
|
return hasChanges;
|
|
134
162
|
}
|
|
135
163
|
|