Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/rangojs-router/docs/design/ppr-shell-resume.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions packages/rangojs-router/docs/prerender-api-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 88 additions & 34 deletions packages/rangojs-router/e2e/inline-bound-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,87 +25,139 @@ 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<void> {
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<void> {
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<void> {
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"),
);

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");
77 changes: 77 additions & 0 deletions packages/rangojs-router/e2e/prerender-ppr.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<void> => {
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,
}) => {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,20 @@ export async function updateBannerAction(
await updateTag(SHELL_ACTION_BANNER_TAG);
return { banner };
}

export interface PrerenderPprActionResult {
streamed: Promise<string>;
}

export async function submitPrerenderPprAction(
_previous: PrerenderPprActionResult | null,
formData: FormData,
): Promise<PrerenderPprActionResult> {
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),
),
};
}
Loading
Loading