diff --git a/packages/rangojs-router/docs/design/ppr-shell-resume.md b/packages/rangojs-router/docs/design/ppr-shell-resume.md
index 11d6916f6..47b6b0cde 100644
--- a/packages/rangojs-router/docs/design/ppr-shell-resume.md
+++ b/packages/rangojs-router/docs/design/ppr-shell-resume.md
@@ -1420,7 +1420,10 @@ are not revived.
FIDELITY (middleware ctx value photographed into the prelude); the
middleware-run counter (capture never re-runs the chain); action correctness
(hole mutation stays HIT; updateTag drops + recaptures the shell; PE POST
- never composes). `(production)` describe-title bucketing rules apply.
+ never composes); inline closure-bound action streaming while an independent
+ page hole remains pending on document MISS, document HIT, and partial replay;
+ client-imported module actions from Passthrough+Prerender+ppr document and
+ partial paths. `(production)` describe-title bucketing rules apply.
- Semantic matrix rows `[PPR1]` (commit-after-all-middleware + capture never
re-runs the chain + scope fidelity) and `[PPR2]` (serve-time guarding: HIT
runs the full chain, loader hole fresh) must stay green, alongside the
diff --git a/packages/rangojs-router/docs/prerender-api-design.md b/packages/rangojs-router/docs/prerender-api-design.md
index 1af0ed5a2..b5e5f7e80 100644
--- a/packages/rangojs-router/docs/prerender-api-design.md
+++ b/packages/rangojs-router/docs/prerender-api-design.md
@@ -578,6 +578,24 @@ Actions do not re-render pre-rendered segments. The frozen handler output
stays. Loaders can be revalidated by actions. With `Passthrough()` routes and
`revalidate()`, the live handler can re-render.
+A client component can directly import and invoke a module-level action from a
+`Passthrough(Prerender(...), liveHandler) + ppr` page. If actions should leave
+the frozen build-time tree mounted, attach
+`revalidate(({ actionId }) => (actionId ? false : undefined))`: action
+revalidation is suppressed while ordinary navigations retain their default
+params-changed behavior. The action result, including a nested pending value,
+then streams into `useActionState` without replacing the client boundary with
+the Passthrough live handler. This opt-out is part of the streaming guarantee:
+default Passthrough action revalidation replaces the prerendered client boundary
+with the live handler, so local `useActionState` state from that boundary does
+not survive. The dev + production fixtures therefore pin the retained-tree path
+for both a producer-B document HIT and prerender-store partial navigation; they
+do not claim that a streamed action result survives default tree replacement.
+
+This does not extend to an inline closure-bound action embedded in the stored
+Flight payload. That path remains blocked by #584 / plugin-rsc #1246; runtime
+`ppr` covers encrypted bound actions separately.
+
### Handle Data
Values pushed via `ctx.use()` during pre-rendering are baked into the Flight
diff --git a/packages/rangojs-router/e2e/inline-bound-action.test.ts b/packages/rangojs-router/e2e/inline-bound-action.test.ts
index 3c513abf4..2f212e3b0 100644
--- a/packages/rangojs-router/e2e/inline-bound-action.test.ts
+++ b/packages/rangojs-router/e2e/inline-bound-action.test.ts
@@ -7,6 +7,8 @@ import {
expectNoReload,
testId,
} from "./helper";
+import { guardHydrationErrors, waitForShellHydration } from "@shared/e2e";
+import { assertPprReplayStatus } from "@rangojs/router/testing/e2e";
// Case (b) coverage: an inline `"use server"` action DEFINED INSIDE a server
// component that CLOSES OVER a render-scope value and is passed as a prop to a
@@ -23,73 +25,113 @@ import {
// partial-navigation replay must not inherit that external dependency.
const HTML_HEADERS = { Accept: "text/html" };
+// APIRequestContext buffers the body before returning. The fixture shortens
+// only warm-up holes so polling can observe HIT; browser holes stay action-gated.
+const WARM_HEADERS = {
+ ...HTML_HEADERS,
+ "x-rango-test-short-inline-hole": "1",
+};
async function warmToHit(request: Page["request"], url: string): Promise {
await expect(async () => {
- const response = await request.get(url, { headers: HTML_HEADERS });
+ const response = await request.get(url, { headers: WARM_HEADERS });
expect(response.status()).toBe(200);
expect(response.headers()["x-rango-shell"]).toBe("HIT");
- }).toPass({ timeout: 10_000 });
+ }).toPass({ timeout: 20_000 });
}
-async function expectBoundActionRoundTrip(page: Page): Promise {
- await expect(testId(page, "inline-bound-action-page")).toBeVisible();
- const rendered = await testId(
- page,
- "inline-bound-action-rendered-captured",
- ).textContent();
+async function expectBoundActionStreamsWhilePageHolePending(
+ page: Page,
+): Promise {
+ const roots = page.locator('[data-testid="inline-bound-action-page"]');
+ await expect(roots).toHaveCount(1);
+ const root = roots.first();
+ await expect(root).toBeVisible();
+ const rendered = await root
+ .locator('[data-testid="inline-bound-action-rendered-captured"]')
+ .textContent();
const capturedValue = rendered!.replace(/^rendered:/, "");
expect(capturedValue).toMatch(/^server-token-/);
- await expect(testId(page, "inline-bound-action-captured")).toHaveText(
- "captured:none",
- );
- await testId(page, "inline-bound-action-submit").click();
- await expect(testId(page, "inline-bound-action-captured")).toHaveText(
- `captured:${capturedValue}`,
- );
- await expect(testId(page, "inline-bound-action-submitted")).toHaveText(
- "submitted:from-client",
+ const pageFallback = root.locator(
+ '[data-testid="inline-bound-page-hole-fallback"]',
);
+ const submit = root.locator('[data-testid="inline-bound-action-submit"]');
+ await expect(pageFallback).toBeVisible();
+ await expect(
+ root.locator('[data-testid="inline-bound-action-captured"]'),
+ ).toHaveText("captured:none");
+
+ await submit.click();
+ await expect(submit).toHaveText("Processing...");
+ await expect(
+ root.locator('[data-testid="inline-bound-action-stream-fallback"]'),
+ ).toBeVisible();
+ await expect(pageFallback).toBeVisible();
+
+ await expect(
+ root.locator('[data-testid="inline-bound-action-stream-result"]'),
+ ).toHaveText(`completed:${capturedValue}:from-client`, { timeout: 5_000 });
+ await expect(
+ root.locator('[data-testid="inline-bound-action-captured"]'),
+ ).toHaveText(`captured:${capturedValue}`);
+ await expect(
+ root.locator('[data-testid="inline-bound-action-submitted"]'),
+ ).toHaveText("submitted:from-client");
+
+ // The action's own streamed result completes while the unrelated page hole
+ // is still pending.
+ await expect(pageFallback).toBeVisible();
+ await expect(
+ root.locator('[data-testid="inline-bound-page-hole-result"]'),
+ ).toHaveText("Page hole resolved", { timeout: 5_000 });
}
-function defineSpec(label: string, mode: "dev" | "build") {
+function defineSpec(mode: "dev" | "build") {
+ const label = mode === "build" ? "production" : "dev";
test.describe(`inline bound action (${label})`, () => {
const f = useFixture({
root: "./e2e/test-app",
mode,
});
- test("closure-captured render-scope value round-trips through the action", async ({
+ test("document MISS streams a bound action while a page hole is pending", async ({
page,
}) => {
using _ = expectNoPageError(page);
+ using __ = guardHydrationErrors(page);
+ const url = f.url(
+ `/inline-bound-action?probe=ppr-miss-action-stream-${crypto.randomUUID()}`,
+ );
- await page.goto(f.url("/inline-bound-action"));
- await waitForHydration(page);
- await using __ = await expectNoReload(page);
+ const response = await page.goto(url, { waitUntil: "commit" });
+ expect(response?.headers()["x-rango-shell"]).toBe("MISS");
+ await waitForShellHydration(page);
+ await using ___ = await expectNoReload(page);
- await expectBoundActionRoundTrip(page);
+ await expectBoundActionStreamsWhilePageHolePending(page);
});
- test("runtime shell HIT preserves an embedded bound action", async ({
+ test("document HIT streams a bound action while a page hole is pending", async ({
page,
}) => {
using _ = expectNoPageError(page);
- const url = f.url("/inline-bound-action?probe=ppr-hit");
+ using __ = guardHydrationErrors(page);
+ const url = f.url("/inline-bound-action?probe=ppr-hit-action-stream");
await warmToHit(page.request, url);
- const response = await page.goto(url);
+ const response = await page.goto(url, { waitUntil: "commit" });
expect(response?.headers()["x-rango-shell"]).toBe("HIT");
- await waitForHydration(page);
- await using __ = await expectNoReload(page);
- await expectBoundActionRoundTrip(page);
+ await waitForShellHydration(page);
+ await using ___ = await expectNoReload(page);
+ await expectBoundActionStreamsWhilePageHolePending(page);
});
- test("partial PPR navigation preserves an embedded bound action", async ({
+ test("partial PPR replay streams a bound action while a page hole is pending", async ({
page,
}) => {
using _ = expectNoPageError(page);
+ using __ = guardHydrationErrors(page);
await warmToHit(
page.request,
f.url("/inline-bound-action?probe=ppr-nav"),
@@ -97,13 +139,25 @@ function defineSpec(label: string, mode: "dev" | "build") {
await page.goto(f.url("/"));
await waitForHydration(page);
- await using __ = await expectNoReload(page);
+ await using ___ = await expectNoReload(page);
+ const partialResponsePromise = page.waitForResponse((response) => {
+ const url = new URL(response.url());
+ return (
+ url.pathname === "/inline-bound-action" &&
+ url.searchParams.has("_rsc_partial")
+ );
+ });
await testId(page, "nav-ppr-inline-action").click();
+ const partialResponse = await partialResponsePromise;
+ assertPprReplayStatus(
+ { headers: new Headers(partialResponse.headers()) },
+ { outcome: "HIT", freshness: "fresh" },
+ );
await expect(page).toHaveURL(/inline-bound-action\?probe=ppr-nav$/);
- await expectBoundActionRoundTrip(page);
+ await expectBoundActionStreamsWhilePageHolePending(page);
});
});
}
-defineSpec("dev", "dev");
-defineSpec("production", "build");
+defineSpec("dev");
+defineSpec("build");
diff --git a/packages/rangojs-router/e2e/prerender-ppr.test.ts b/packages/rangojs-router/e2e/prerender-ppr.test.ts
index bdd5ee3ff..336eaa3a8 100644
--- a/packages/rangojs-router/e2e/prerender-ppr.test.ts
+++ b/packages/rangojs-router/e2e/prerender-ppr.test.ts
@@ -1,5 +1,13 @@
import { expect, test, type Page } from "@playwright/test";
import { useFixture, type Fixture } from "./fixture";
+import {
+ expectNoPageError,
+ expectNoReload,
+ testId,
+ waitForHydration,
+} from "./helper";
+import { guardHydrationErrors } from "@shared/e2e";
+import { assertPprReplayStatus } from "@rangojs/router/testing/e2e";
// Prerender + ppr COMPOSITION (docs/design/shell-fast-path.md): one route
// carries both a build-time prerendered handler (trie pr:true) and the ppr
@@ -40,6 +48,64 @@ function readSeq(html: string): number {
}
function runPrerenderPprSpec(f: Fixture): void {
+ // These action assertions intentionally target the route's action-only
+ // revalidation opt-out. Default Passthrough revalidation replaces this client
+ // boundary with the live handler and discards its local useActionState result.
+ const expectPrerenderAction = async (page: Page): Promise => {
+ const submit = testId(page, "prerender-ppr-action-submit");
+ await expect(testId(page, "prerender-ppr-action-result")).toHaveCount(0);
+ await submit.click();
+ await expect(submit).toHaveText("Submitting...");
+ await expect(testId(page, "prerender-ppr-action-fallback")).toBeVisible();
+ await expect(testId(page, "ppp-source")).toHaveText("baked");
+ await expect(testId(page, "prerender-ppr-action-result")).toHaveText(
+ "prerender-ppr-action:from-client",
+ );
+ await expect(submit).toHaveText("Submit prerender action");
+ await expect(testId(page, "ppp-source")).toHaveText("baked");
+ };
+
+ test("Passthrough Prerender+ppr document HIT streams with action revalidation opted out", async ({
+ page,
+ }) => {
+ using _ = expectNoPageError(page);
+ using __ = guardHydrationErrors(page);
+ const url = f.url("/ppp/baked");
+ await warmToHit(page.request, url);
+
+ const response = await page.goto(url);
+ expect(response?.headers()["x-rango-shell"]).toBe("HIT");
+ await waitForHydration(page);
+ await using ___ = await expectNoReload(page);
+ await expect(testId(page, "ppp-source")).toHaveText("baked");
+ await expectPrerenderAction(page);
+ });
+
+ test("Prerender-store partial navigation streams a client-imported action", async ({
+ page,
+ }) => {
+ using _ = expectNoPageError(page);
+ using __ = guardHydrationErrors(page);
+ await page.goto(f.url("/"));
+ await waitForHydration(page);
+ await using ___ = await expectNoReload(page);
+ const partialResponsePromise = page.waitForResponse((response) => {
+ const url = new URL(response.url());
+ return (
+ url.pathname === "/ppp/baked" && url.searchParams.has("_rsc_partial")
+ );
+ });
+
+ await testId(page, "nav-prerender-ppr-action").click();
+ const partialResponse = await partialResponsePromise;
+ assertPprReplayStatus(
+ { headers: new Headers(partialResponse.headers()) },
+ { outcome: "BYPASS", reason: "prerender-store" },
+ );
+ await expect(testId(page, "ppp-source")).toHaveText("baked");
+ await expectPrerenderAction(page);
+ });
+
test("MISS serves the prerendered content live, then the route flips to HIT", async ({
request,
}) => {
@@ -157,6 +223,17 @@ function runPrerenderPprSpec(f: Fixture): void {
expect(liveBody).toContain("PPP content for live-one");
expect(liveBody).toContain("live");
expect(liveBody).toContain("ppp-exec-");
+
+ // The action-only revalidation opt-out must defer on navigation. A hard
+ // false here would incorrectly retain live-one for this same-route param
+ // change.
+ const nextLiveUrl = f.url("/ppp/live-two?probe=ppp-live-next");
+ const nextLive = await request.get(
+ `${nextLiveUrl}&_rsc_partial=true&_rsc_segments=`,
+ { headers: { "X-RSC-Router-Client-Path": liveUrl } },
+ );
+ expect(nextLive.status()).toBe(200);
+ expect(await nextLive.text()).toContain("PPP content for live-two");
});
// Fragment splice (issue #700) on the Prerender+ppr composition: the HIT
diff --git a/packages/rangojs-router/e2e/test-app/src/actions/shell-cache-action.ts b/packages/rangojs-router/e2e/test-app/src/actions/shell-cache-action.ts
index 919a746b4..4768c0c55 100644
--- a/packages/rangojs-router/e2e/test-app/src/actions/shell-cache-action.ts
+++ b/packages/rangojs-router/e2e/test-app/src/actions/shell-cache-action.ts
@@ -41,3 +41,20 @@ export async function updateBannerAction(
await updateTag(SHELL_ACTION_BANNER_TAG);
return { banner };
}
+
+export interface PrerenderPprActionResult {
+ streamed: Promise;
+}
+
+export async function submitPrerenderPprAction(
+ _previous: PrerenderPprActionResult | null,
+ formData: FormData,
+): Promise {
+ const value = String(formData.get("value"));
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ return {
+ streamed: new Promise((resolve) =>
+ setTimeout(() => resolve(`prerender-ppr-action:${value}`), 1_200),
+ ),
+ };
+}
diff --git a/packages/rangojs-router/e2e/test-app/src/components/InlineBoundActionForm.tsx b/packages/rangojs-router/e2e/test-app/src/components/InlineBoundActionForm.tsx
index 1a57a80d0..baf7170d9 100644
--- a/packages/rangojs-router/e2e/test-app/src/components/InlineBoundActionForm.tsx
+++ b/packages/rangojs-router/e2e/test-app/src/components/InlineBoundActionForm.tsx
@@ -1,50 +1,136 @@
"use client";
-import { useActionState } from "react";
+import { Suspense, use, useActionState } from "react";
+import { useLoader } from "@rangojs/router/client";
+import type { LoaderDefinition } from "@rangojs/router";
+import { submitPrerenderPprAction } from "../actions/shell-cache-action.js";
// State returned by the closure-capturing inline action. `captured` is the
// render-scope value the server component closed over (a bound argument that
// rides through encryptActionBoundArgs/decryptActionBoundArgs in production);
-// `submitted` echoes the form input so we can assert both round-trip.
-export type InlineBoundState = {
+// `streamed` stays pending after the action's top-level result arrives.
+export interface InlineBoundResult {
captured: string;
submitted: string;
-} | null;
+ streamed: Promise;
+}
+
+export type InlineBoundState = InlineBoundResult | null;
+
+export interface InlineBoundPageHoleData {
+ pendingData: Promise;
+}
+
+function PendingText({
+ promise,
+ testId,
+}: {
+ promise: Promise;
+ testId: string;
+}) {
+ return {use(promise)}
;
+}
export function InlineBoundActionForm({
boundAction,
+ pageHoleLoader,
}: {
boundAction: (
prev: InlineBoundState,
formData: FormData,
) => Promise;
+ pageHoleLoader: LoaderDefinition;
}) {
+ const { data: pageHole } = useLoader(pageHoleLoader);
const [state, formAction, isPending] = useActionState<
InlineBoundState,
FormData
>(boundAction, null);
return (
-
+ }
+ >
+
+
+ )}
);
}
diff --git a/packages/rangojs-router/e2e/test-app/src/components/layouts/RootLayout.tsx b/packages/rangojs-router/e2e/test-app/src/components/layouts/RootLayout.tsx
index 52ea0245d..e4105a562 100644
--- a/packages/rangojs-router/e2e/test-app/src/components/layouts/RootLayout.tsx
+++ b/packages/rangojs-router/e2e/test-app/src/components/layouts/RootLayout.tsx
@@ -69,6 +69,13 @@ export function RootLayout(ctx: any) {
>
PPR inline action
+
+ Prerender PPR action
+
diff --git a/packages/rangojs-router/e2e/test-app/src/urls/hooks.handlers.tsx b/packages/rangojs-router/e2e/test-app/src/urls/hooks.handlers.tsx
index f2add4f4a..9ae0aeabd 100644
--- a/packages/rangojs-router/e2e/test-app/src/urls/hooks.handlers.tsx
+++ b/packages/rangojs-router/e2e/test-app/src/urls/hooks.handlers.tsx
@@ -1,5 +1,5 @@
import type { Handler } from "@rangojs/router";
-import { cookies, Meta } from "@rangojs/router";
+import { cookies, createLoader, Meta } from "@rangojs/router";
import { Link } from "@rangojs/router/client";
import { PeHeaderProbeLoader } from "../loaders.js";
import {
@@ -15,7 +15,11 @@ import {
ComposingFetchableUsesNonFetchable,
} from "../loaders.js";
import { FetchLoaderTest } from "../components/FetchLoaderTest.js";
-import { InlineBoundActionForm } from "../components/InlineBoundActionForm.js";
+import {
+ InlineBoundActionForm,
+ type InlineBoundPageHoleData,
+ type InlineBoundResult,
+} from "../components/InlineBoundActionForm.js";
import {
UseLoaderTest,
UseFetchLoaderPreloadedTest,
@@ -265,21 +269,88 @@ export const InlineActionHandler: Handler<"inlineAction"> = () => {
);
};
-export const InlineBoundActionHandler: Handler<"inlineBoundAction"> = () => {
+const INLINE_BOUND_WARM_HOLE_DELAY_MS = 2_000;
+const INLINE_BOUND_PAGE_HOLE_FAILSAFE_MS = 30_000;
+const INLINE_BOUND_PAGE_HOLE_AFTER_ACTION_MS = 2_000;
+const INLINE_BOUND_ACTION_DELAY_MS = 1_000;
+const INLINE_BOUND_ACTION_STREAM_DELAY_MS = 1_200;
+
+// Probe-scoped resolvers make the ordering causal: the page hole cannot finish
+// until this page's action result has streamed. The long timer is only a leak
+// failsafe; API warm-up requests opt into the short timer via a test header.
+const inlineBoundPageHoleResolvers = new Map void>>();
+
+function createInlineBoundPageHole(
+ probe: string,
+ shortWarmup: boolean,
+): Promise {
+ return new Promise((resolve) => {
+ const resolvers = inlineBoundPageHoleResolvers.get(probe) ?? new Set();
+ inlineBoundPageHoleResolvers.set(probe, resolvers);
+ let timeout: ReturnType;
+ const finish = () => {
+ clearTimeout(timeout);
+ resolvers.delete(finish);
+ if (resolvers.size === 0) inlineBoundPageHoleResolvers.delete(probe);
+ resolve("Page hole resolved");
+ };
+ resolvers.add(finish);
+ timeout = setTimeout(
+ finish,
+ shortWarmup
+ ? INLINE_BOUND_WARM_HOLE_DELAY_MS
+ : INLINE_BOUND_PAGE_HOLE_FAILSAFE_MS,
+ );
+ });
+}
+
+function resolveInlineBoundPageHoleAfterAction(probe: string): void {
+ setTimeout(() => {
+ for (const resolve of [
+ ...(inlineBoundPageHoleResolvers.get(probe) ?? []),
+ ]) {
+ resolve();
+ }
+ }, INLINE_BOUND_PAGE_HOLE_AFTER_ACTION_MS);
+}
+
+export const InlineBoundPageHoleLoader = createLoader(
+ async (ctx): Promise => ({
+ pendingData: createInlineBoundPageHole(
+ ctx.searchParams.get("probe") ?? "default",
+ ctx.request.headers.has("x-rango-test-short-inline-hole"),
+ ),
+ }),
+);
+
+export const InlineBoundActionHandler: Handler<"inlineBoundAction"> = (ctx) => {
// Render-scope value computed on the server. The inline action below closes
// over it, so plugin-rsc treats it as a bound argument (encrypted in
// production via encryptActionBoundArgs / decrypted via
// decryptActionBoundArgs). The client can never see or reconstruct this
// value, so a correct round-trip proves bound-arg serialization works.
const captured = `server-token-${Date.now().toString(36)}`;
+ const probe = ctx.searchParams.get("probe") ?? "default";
async function inlineBoundAction(
_prev: { captured: string; submitted: string } | null,
formData: FormData,
- ): Promise<{ captured: string; submitted: string }> {
+ ): Promise {
"use server";
const submitted = String(formData.get("submitted") ?? "");
- return { captured, submitted };
+ await new Promise((resolve) =>
+ setTimeout(resolve, INLINE_BOUND_ACTION_DELAY_MS),
+ );
+ return {
+ captured,
+ submitted,
+ streamed: new Promise((resolve) =>
+ setTimeout(() => {
+ resolve(`completed:${captured}:${submitted}`);
+ resolveInlineBoundPageHoleAfterAction(probe);
+ }, INLINE_BOUND_ACTION_STREAM_DELAY_MS),
+ ),
+ };
}
return (
@@ -291,7 +362,10 @@ export const InlineBoundActionHandler: Handler<"inlineBoundAction"> = () => {
rendered:{captured}
-
+
);
};
diff --git a/packages/rangojs-router/e2e/test-app/src/urls/hooks.tsx b/packages/rangojs-router/e2e/test-app/src/urls/hooks.tsx
index 21a76f512..ff9fa3a17 100644
--- a/packages/rangojs-router/e2e/test-app/src/urls/hooks.tsx
+++ b/packages/rangojs-router/e2e/test-app/src/urls/hooks.tsx
@@ -14,6 +14,7 @@ import {
LoaderCompositionHandler,
InlineActionHandler,
InlineBoundActionHandler,
+ InlineBoundPageHoleLoader,
ProgressiveEnhancementHandler,
ParityCounterHandler,
PeRedirectHandler,
@@ -63,10 +64,15 @@ export const hooksPatterns = urls(({ path, loader }) => [
name: "loaderComposition",
}),
path("/inline-action", InlineActionHandler, { name: "inlineAction" }),
- path("/inline-bound-action", InlineBoundActionHandler, {
- name: "inlineBoundAction",
- ppr: true,
- }),
+ path(
+ "/inline-bound-action",
+ InlineBoundActionHandler,
+ {
+ name: "inlineBoundAction",
+ ppr: true,
+ },
+ () => [loader(InlineBoundPageHoleLoader)],
+ ),
path("/progressive-enhancement", ProgressiveEnhancementHandler, {
name: "progressiveEnhancement",
}),
diff --git a/packages/rangojs-router/e2e/test-app/src/urls/prerender.tsx b/packages/rangojs-router/e2e/test-app/src/urls/prerender.tsx
index fb1930bff..682164123 100644
--- a/packages/rangojs-router/e2e/test-app/src/urls/prerender.tsx
+++ b/packages/rangojs-router/e2e/test-app/src/urls/prerender.tsx
@@ -14,6 +14,7 @@ import { ChangelogPage } from "./prerender-fs.js";
import { PrerenderTestLoader } from "../loaders.js";
import { PrerenderClientTest } from "../components/PrerenderClientTest.js";
import { PrerenderPprSeq } from "../components/PrerenderPprSeq.js";
+import { PrerenderPprActionForm } from "../components/InlineBoundActionForm.js";
// Resolved by the `test-parity-alias` resolveId plugin (vite.config.ts), not
// resolve.alias. Reaching this through build-time Static/Prerender handlers
// asserts discovery's runner honors third-party resolvers (issue #500).
@@ -277,6 +278,7 @@ export const PrerenderPprPassthroughDef = Prerender<{ slug: string }>(
baked
{`PPP content for ${ctx.params.slug}`}
+
),
);
@@ -290,13 +292,22 @@ export const PrerenderPprPassthroughArticle = Passthrough(
live
{`PPP content for ${ctx.params.slug}`}
{`ppp-exec-${ppPassthroughExec}`}
+
);
},
);
export const prerenderPatterns = urls(
- ({ path, loader, loading, parallel, middleware, notFoundBoundary }) => [
+ ({
+ path,
+ loader,
+ loading,
+ parallel,
+ middleware,
+ notFoundBoundary,
+ revalidate,
+ }) => [
path("/prerender-handle", PrerenderHandle, { name: "prerender-handle" }),
path("/docs", DocsPage, { name: "docs" }),
// Prerender + ppr on ONE route: build-time segments become the frozen
@@ -321,10 +332,18 @@ export const prerenderPatterns = urls(
),
// Passthrough + Prerender + ppr (replay gate existence probe): only
// "baked" bakes; other slugs render live and must keep navigation replay.
- path("/ppp/:slug", PrerenderPprPassthroughArticle, {
- name: "pp.passthrough",
- ppr: { ttl: 300, swr: 120 },
- }),
+ path(
+ "/ppp/:slug",
+ PrerenderPprPassthroughArticle,
+ {
+ name: "pp.passthrough",
+ ppr: { ttl: 300, swr: 120 },
+ },
+ // Retaining the prerendered client boundary is part of this streaming
+ // contract. Default Passthrough revalidation replaces it with the live
+ // handler and therefore discards its local useActionState result.
+ () => [revalidate(({ actionId }) => (actionId ? false : undefined))],
+ ),
// Build-shell eviction fixture (#699): tagged so updateTag can reject the
// baked entry via the store's tag markers (manifest entries are immutable
// — eviction is a marker comparison, not a deletion).
diff --git a/tests/cloudflare-basic/e2e/ppr-shell.test.ts b/tests/cloudflare-basic/e2e/ppr-shell.test.ts
index d3a724354..5caa66127 100644
--- a/tests/cloudflare-basic/e2e/ppr-shell.test.ts
+++ b/tests/cloudflare-basic/e2e/ppr-shell.test.ts
@@ -8,7 +8,7 @@ import {
waitForNavigation,
goBack,
} from "./helper";
-import { guardHydrationErrors } from "@shared/e2e";
+import { guardHydrationErrors, waitForShellHydration } from "@shared/e2e";
import {
assertPprReplayStatus,
assertShellStatus,
@@ -47,6 +47,12 @@ const STREAM_INNER_DELAY_MS = 300;
// request.get defaults to Accept: * / *, so document probes must ask for HTML
// the way a browser navigation does.
const HTML_HEADERS = { Accept: "text/html" };
+// APIRequestContext buffers the body before returning. The fixture shortens
+// only warm-up holes so polling can observe HIT; browser holes stay action-gated.
+const INLINE_ACTION_WARM_HEADERS = {
+ ...HTML_HEADERS,
+ "x-rango-test-short-inline-hole": "1",
+};
/**
* Poll a URL until the shell cache reports HIT (the background capture landed).
@@ -57,9 +63,13 @@ const HTML_HEADERS = { Accept: "text/html" };
* cold-start path the retry-in-place exists to smooth. The built preview worker
* captures on the first attempt (pre-built modules), so it HITs well inside this.
*/
-async function warmToHit(request: Page["request"], url: string): Promise {
+async function warmToHit(
+ request: Page["request"],
+ url: string,
+ headers: Record = HTML_HEADERS,
+): Promise {
await expect(async () => {
- const res = await request.get(url, { headers: HTML_HEADERS });
+ const res = await request.get(url, { headers });
expect(res.status()).toBe(200);
// Dogfood the public testing helper (same contract as production header).
assertShellStatus(
@@ -74,21 +84,43 @@ async function warmToHit(request: Page["request"], url: string): Promise {
}
async function expectInlineActionRoundTrip(page: Page): Promise {
- await expect(testId(page, "ppr-inline-action-page")).toBeVisible();
- const rendered = await testId(
- page,
- "ppr-inline-action-rendered",
- ).textContent();
+ const roots = page.locator('[data-testid="ppr-inline-action-page"]');
+ await expect(roots).toHaveCount(1);
+ const root = roots.first();
+ await expect(root).toBeVisible();
+ const rendered = await root
+ .locator('[data-testid="ppr-inline-action-rendered"]')
+ .textContent();
const captured = rendered!.replace(/^rendered:/, "");
expect(captured).toMatch(/^cf-server-token-/);
- await testId(page, "ppr-inline-action-submit").click();
- await expect(testId(page, "ppr-inline-action-captured")).toHaveText(
- `captured:${captured}`,
- );
- await expect(testId(page, "ppr-inline-action-submitted")).toHaveText(
- "submitted:from-client",
+ const pageFallback = root.locator(
+ '[data-testid="ppr-inline-page-hole-fallback"]',
);
+ const submit = root.locator('[data-testid="ppr-inline-action-submit"]');
+ await expect(pageFallback).toBeVisible();
+
+ await submit.click();
+ await expect(submit).toHaveText("Processing...");
+ await expect(
+ root.locator('[data-testid="ppr-inline-action-stream-fallback"]'),
+ ).toBeVisible();
+ await expect(pageFallback).toBeVisible();
+
+ await expect(
+ root.locator('[data-testid="ppr-inline-action-stream-result"]'),
+ ).toHaveText(`completed:${captured}:from-client`, { timeout: 5_000 });
+ await expect(
+ root.locator('[data-testid="ppr-inline-action-captured"]'),
+ ).toHaveText(`captured:${captured}`);
+ await expect(
+ root.locator('[data-testid="ppr-inline-action-submitted"]'),
+ ).toHaveText("submitted:from-client");
+
+ await expect(pageFallback).toBeVisible();
+ await expect(
+ root.locator('[data-testid="ppr-inline-page-hole-result"]'),
+ ).toHaveText("CF page hole resolved", { timeout: 5_000 });
}
/** Native fetch + incremental reader: first-chunk latency and the full HTML body. */
@@ -135,33 +167,122 @@ function describePprShell(mode: "dev" | "build") {
test.describe(`ppr-shell caching (${label})`, () => {
const f = useFixture({ root: ".", mode });
- test("runtime shell HIT preserves an embedded bound action", async ({
+ // This intentionally pins the retained-tree policy. Default Passthrough
+ // revalidation replaces the boundary and discards local useActionState.
+ const expectPrerenderAction = async (page: Page): Promise => {
+ const submit = testId(page, "ppr-prerender-action-submit");
+ await expect(testId(page, "ppr-prerender-action-result")).toHaveCount(0);
+ await submit.click();
+ await expect(submit).toHaveText("Submitting...");
+ await expect(testId(page, "ppr-prerender-action-fallback")).toBeVisible();
+ await expect(testId(page, "ppr-ppp-source")).toHaveText("baked");
+ await expect(testId(page, "ppr-prerender-action-result")).toHaveText(
+ "cf-prerender-ppr-action:from-client",
+ );
+ await expect(submit).toHaveText("Submit prerender action");
+ await expect(testId(page, "ppr-ppp-source")).toHaveText("baked");
+ };
+
+ test("Passthrough Prerender+ppr document HIT streams with action revalidation opted out", async ({
page,
}) => {
using _ = expectNoPageError(page);
- const url = f.url("/ppr-shell/inline-action?probe=ppr-inline-hit");
+ using __ = guardHydrationErrors(page);
+ const url = f.url("/ppr-shell/passthrough/baked");
await warmToHit(page.request, url);
const response = await page.goto(url);
expect(response?.headers()["x-rango-shell"]).toBe("HIT");
await waitForHydration(page);
- await using __ = await expectNoReload(page);
+ await using ___ = await expectNoReload(page);
+ await expect(testId(page, "ppr-ppp-source")).toHaveText("baked");
+ await expectPrerenderAction(page);
+ });
+
+ test("Prerender-store partial navigation streams a client-imported action", async ({
+ page,
+ }) => {
+ using _ = expectNoPageError(page);
+ using __ = guardHydrationErrors(page);
+ await page.goto(f.url("/"));
+ await waitForHydration(page);
+ await using ___ = await expectNoReload(page);
+ const partialResponsePromise = page.waitForResponse((response) => {
+ const url = new URL(response.url());
+ return (
+ url.pathname === "/ppr-shell/passthrough/baked" &&
+ url.searchParams.has("_rsc_partial")
+ );
+ });
+
+ await testId(page, "nav-prerender-ppr-action").click();
+ const partialResponse = await partialResponsePromise;
+ assertPprReplayStatus(
+ { headers: new Headers(partialResponse.headers()) },
+ { outcome: "BYPASS", reason: "prerender-store" },
+ );
+ await expect(testId(page, "ppr-ppp-source")).toHaveText("baked");
+ await expectPrerenderAction(page);
+ });
+
+ test("document MISS streams a bound action while a page hole is pending", async ({
+ page,
+ }) => {
+ using _ = expectNoPageError(page);
+ using __ = guardHydrationErrors(page);
+ const url = f.url(
+ `/ppr-shell/inline-action?probe=ppr-inline-miss-stream-${crypto.randomUUID()}`,
+ );
+
+ const response = await page.goto(url, { waitUntil: "commit" });
+ expect(response?.headers()["x-rango-shell"]).toBe("MISS");
+ await waitForShellHydration(page);
+ await using ___ = await expectNoReload(page);
await expectInlineActionRoundTrip(page);
});
- test("partial PPR navigation preserves an embedded bound action", async ({
+ test("document HIT streams a bound action while a page hole is pending", async ({
page,
}) => {
using _ = expectNoPageError(page);
+ using __ = guardHydrationErrors(page);
+ const url = f.url("/ppr-shell/inline-action?probe=ppr-inline-hit-stream");
+ await warmToHit(page.request, url, INLINE_ACTION_WARM_HEADERS);
+
+ const response = await page.goto(url, { waitUntil: "commit" });
+ expect(response?.headers()["x-rango-shell"]).toBe("HIT");
+ await waitForShellHydration(page);
+ await using ___ = await expectNoReload(page);
+ await expectInlineActionRoundTrip(page);
+ });
+
+ test("partial PPR replay streams a bound action while a page hole is pending", async ({
+ page,
+ }) => {
+ using _ = expectNoPageError(page);
+ using __ = guardHydrationErrors(page);
await warmToHit(
page.request,
f.url("/ppr-shell/inline-action?probe=ppr-inline-nav"),
+ INLINE_ACTION_WARM_HEADERS,
);
await page.goto(f.url("/"));
await waitForHydration(page);
- await using __ = await expectNoReload(page);
+ await using ___ = await expectNoReload(page);
+ const partialResponsePromise = page.waitForResponse((response) => {
+ const url = new URL(response.url());
+ return (
+ url.pathname === "/ppr-shell/inline-action" &&
+ url.searchParams.has("_rsc_partial")
+ );
+ });
await testId(page, "nav-ppr-inline-action").click();
+ const partialResponse = await partialResponsePromise;
+ assertPprReplayStatus(
+ { headers: new Headers(partialResponse.headers()) },
+ { outcome: "HIT", freshness: "fresh" },
+ );
await expect(page).toHaveURL(
/ppr-shell\/inline-action\?probe=ppr-inline-nav$/,
);
@@ -1037,6 +1158,16 @@ function describePprShell(mode: "dev" | "build") {
expect(liveBody).toContain("PPR-PPP content for live-one");
expect(liveBody).toContain("live");
expect(liveBody).toContain("ppr-ppp-exec-");
+
+ const nextLiveUrl = f.url(
+ "/ppr-shell/passthrough/live-two?probe=cf-ppp-live-next",
+ );
+ const nextLive = await request.get(
+ `${nextLiveUrl}&_rsc_partial=true&_rsc_segments=`,
+ { headers: { "X-RSC-Router-Client-Path": liveUrl } },
+ );
+ expect(nextLive.status()).toBe(200);
+ expect(await nextLive.text()).toContain("PPR-PPP content for live-two");
});
// --- Storefront shape: replay composed with an ancestor cache() scope
diff --git a/tests/cloudflare-basic/src/actions/counter.ts b/tests/cloudflare-basic/src/actions/counter.ts
index bd85dde9e..945a970f9 100644
--- a/tests/cloudflare-basic/src/actions/counter.ts
+++ b/tests/cloudflare-basic/src/actions/counter.ts
@@ -16,3 +16,20 @@ export async function decrementCounter(): Promise {
export async function getCounter(): Promise {
return counter;
}
+
+export interface PrerenderPprActionResult {
+ streamed: Promise;
+}
+
+export async function submitPrerenderPprAction(
+ _previous: PrerenderPprActionResult | null,
+ formData: FormData,
+): Promise {
+ const value = String(formData.get("value"));
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ return {
+ streamed: new Promise((resolve) =>
+ setTimeout(() => resolve(`cf-prerender-ppr-action:${value}`), 1_200),
+ ),
+ };
+}
diff --git a/tests/cloudflare-basic/src/components/NavLayout.tsx b/tests/cloudflare-basic/src/components/NavLayout.tsx
index 05ad1bbac..95868003a 100644
--- a/tests/cloudflare-basic/src/components/NavLayout.tsx
+++ b/tests/cloudflare-basic/src/components/NavLayout.tsx
@@ -86,6 +86,13 @@ export function NavLayout() {
>
PPR inline action
+
+ Prerender PPR action
+
diff --git a/tests/cloudflare-basic/src/components/PprShellExecMatrix.tsx b/tests/cloudflare-basic/src/components/PprShellExecMatrix.tsx
index e8411bb68..97b759a1f 100644
--- a/tests/cloudflare-basic/src/components/PprShellExecMatrix.tsx
+++ b/tests/cloudflare-basic/src/components/PprShellExecMatrix.tsx
@@ -1,9 +1,13 @@
"use client";
-import { useActionState } from "react";
+import { Suspense, use, useActionState } from "react";
import { useLoader } from "@rangojs/router/client";
import type { LoaderDefinition } from "@rangojs/router";
-import type { PprExecCounters } from "../loaders/ppr-shell.js";
+import type {
+ PprExecCounters,
+ PprInlineActionHoleData,
+} from "../loaders/ppr-shell.js";
+import { submitPrerenderPprAction } from "../actions/counter.js";
// Execution-matrix consumer (docs/design/shell-fast-path.md): renders the
// loader's per-layer counter snapshot as JSON text so the e2e can parse which
@@ -20,38 +24,120 @@ export function PprShellExecMatrix({
export interface PprInlineActionState {
captured: string;
submitted: string;
+ streamed: Promise | null;
+}
+
+function PprPendingText({
+ promise,
+ testId,
+}: {
+ promise: Promise;
+ testId: string;
+}) {
+ return {use(promise)}
;
}
export function PprInlineActionForm({
action,
renderedCaptured,
+ pageHoleLoader,
}: {
action: (
previous: PprInlineActionState,
formData: FormData,
) => Promise;
renderedCaptured: string;
+ pageHoleLoader: LoaderDefinition;
}) {
- const [state, formAction] = useActionState(action, {
+ const { data: pageHole } = useLoader(pageHoleLoader);
+ const [state, formAction, isPending] = useActionState(action, {
captured: "none",
submitted: "none",
+ streamed: null,
});
return (
-
-
);
}
diff --git a/tests/cloudflare-basic/src/loaders/ppr-shell.ts b/tests/cloudflare-basic/src/loaders/ppr-shell.ts
index 9289f9b88..abe557a8d 100644
--- a/tests/cloudflare-basic/src/loaders/ppr-shell.ts
+++ b/tests/cloudflare-basic/src/loaders/ppr-shell.ts
@@ -80,6 +80,56 @@ export const PprShellStreamLoader = createLoader(
},
);
+const PPR_INLINE_ACTION_WARM_HOLE_DELAY_MS = 2_000;
+const PPR_INLINE_ACTION_HOLE_FAILSAFE_MS = 30_000;
+const PPR_INLINE_ACTION_HOLE_AFTER_ACTION_MS = 2_000;
+
+// Probe-scoped resolvers make the ordering causal: the page hole cannot finish
+// until this page's action result has streamed. The long timer is only a leak
+// failsafe; API warm-up requests opt into the short timer via a test header.
+const pprInlineActionHoleResolvers = new Map void>>();
+
+export function resolvePprInlineActionHoleAfterAction(probe: string): void {
+ setTimeout(() => {
+ for (const resolve of [
+ ...(pprInlineActionHoleResolvers.get(probe) ?? []),
+ ]) {
+ resolve();
+ }
+ }, PPR_INLINE_ACTION_HOLE_AFTER_ACTION_MS);
+}
+
+export interface PprInlineActionHoleData {
+ pendingData: Promise;
+}
+
+// Bake-lane container with a nested promise: the form remains shell material,
+// while the nested value is masked during capture and streams fresh per serve.
+export const PprInlineActionHoleLoader = createLoader(
+ async (ctx): Promise => {
+ const probe = ctx.searchParams.get("probe") ?? "default";
+ const resolvers = pprInlineActionHoleResolvers.get(probe) ?? new Set();
+ pprInlineActionHoleResolvers.set(probe, resolvers);
+ const pendingData = new Promise((resolve) => {
+ let timeout: ReturnType;
+ const finish = () => {
+ clearTimeout(timeout);
+ resolvers.delete(finish);
+ if (resolvers.size === 0) pprInlineActionHoleResolvers.delete(probe);
+ resolve("CF page hole resolved");
+ };
+ resolvers.add(finish);
+ timeout = setTimeout(
+ finish,
+ ctx.request.headers.has("x-rango-test-short-inline-hole")
+ ? PPR_INLINE_ACTION_WARM_HOLE_DELAY_MS
+ : PPR_INLINE_ACTION_HOLE_FAILSAFE_MS,
+ );
+ });
+ return { pendingData };
+ },
+);
+
// Layout-loader bake-lane fixture (the storefront shape: an app-wide layout
// registering session/basket-style loaders, no loading() on the layout).
// Executes at capture (the gate holds for the 100ms), bakes, and is
diff --git a/tests/cloudflare-basic/src/pages/ppr-shell.tsx b/tests/cloudflare-basic/src/pages/ppr-shell.tsx
index c33430ca5..ebdd56173 100644
--- a/tests/cloudflare-basic/src/pages/ppr-shell.tsx
+++ b/tests/cloudflare-basic/src/pages/ppr-shell.tsx
@@ -1,5 +1,10 @@
import { Suspense } from "react";
-import { Meta, Prerender, Passthrough } from "@rangojs/router";
+import {
+ getRequestContext,
+ Meta,
+ Prerender,
+ Passthrough,
+} from "@rangojs/router";
import type { HandlerContext } from "@rangojs/router";
import { Link, Outlet, ParallelOutlet } from "@rangojs/router/client";
import { Breadcrumbs } from "../handles/breadcrumbs.js";
@@ -7,7 +12,11 @@ import { PprShellPriceLoader } from "../loaders/ppr-shell.js";
import { PprShellStreamLoader } from "../loaders/ppr-shell.js";
import { PprShellSettledLoader } from "../loaders/ppr-shell.js";
import { PprShellExecLoader, pprExecCounters } from "../loaders/ppr-shell.js";
-import { PprPrerenderSeqLoader } from "../loaders/ppr-shell.js";
+import {
+ PprPrerenderSeqLoader,
+ resolvePprInlineActionHoleAfterAction,
+} from "../loaders/ppr-shell.js";
+import { PprInlineActionHoleLoader } from "../loaders/ppr-shell.js";
import { PprBakeSlowLoader, PprBakeHoleLoader } from "../loaders/ppr-shell.js";
import { makePprPhysicsPromise } from "../loaders/ppr-shell.js";
import {
@@ -22,6 +31,7 @@ import { PprShellCounter } from "../components/PprShellCounter.js";
import { PprShellPhysicsValue } from "../components/PprShellPhysicsValue.js";
import {
PprInlineActionForm,
+ PprPrerenderActionForm,
PprShellExecMatrix,
type PprInlineActionState,
} from "../components/PprShellExecMatrix.js";
@@ -241,18 +251,33 @@ export function PprExecPage() {
export function PprInlineActionPage() {
const captured = `cf-server-token-${crypto.randomUUID()}`;
+ const probe = getRequestContext()?.searchParams.get("probe") ?? "default";
async function submit(
_previous: PprInlineActionState,
formData: FormData,
): Promise {
"use server";
+ const submitted = String(formData.get("value"));
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
return {
captured,
- submitted: String(formData.get("value")),
+ submitted,
+ streamed: new Promise((resolve) =>
+ setTimeout(() => {
+ resolve(`completed:${captured}:${submitted}`);
+ resolvePprInlineActionHoleAfterAction(probe);
+ }, 1_200),
+ ),
};
}
- return ;
+ return (
+
+ );
}
// Prerender + ppr composition (docs/design/shell-fast-path.md): build-time
@@ -296,6 +321,7 @@ export const PprPrerenderedPassthroughDef = Prerender<{ slug: string }>(
baked
{`PPR-PPP content for ${ctx.params.slug}`}
+
),
);
@@ -309,6 +335,7 @@ export const PprPrerenderedPassthroughArticle = Passthrough(
live
{`PPR-PPP content for ${ctx.params.slug}`}
{`ppr-ppp-exec-${pprPpPassthroughExec}`}
+
);
},
diff --git a/tests/cloudflare-basic/src/urls.tsx b/tests/cloudflare-basic/src/urls.tsx
index 87bcf3f32..95c719ee5 100644
--- a/tests/cloudflare-basic/src/urls.tsx
+++ b/tests/cloudflare-basic/src/urls.tsx
@@ -73,6 +73,7 @@ import {
import {
PprShellPriceLoader,
PprShellStreamLoader,
+ PprInlineActionHoleLoader,
PprShellSettledLoader,
PprShellExecLoader,
pprExecCounters,
@@ -169,6 +170,7 @@ export const urlpatterns = urls(
include,
middleware,
transition,
+ revalidate,
errorBoundary,
}) => [
// API routes (response routes - skip RSC pipeline)
@@ -792,10 +794,15 @@ export const urlpatterns = urls(
]),
],
),
- path("/ppr-shell/inline-action", PprInlineActionPage, {
- name: "pprShellInlineAction",
- ppr: { ttl: 300, swr: 120 },
- }),
+ path(
+ "/ppr-shell/inline-action",
+ PprInlineActionPage,
+ {
+ name: "pprShellInlineAction",
+ ppr: { ttl: 300, swr: 120 },
+ },
+ () => [loader(PprInlineActionHoleLoader)],
+ ),
// Prerender + ppr composition (docs/design/shell-fast-path.md):
// build-time segments are the frozen prelude; the slot-owned loader
// is the badge-sized streaming hole. See pages/ppr-shell.tsx.
@@ -822,10 +829,18 @@ export const urlpatterns = urls(
// Passthrough + Prerender + ppr (replay gate existence probe): only
// "baked" bakes; other slugs render live and must keep navigation
// replay on the real CFCacheStore/KV path.
- path("/ppr-shell/passthrough/:slug", PprPrerenderedPassthroughArticle, {
- name: "pprShellPassthrough",
- ppr: { ttl: 300, swr: 120 },
- }),
+ path(
+ "/ppr-shell/passthrough/:slug",
+ PprPrerenderedPassthroughArticle,
+ {
+ name: "pprShellPassthrough",
+ ppr: { ttl: 300, swr: 120 },
+ },
+ // Retaining the prerendered client boundary is part of this streaming
+ // contract. Default Passthrough revalidation replaces it with the live
+ // handler and discards its local useActionState result.
+ () => [revalidate(({ actionId }) => (actionId ? false : undefined))],
+ ),
// Build-shell eviction fixture (#699): its own route + tag so the
// eviction e2e's updateTag cannot blast the sibling prerendered
// entries (baked manifest entries are immutable — eviction is a tag
diff --git a/tests/shared-e2e/src/index.ts b/tests/shared-e2e/src/index.ts
index 36bb04a82..54e50d01c 100644
--- a/tests/shared-e2e/src/index.ts
+++ b/tests/shared-e2e/src/index.ts
@@ -52,6 +52,19 @@ export function checkoutPortOffset(): number {
return ((h >>> 0) % 150) * 200;
}
+/**
+ * Wait for hydration without waiting for DOMContentLoaded. A pending PPR hole
+ * keeps the document stream open, so the normal helper cannot reach its
+ * hydration check until the condition this kind of test needs to observe has
+ * already disappeared.
+ */
+export async function waitForShellHydration(page: Page): Promise {
+ await page.waitForFunction(
+ () => document.documentElement.hasAttribute("data-hydrated"),
+ { timeout: 20_000 },
+ );
+}
+
/**
* Server-output marker emitted once per route re-discovery pass by the Rango
* Vite plugin (see `discover-routers.ts`: `[rango] Router "" -> N routes`).
diff --git a/tests/vite-rsc-demo/e2e/shop-actions.test.ts b/tests/vite-rsc-demo/e2e/shop-actions.test.ts
index e8df3003e..f5e2cebd5 100644
--- a/tests/vite-rsc-demo/e2e/shop-actions.test.ts
+++ b/tests/vite-rsc-demo/e2e/shop-actions.test.ts
@@ -6,6 +6,7 @@ import {
expectNoPageError,
goBack,
clearCart,
+ expectNoReload,
prodDescribe,
} from "./helper";
@@ -72,18 +73,20 @@ async function shopConcurrentAddToCart(page: Page, url: UrlResolver) {
});
const withResultButton = page
- .locator("button")
- .filter({ hasText: "Add to Cart (With Result)" })
- .first();
- const streamingButton = page
- .locator("button")
- .filter({ hasText: "Add product (Streaming)" })
- .first();
+ .getByRole("heading", { name: "2. With Return Value" })
+ .locator("..")
+ .getByRole("button");
+ const streamingButton = page.getByTestId("shop-streaming-submit");
// Fire both actions back to back (concurrent actions).
await withResultButton.click();
await streamingButton.click();
+ // Both actions overlap; a serialized coordinator would never expose these
+ // two pending labels at the same time.
+ await expect(withResultButton).toHaveText("Adding...");
+ await expect(streamingButton).toHaveText("Processing...");
+
// "With Result" action settles and reports success.
await expect(withResultButton).not.toHaveText("Adding...", {
timeout: 15000,
@@ -92,10 +95,12 @@ async function shopConcurrentAddToCart(page: Page, url: UrlResolver) {
timeout: 10000,
});
- // Streaming action settles too (button leaves the "Processing..." state).
- await expect(streamingButton).not.toHaveText("Processing...", {
- timeout: 20000,
- });
+ // The nested action promise reaches its resolved UI, not merely the point
+ // where the top-level action stopped showing "Processing...".
+ await expect(page.getByTestId("shop-streaming-result")).toContainText(
+ "Completed!",
+ { timeout: 20_000 },
+ );
// Page remains functional after both concurrent actions.
await expect(
@@ -103,6 +108,38 @@ async function shopConcurrentAddToCart(page: Page, url: UrlResolver) {
).toBeVisible();
}
+async function expectShopStreamingAction(
+ page: Page,
+ url: UrlResolver,
+): Promise {
+ using _ = expectNoPageError(page);
+ await page.goto(url("/shop/product/wireless-headphones"));
+ await waitForHydration(page);
+ await using __ = await expectNoReload(page);
+
+ await expect(page.locator("h2:has-text('Wireless Headphones')")).toBeVisible({
+ timeout: 15_000,
+ });
+
+ const button = page.getByTestId("shop-streaming-submit");
+ const status = page.getByTestId("shop-streaming-status");
+ await expect(button).toBeVisible();
+ await expect(status).toContainText("idle");
+
+ await button.click();
+ await expect(button).toHaveText("Processing...");
+ await expect(status).toContainText("loading");
+ await expect(page.getByTestId("shop-streaming-fallback")).toBeVisible();
+ await expect(status).toContainText("streaming");
+ await expect(button).toHaveText("Add product (Streaming)");
+ await expect(button).toBeEnabled();
+ await expect(page.getByTestId("shop-streaming-result")).toContainText(
+ "Completed! Added 1 1",
+ { timeout: 10_000 },
+ );
+ await expect(status).toContainText("idle");
+}
+
async function shopActionDuringNavigation(page: Page, url: UrlResolver) {
using _ = expectNoPageError(page);
@@ -191,39 +228,9 @@ devTest.describe("shop-actions", () => {
devTest(
"should show streaming action updates",
async ({ page, devServerURL }) => {
- using _ = expectNoPageError(page);
-
- await page.goto(
- devURL(devServerURL, "/shop/product/wireless-headphones"),
+ await expectShopStreamingAction(page, (path) =>
+ devURL(devServerURL, path),
);
- await waitForHydration(page);
-
- // Wait for product to load
- await expect(
- page.locator("h2:has-text('Wireless Headphones')"),
- ).toBeVisible({
- timeout: 10000,
- });
-
- // Wait for the add to cart section to be visible
- await expect(page.locator("text=Add to Cart - Tests")).toBeVisible({
- timeout: 5000,
- });
-
- // Click streaming add to cart button
- const streamingButton = page
- .locator("button")
- .filter({ hasText: "Add product (Streaming)" })
- .first();
- await streamingButton.click();
-
- // Wait for streaming action to complete (has 3s delay)
- await page.waitForTimeout(4000);
-
- // Page should still be functional
- await expect(
- page.locator("h2:has-text('Wireless Headphones')"),
- ).toBeVisible();
},
);
@@ -801,6 +808,10 @@ test.describe("shop-actions (production)", () => {
await expect(page.locator("text=Total items in cart:")).toBeVisible();
});
+ test("should show streaming action updates", async ({ page }) => {
+ await expectShopStreamingAction(page, (path) => f.url(path));
+ });
+
test("should update cart quantity from intercept modal", async ({ page }) => {
using _ = expectNoPageError(page);
diff --git a/tests/vite-rsc-demo/src/actions/streaming.actions.ts b/tests/vite-rsc-demo/src/actions/streaming.actions.ts
index 76df0ae8d..7a839f98d 100644
--- a/tests/vite-rsc-demo/src/actions/streaming.actions.ts
+++ b/tests/vite-rsc-demo/src/actions/streaming.actions.ts
@@ -1,41 +1,47 @@
"use server";
+import { createElement, type ReactNode } from "react";
+
/**
* Streaming action demo - returns a Promise that resolves on the client
*
* This demonstrates RSC's ability to stream Promises to the client,
* where they can be awaited using Suspense boundaries.
*/
-export async function addToCartSlowly(productId: string, quantity: number = 1) {
- console.log(`[Action] addToCartSlowly: Starting for ${productId}...`);
-
- // Return a Promise immediately (don't await!)
- // This Promise will be serialized and sent to the client
- // oxlint-disable-next-line no-async-promise-executor -- intentional: stream result after delay
- const resultPromise = new Promise(async (resolve) => {
- // Simulate slow operation (3 seconds)
- await new Promise((wait) => setTimeout(wait, 1000));
+export interface StreamingCartResult {
+ promise: Promise;
+}
- console.log(`[Action] addToCartSlowly: Completed after 3s`);
+export async function addToCartSlowly(
+ _previous: StreamingCartResult | null,
+ formData: FormData,
+): Promise {
+ const productId = String(formData.get("productId"));
+ const quantity = Number(formData.get("quantity") ?? 1);
+ console.log(`[Action] addToCartSlowly: Starting for ${productId}...`);
- resolve({
- success: true,
- message: `Successfully added ${quantity} ${productId} after 1 seconds!`,
- timestamp: new Date().toISOString(),
- cart: {
- productId,
- quantity,
- totalItems: quantity, // Simplified
- },
- });
+ // Keep the action pending first, then return a nested promise. Returning the
+ // promise itself from an async action would assimilate it and remove the
+ // Suspense streaming phase entirely.
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ const resultPromise = new Promise((resolve) => {
+ setTimeout(() => {
+ console.log(`[Action] addToCartSlowly: Stream completed`);
+ resolve(
+ createElement(
+ "span",
+ null,
+ `Completed! Added ${quantity} ${productId}`,
+ ),
+ );
+ }, 4000);
});
console.log(
`[Action] addToCartSlowly: Returning Promise (will stream to client)`,
);
- // Return the Promise - RSC will serialize it and stream to client
- return resultPromise;
+ return { promise: resultPromise };
}
/**
diff --git a/tests/vite-rsc-demo/src/actions/test.actions.tsx b/tests/vite-rsc-demo/src/actions/test.actions.tsx
deleted file mode 100644
index 469eaf51d..000000000
--- a/tests/vite-rsc-demo/src/actions/test.actions.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-"use server";
-
-import { ReactNode } from "react";
-
-export const StreamingAction = async (data: FormData) => {
- await new Promise((resolve) => setTimeout(resolve, 2000)); // Simulate processing delay
- return {
- promise: new Promise((resolve) => {
- setTimeout(() => {
- resolve(
- <>
-
-
✅ Completed!
-
- >,
- );
- }, 10000); // Simulate delay
- }),
- }; // Simulate delay
-};
diff --git a/tests/vite-rsc-demo/src/components/StreamingActionForm.tsx b/tests/vite-rsc-demo/src/components/StreamingActionForm.tsx
index 38c1faea4..a74939595 100644
--- a/tests/vite-rsc-demo/src/components/StreamingActionForm.tsx
+++ b/tests/vite-rsc-demo/src/components/StreamingActionForm.tsx
@@ -1,12 +1,22 @@
"use client";
-import { use, useActionState, Suspense, startTransition } from "react";
-import { StreamingAction } from "../actions/test.actions";
+import { use, useActionState, Suspense } from "react";
import { useAction } from "@rangojs/router/client";
+import {
+ addToCartSlowly,
+ type StreamingCartResult,
+} from "../actions/streaming.actions.js";
+
export const StreamingActionStatus = () => {
- const status = useAction(StreamingAction);
+ // useAction needs the module reference directly; an action passed through
+ // RSC as a prop intentionally does not retain the metadata this hook reads.
+ const status = useAction(addToCartSlowly);
console.log("StreamingActionStatus", status.state);
- return StreamingAction status: {status.state}
;
+ return (
+
+ StreamingAction status: {status.state}
+
+ );
};
const getOwnProps = (item: any) => {
const reflect = Reflect.ownKeys(item);
@@ -44,34 +54,22 @@ export const ActionStatus = ({
*/
export function StreamingActionForm({
productId,
- action,
children,
}: {
productId: string | number;
- action: (productId: string, quantity?: number) => Promise;
children?: React.ReactNode;
}) {
- const [state, formAction, isPending] = useActionState(
- async (_prevState: unknown, formData: FormData) => {
- try {
- console.log("StreamingActionForm data set", { isPending, state });
- const result = await StreamingAction(formData);
- console.log("[StreamingAction] Action result:", result);
- return result;
- } catch (error) {
- console.error("[StreamingAction] Action error:", error);
- return { success: false, error: String(error) };
- }
- },
- null,
- );
+ const [state, formAction, isPending] = useActionState(addToCartSlowly, null);
return (
+
+
{children}>}
- {state && "promise" in state && (
+ {state && (
+
⏳ Streaming...
- Waiting for server to complete slow operation (10 seconds)...
+ Waiting for the streamed server result...
)}
@@ -138,11 +134,11 @@ export function StreamingActionForm({
/**
* Component that uses() a Promise - triggers Suspense
*/
-function PromiseResolver({ promise }: { promise: Promise
}) {
+function PromiseResolver({ promise }: { promise: Promise }) {
// use() hook suspends until Promise resolves
const result = use(promise);
console.log("[PromiseResolver] Promise resolved:", result);
- return promise;
+ return {result}
;
}
diff --git a/tests/vite-rsc-demo/src/handlers/shop/routes/product.tsx b/tests/vite-rsc-demo/src/handlers/shop/routes/product.tsx
index f4d765837..e9d24be51 100644
--- a/tests/vite-rsc-demo/src/handlers/shop/routes/product.tsx
+++ b/tests/vite-rsc-demo/src/handlers/shop/routes/product.tsx
@@ -15,7 +15,6 @@ import {
addToCartWithResult,
getCartCount,
} from "@/handlers/shop/actions/shop.actions.js";
-import { addToCartSlowly } from "@/actions/streaming.actions.js";
import {
PDPNavbar,
ProductCard,
@@ -178,13 +177,10 @@ export const ProductsDetailRoute: Handler<"/product/:slug"> = async (ctx) => {
4. Streaming Updates
- Real-time progress (3s delay)
+ Pending action followed by a streamed result
-
+
Add product (Streaming)