Skip to content

test(ppr): cover streaming actions across replay modes - #790

Merged
ivogt merged 3 commits into
mainfrom
test/ppr-streaming-action-coverage
Jul 13, 2026
Merged

test(ppr): cover streaming actions across replay modes#790
ivogt merged 3 commits into
mainfrom
test/ppr-streaming-action-coverage

Conversation

@ivogt

@ivogt ivogt commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

Cached content has two distinct Server Action paths. Runtime-captured PPR shells must preserve inline closure-bound action references across document HIT hydration and partial replay. Build-time Passthrough(Prerender(...), liveHandler) + ppr content can stream a client-imported module action only while the prerendered client boundary is retained; default Passthrough action revalidation replaces that boundary with the live handler and drops its local useActionState result.

The existing coverage did not pin these boundaries end to end. A regression could lose an encrypted inline closure, leave a streamed result behind its Suspense fallback, duplicate a resumed subtree, or reload the document while a basic invocation assertion still passed.

Before vs. after

No consumer migration or public API change is required.

Before, this usage had only basic action invocation coverage:

const [state, formAction, isPending] = useActionState(addToCartSlowly, null);

return (
  <form action={formAction}>
    <button disabled={isPending}>Add to cart</button>
    {state && (
      <Suspense fallback={<p>Streaming...</p>}>
        <PromiseResolver promise={state.promise} />
      </Suspense>
    )}
  </form>
);

After, pending-to-streamed-result behavior is verified in development and production for:

  • inline closure-bound actions from runtime PPR document MISS, document HIT, and partial replay;
  • client-imported module actions from build-time Prerender document HIT and prerender-store navigation when action revalidation is explicitly suppressed;
  • direct and concurrent shop PDP submissions.

End-to-end example

A runtime PPR action can retain render-scope data while its result streams independently from another PPR hole:

export const ProductRoute: Handler<"product"> = (ctx) => {
  const captured = `server-token-${Date.now().toString(36)}`;

  async function submit(_previous: Result | null, formData: FormData) {
    "use server";
    return {
      captured,
      submitted: String(formData.get("submitted")),
      streamed: getStreamedResult(),
    };
  }

  return <StreamingForm action={submit} />;
};

For build-time Prerender content, use a module-level action and retain the prerendered boundary when its local action state must stream:

path(
  "/article/:slug",
  Passthrough(Prerender(getParams, BakedArticle), LiveArticle),
  { ppr: { ttl: 300, swr: 120 } },
  () => [revalidate(({ actionId }) => (actionId ? false : undefined))],
);
"use client";

import { submitArticleAction } from "./actions.js";

export function ArticleActionForm() {
  const [state, formAction] = useActionState(submitArticleAction, null);
  return (
    <form action={formAction}>
      {state && (
        <Suspense fallback={<p>Streaming...</p>}>
          <PromiseResolver promise={state.streamed} />
        </Suspense>
      )}
    </form>
  );
}

Semantics

Request path Contract pinned
Runtime PPR document MISS Inline bound action submits without reload; nested result streams while the page hole remains pending
Runtime PPR document HIT Cached shell retains the action reference and encrypted closure; action behavior matches MISS
Runtime PPR partial replay x-rango-ppr-replay: HIT; action fallback and result resolve without suppressing the independent hole
Prerender document HIT with action revalidation suppressed Client-imported module action exposes action pending, nested Suspense fallback, and streamed result while frozen content stays mounted
Prerender-store navigation with action revalidation suppressed Imported action streams after BYPASS; reason=prerender-store; ordinary navigation revalidation remains unchanged
Default Passthrough action revalidation Deliberately not claimed: replacing the prerendered boundary with the live handler discards its local useActionState result
Shop PDP Native useActionState exposes pending, streamed fallback, resolved result, restored idle state, and concurrent submissions
Stored build-time Prerender Flight with an inline closure action Unsupported; still blocked by #584 and vitejs/vite-plugin-react#1246

Code changes

  • Expanded runtime inline-action coverage into document MISS, document HIT, and partial-replay matrices in the router test-app and cloudflare-basic.
  • Made page-hole ordering causal: probe-scoped resolvers release each hole only after that page action has streamed; API warm-up requests use a short test-only failsafe so shell capture remains fast.
  • Restored the DOM uniqueness invariant by asserting exactly one unfiltered page root before selecting it.
  • Increased the inline action pending phase from 250ms to 1s and aligned the Cloudflare form with disabled={isPending}.
  • Coupled the dynamic bucketing helper title directly to mode and moved shell hydration waiting into tests/shared-e2e with the pending-document-stream rationale.
  • Named and documented the build-time Prerender action-revalidation opt-out as part of the retained-tree streaming contract.
  • Strengthened shop direct and concurrent assertions, reduced its nested stream delay from 6s to the verified 4s minimum, and removed the dead test.actions.tsx action module.
  • No router runtime, public export, or route shape changed.

Test plan

  • Router runtime PPR inline action matrix, MISS/HIT/replay, dev + production: 6 passed
  • cloudflare-basic mirrored runtime PPR inline action matrix, dev + production: 6 passed
  • Router retained-tree Prerender action, document HIT + navigation, dev + production: 4 passed
  • cloudflare-basic retained-tree Prerender action, dev + production: 4 passed
  • vite-rsc-demo direct + concurrent shop streaming actions, dev + production: 4 passed
  • pnpm --filter @rangojs/router exec playwright test semantic-matrix: 75 passed
  • pnpm run typecheck
  • pnpm run test:unit:all
  • pnpm run lint
  • pnpm run format
  • pnpm check:e2e-bucketing
  • pnpm check:e2e-parity --strict
  • vite-rsc-demo production build

The browser suites exercise the public route DSL, useActionState, Server Action props, PPR replay, and Prerender behavior as consumer applications do.

Notes

  • No generated route file or migration change.
  • The 2s shop stream-delay proposal was tested and rejected: both dev and production resolved the nested value during post-action revalidation, so the Suspense fallback never appeared. The 4s delay passed direct and concurrent suites in both modes.
  • The removed client wrapper did not provide a covered rejection contract: it ignored the supplied shop action, called a separate never-rejecting demo action, and hid its error result. Native action rejection behavior is unchanged by this PR.
  • Inline closure-bound actions are supported and tested on runtime-captured PPR shell HITs.
  • Inline closure-bound actions embedded in stored build-time Static/Prerender Flight remain unsupported until decoded server references can be preserved across storage and replay (fix(router): preserve embedded server actions across cache and prerender hits #584 / feat(rsc): improve directive transform primitives vitejs/vite-plugin-react#1246).
  • Desktop initial, pending, streaming, and resolved shop states were inspected. The demo still has horizontal overflow at a 390px viewport from its broader debug/navigation layout and fixed two-column action section; this PR does not change that layout.

@pkg-pr-new

pkg-pr-new Bot commented Jul 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@790

commit: bb9a5b7

@ivogt
ivogt merged commit 528da44 into main Jul 13, 2026
69 of 70 checks passed
@ivogt
ivogt deleted the test/ppr-streaming-action-coverage branch July 13, 2026 13:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant