Skip to content

e2e coverage gaps from review: prerender Passthrough+loading(), redirect under loading() #567

Closed

Description

@ivogt

Summary

Three correctness fixes from the multi-agent review landed with unit coverage but still lack the dev + production e2e fixtures the repo mandates (CLAUDE.md: "All e2e tests MUST cover BOTH dev AND production modes"). This issue tracks adding them. The fixtures below are already written and verified to compile; they were pulled out of the stabilization PR because regenerating the test-app .gen.ts + running dual-mode Playwright is a separate, heavier step.

Gap 1 — Prerender Passthrough + loading() + ctx.passthrough() (was M16)

Consumer story. A consumer pre-renders a route, declares a loading() skeleton, and skips specific params at build with ctx.passthrough():

// urls.tsx — what a consumer writes
path("/:slug", Passthrough(ProductDef, LiveProduct), { name: "product" }, () => [
  loading(<ProductSkeleton />),            // <- the loading() boundary is the trigger
])

// ProductDef build handler
async (ctx) => {
  if (!(await isPublished(ctx.params.slug))) return ctx.passthrough(); // skip baking
  return <Product slug={ctx.params.slug} />;
}

Before the fix: because loading() defers the build handler, ctx.passthrough() arrived as a Promise-wrapped component and the synchronous sentinel check missed it — the build baked a corrupt artifact for the skipped param instead of deferring to the live handler. After the fix (detectPrerenderPassthrough awaits the thenable), the param correctly passes through. Unit test: prerender-passthrough-detect.test.ts.

Fixture to add (e2e/test-app/src/urls/prerender-loading.tsx):

import { urls, Prerender, Passthrough } from "@rangojs/router";

export const PrerenderLoadingDef = Prerender<{ slug: string }>(
  async () => [{ slug: "baked" }, { slug: "skip" }],
  async (ctx) => {
    if (ctx.params.slug === "skip") return ctx.passthrough();
    return (
      <div data-testid="prerender-loading-page">
        <h1 data-testid="prerender-loading-title">{ctx.params.slug}</h1>
        <p data-testid="prerender-loading-build">{String(ctx.build)}</p>
        <p data-testid="prerender-loading-ts">{Date.now()}</p>
      </div>
    );
  },
);
export const PrerenderLoadingTest = Passthrough(PrerenderLoadingDef, async (ctx) => (
  <div data-testid="prerender-loading-page">
    <h1 data-testid="prerender-loading-title">{ctx.params.slug}</h1>
    <p data-testid="prerender-loading-build">{String(ctx.build)}</p>
    <p data-testid="prerender-loading-live">live</p>
    <p data-testid="prerender-loading-ts">{Date.now()}</p>
  </div>
));
export const prerenderLoadingPatterns = urls(({ path, loading }) => [
  path("/:slug", PrerenderLoadingTest, { name: "detail" }, () => [
    loading(<div data-testid="prerender-loading-skeleton">Loading...</div>),
  ]),
]);

Wire into urls.tsx: include("/prerender-loading", prerenderLoadingPatterns, { name: "prerenderLoading" }).

Assertions (dev + production):

  • baked → prerendered (ctx.build true at build; served from manifest).
  • skip → passes through to the live handler (prerender-loading-live present; ctx.build false).
  • production: /__test/prerender-manifest-entries?route=prerenderLoading.detail count includes baked but not skip (the M16 regression guard — without the fix, skip would be baked).

Gap 2 — redirect() from a loading() route (was M9)

Consumer story. A sync handler return of redirect() on a loading() route short-circuits to a real HTTP redirect (supported); an async handler return is streamed instead (documented limitation — dev warns via warnOnStreamedResponse).

// supported: synchronous return redirects
path("/sync", () => redirect("/loading-redirect/target"), { name: "sync" }, () => [
  loading(<Skeleton />),
])

Fixture to add (e2e/test-app/src/urls/loading-redirect.tsx):

import { urls, redirect } from "@rangojs/router";
export const loadingRedirectPatterns = urls(({ path, loading }) => [
  path("/sync", () => redirect("/loading-redirect/target"), { name: "sync" }, () => [
    loading(<div data-testid="lr-skeleton">Loading...</div>),
  ]),
  path("/target", () => <div data-testid="lr-target">arrived</div>, { name: "target" }),
]);

