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 ( -
- +
+ + Page hole pending... +

+ } + > + +
+ + + + +

+ captured:{state?.captured ?? "none"} +

+

+ submitted:{state?.submitted ?? "none"} +

+ {state?.streamed && ( + + Streaming action result... +

+ } + > + +
+ )} + +
+ ); +} + +export function PrerenderPprActionForm() { + const [state, formAction, isPending] = useActionState( + submitPrerenderPprAction, + null, + ); + + return ( +
+ -

- captured:{state?.captured ?? "none"} -

-

- submitted:{state?.submitted ?? "none"} -

+ {state && ( + + Streaming prerender action result... +

+ } + > + +
+ )}
); } 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 ( -
-

- {`rendered:${renderedCaptured}`} -

+
+ + CF page hole pending... +

+ } + > + +
+ + +

+ {`rendered:${renderedCaptured}`} +

+ + +

+ {`captured:${state.captured}`} +

+

+ {`submitted:${state.submitted}`} +

+ {state.streamed && ( + + Streaming action result... +

+ } + > + +
+ )} + +
+ ); +} + +export function PprPrerenderActionForm() { + const [state, formAction, isPending] = useActionState( + submitPrerenderPprAction, + null, + ); + + return ( +
- -

- {`captured:${state.captured}`} -

-

- {`submitted:${state.submitted}`} -

+ {state && ( + + Streaming prerender action result... +

+ } + > + +
+ )}
); } 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 (
+ +
- {state && "promise" in state && ( + {state && (
+

⏳ Streaming...

- Waiting for server to complete slow operation (10 seconds)... + Waiting for the streamed server result...

} > - }).promise} - /> +
)} @@ -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)