Assertions (dev + production): navigating to /loading-redirect/sync lands on /loading-redirect/target (lr-target visible). (The async-swallow limitation is intentionally not e2e'd — it produces a degraded render, not a navigable outcome; it's covered by the unit test + execution-model.md.)

Additional e2e gaps surfaced by the review (lower priority)

  • Build-time intercept _currentSegmentId not pinned → ctx.use handle data dropped from the intercept prerender artifact. Needs a test-app intercept handler that pushes a handle, asserted dev + prod.
  • server-action-bridge pre-dispatch failure over-invalidates the client cache + broadcasts staleness even though nothing reached the server (over-invalidation only — safe today).
  • document-cache private, s-maxage writes to the shared store (spec-correct per RFC 7234 but a defense-in-depth gap); document-cache.test.ts:268 covers private alone.

Test plan

  • Wire both fixtures into e2e/test-app/src/urls.tsx; regenerate .gen.ts via the test-app build/dev (runtime discovery — the static CLI can't resolve the factory include); commit router.named-routes.gen.ts.
  • Add prerender-loading.test.ts and loading-redirect.test.ts with the assertions above, dev + (production) describes (use prodDescribe/the bucketing convention).
  • Run both in dev and production locally; confirm green.

Activity

  1. ivogt commented on Jun 13, 2026

    @ivogt
    CollaboratorAuthor

    What can move down the pyramid (re: the new @rangojs/router/testing primitives + docs/testing.md)

    Checked each gap against the testing-surface table. Most have a unit-coverable slice; only the server/Flight/build-lifecycle parts genuinely need e2e.

    Gap Unit-coverable slice (do this) Genuinely e2e (keep)
    M16 prerender Passthrough+loading() Sentinel detection — already unit-tested (prerender-passthrough-detect.test.ts) Build bakes/skips + serves the artifact: Prerender is e2e(prod) + assertCacheStatus(…, "prerendered") (guide line 70)
    M9 redirect under loading() Sync handler return redirect() throws the Response → renderHandler can assert the thrown redirect; dev warning already unit-tested (warnOnStreamedResponse) The loading() deferral path (async handler streamed, not redirected) runs through fresh.ts resolution → e2e
    intercept _currentSegmentId handle pinning Handle accumulation → collectHandle; seeded read → renderRoute({ handles }) (guide lines 58-59) Build-time _currentSegmentId pinning during prerender → e2e
    server-action-bridge pre-dispatch over-invalidation The store's finalizeAction/markCacheAsStaleAndBroadcast logic is a navigation-store unit test (cf. existing navigation-store.test.ts) — no e2e needed Full action lifecycle (non-idle useAction) — renderRoute bypasses it (guide line 83) → e2e
    document-cache private, s-maxage parseCacheControl is a pure fn — export it (or the cache-decision fn) and unit-test the decision directly The actual shared-store write across two requests → e2e + signal (guide line 69)

    Net: 4 of 5 gaps gain a unit/integration slice; the irreducible e2e remainder is the prerender build→serve round-trip, the loading() redirect deferral, and the client action lifecycle — each needs the real server/Flight/navigation lifecycle that the unit primitives "deliberately don't cover" (guide's traps table).

    Quick wins to land first (cheap, no server):

    1. parseCacheControl decision unit test (export the fn).
    2. renderHandler test for sync return redirect() (asserts the thrown Response).
    3. navigation-store unit test for pre-dispatch failure → no stale-broadcast.

    These shrink the e2e surface to just the genuinely-irreducible cases above.

  2. reopened this on Jun 14, 2026
  3. ivogt commented on Jun 14, 2026

    @ivogt
    CollaboratorAuthor

    Re-closing: verified complete on main. Both mandated dual-mode e2e fixtures (M16 prerender+loading+passthrough, M9 sync redirect under loading) shipped in #573, and the 3 lower-priority gaps (build-time intercept _currentSegmentId, server-action pre-dispatch over-invalidation, document-cache private/no-store veto) shipped in #575. (The earlier close was deliberate, not an accidental one.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions