Skip to content

feat(testing): loud-fail the alias trap + bare-import Prerender/createLoader/Static (#542) - #543

Merged
ivogt merged 4 commits into
feat/consumer-testing-primitivesfrom
feat/testing-capability-polish
Jun 9, 2026
Merged

ivogt merged 4 commits into
feat/consumer-testing-primitivesfrom
feat/testing-capability-polish

Conversation

@ivogt

@ivogt ivogt commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #533, addressing the capability + coverage items tracked in #542. Stacked on feat/consumer-testing-primitives (base will retarget to main after #533 merges).

Problem (consumer-observable)

  1. A handler that read getRequestContext()/cookies() in an rsc test without the rangoTestAliases resolve.alias resolved to the throwing out-of-react-server stub, and renderHandler swallowed it — the consumer saw an opaque tree: undefined + a bare stub throw with no hint about the missing alias.
  2. A whole-app router that used Prerender() / createLoader() / Static() threw "missing $$id" at construction in a bare test, so dispatch / assertGeneratedRoutesMatch against it were impossible without the Vite plugin or e2e.

What changed

  • renderHandler — loud alias trap. Detects the server-only-stub throw (dual-substring match: is only available from + react-server, instanceof Error) and rethrows an actionable RenderHandlerSetupError pointing at rangoTestAliases. A normal handler throw is not reclassified (control test included).
  • Bare-test $$id fallback for Prerender() + Static() (joining createHandle/createLoader). Each assigns a process-stable __rango_runtime_*_N id when the plugin injected none, gating the dev-throw on NODE_ENV. A router using them now constructs in bare vitest.
  • Docs/skills/dogfood corrected to the accurate contract; steer to renderServerTree over raw renderToFlightString wire matchers, assertCacheStatus over the telemetry sink.
  • Dropped a renderRoute mount-prefix guard (it was unsound — production simply prefixes include-relative patterns, so /shop + /shop → /shop/shop is legitimate, not an error).

Before / after (whole-router construct)

// before: throws "missing $$id" at construction in a bare test
const router = createRouter().routes(urls(({ path }) => [
  path.json("/api/ok", () => ({ ok: true })),
  path("/p", Static(() => <Page />)),
]));
// after: constructs; dispatch serves the response route and reports the Static
// route as an RSC route (the same error prod tooling gives) — not "missing $$id"
await dispatch(router, { request: "/api/ok" });            // 200
await dispatch(router, { request: "/p" });                 // throws /does not render RSC routes/

Semantics — handler kind × bare-test behavior

Handler Bare test (NODE_ENV≠development) pnpm dev (non-exported) Real build
Prerender() / Static() / createLoader() / createHandle() constructs via process-stable fallback $$id dev-throw (missing $$id) plugin injects real $$id; fallback never fires

Remaining whole-router-file caveat (unchanged, app/plugin-specific, not a handler-id limit): importing your real router.tsx can still fail on page-module deps or plugin virtual: modules — build whole-router checks from a focused include or run them at e2e.

Production safety

Both prod-source changes (prerender.ts, static-handler.ts) were adversarially red-teamed: Prerender 0/3 refuted; Static go-with-fix (fix = doc sync only). The fallback never fires under the plugin; prerender lookup keys on routeName+paramHash, the Static manifest keys on the plugin id, and Static's $$id is read only during real RSC serving (never in dispatch/assertGeneratedRoutesMatch — verified staticHandlerId lives on internal EntryData, not the public routeMap, so no false drift).

Test plan

  • Unit: render-handler +2 (loud rethrow + normal-throw control), render-route +1 (prefixing parity), prerender +3, static +4 (incl. a whole-router construct + dispatch integration test); updated the pre-existing static-handler-types "missing $$id" test to the gated contract.
  • Gate: typecheck 12/12, test:unit:all EXIT=0 (router 3495 + 3 todo, RSC 27), lint 0/0, format clean.
  • e2e: unaffected (testing-module + gated-prod changes; fallback never fires in a real build).

Deferred (still tracked in #542): idle-nav loud-fail and the use-cache-without-store warn — neither can be made loud without false positives / touching production; both stay documented in the trap table.

Relates to #533. Closes the buildable items of #542.

ivogt added 4 commits June 8, 2026 07:03
…eLoader/Static (#542)

Follow-up to #533, capability + coverage polish.

- renderHandler: detect the server-only-API stub throw (a handler reading
  getRequestContext()/cookies() when the rsc-project alias is missing) and
  rethrow an actionable RenderHandlerSetupError pointing at rangoTestAliases,
  instead of swallowing it into an opaque tree:undefined. Dual-substring match
  so a normal handler throw is NOT reclassified.
- Bare-test $$id fallback for the component-handler kinds — Prerender()
  (prerender.ts) and Static() (static-handler.ts) — joining createHandle /
  createLoader. Each assigns a process-stable runtime fallback $$id when the Vite
  plugin injected none, gating the dev-throw on NODE_ENV, so a router using them
  CONSTRUCTS in bare vitest (for dispatch / assertGeneratedRoutesMatch). Provably
  inert in production (never fires under the plugin; prerender lookup keys on
  routeName+paramHash; the Static manifest keys on the plugin id; Static's $$id
  is read only during real RSC serving) — both red-teamed (Prerender 0/3 refuted;
  Static go-with-fix, the only fix being doc sync). Importing a real whole-router
  FILE can still fail on app-specific page-module imports / plugin virtual:
  modules — app/plugin-specific, not a handler-id limitation.
- docs/skills/dogfood: corrected the import contract everywhere to the accurate
  statement (all three construct; the whole-router-file caveat is app/plugin
  module resolution). Steer to renderServerTree over raw renderToFlightString
  wire matchers; assertCacheStatus default, telemetry sink advanced.

Considered and dropped: a renderRoute mount-prefix guard — production simply
PREFIXES include-relative patterns (/shop + /shop -> /shop/shop), so a path
starting with the mount is a legitimate shape, not an error. The mount trap stays
documented, not enforced. (Also deferred, tracked in #542: idle-nav loud-fail;
use-cache-without-store warn.)

Tests: render-handler +2, render-route +1 (prefixing parity), prerender +3,
static +4 (incl. a whole-router construct+dispatch integration test); updated the
pre-existing static-handler-types "missing $$id" test to the gated contract.
Gate: typecheck 12/12, test:unit:all EXIT=0 (router 3495 + 3 todo, RSC 27),
lint 0/0, format.

Relates to #533.
…tes [skip ci]

The Setup-section import-contract blockquote was reflowed badly (lost the > quote
prefix on continuation lines and ate spaces around inline code: $$idis,
realrouter.tsx, pluginvirtual:). Convert it to two normal paragraphs so it renders
correctly. Also clear stale Prerender-free / blocked-by-Prerender stragglers in
cloudflare-basic + vite-rsc-demo FINDINGS to the new contract (the whole-router
FILE import is blocked by app page-module deps / plugin virtual: modules, not by
handler $$id, which now falls back).
… production throw)

Review finding (Medium): gating the missing-$$id throw on NODE_ENV==="development"
meant the runtime fallback fired in ANY non-dev runtime, including a production
`vite build`. For an UNSUPPORTED handler shape (e.g. `export let X = Static(...)`)
the Vite plugin leaves the id un-injected and untracked with no warning, so a
production build that previously threw loud ("missing $$id") would silently assign
__rango_runtime_static_0 and degrade to a runtime static/prerender miss. The
"fallback never fires under the plugin" assumption only held for SUPPORTED shapes.

Fix: gate the fallback on `process.env.VITEST` — the only signal true in both
vitest projects (the RSC project forces NODE_ENV=production) yet absent in a real
build. The fallback now fires ONLY under a test runner; a missing id in dev OR a
production build throws, restoring the pre-PR safety net. Applied to all five
create-functions (handle.ts, loader.ts, loader.rsc.ts, prerender.ts,
static-handler.ts) — they shared the identical gate and the same latent bug.

Tests: the id-guard / fallback tests now toggle VITEST (not NODE_ENV) to exercise
the throw ("throws outside a test runner" cases). Docs (testing/vitest.ts JSDoc +
docs/testing.md) note the throw-on-unsupported-shape in a real build. Gate:
typecheck 12/12, test:unit:all EXIT=0 (router 3495 + 3 todo, cloudflare-basic 39,
RSC 27), lint 0/0, format.

Relates to #533, #542.
…gnostic dev/test-only

Review finding (Medium): the previous `!process.env.VITEST` guard shipped to
production with two problems. (1) It is a BARE `process.env` access — a runtime
without `process` (browser, certain edge/worker RSC envs) can ReferenceError before
reaching the intended Rango error. (2) Unlike `process.env.NODE_ENV` (folded by the
app build `define`), `VITEST` is not folded, so switching to it un-DCE'd the rich
stack-parsing `missingInjectedIdError` diagnostic — it shipped in every prod chunk.

Fix:
- New runtime-safe helper `isUnderTestRunner()` = `!!globalThis.process?.env?.VITEST`
  (optional-chained — never a bare `process.env`). Used by all five create-fns.
- Restructure handle.ts / loader.ts / loader.rsc.ts so the rich diagnostic stays
  behind `process.env.NODE_ENV !== "production"` (build-folded -> DCE'd, tree-shakes
  missing-id-error.ts out) with a SMALL production throw in its place. Static() /
  Prerender() already throw small messages — only the runtime-safe check changed.

Verified via `pnpm --filter rangojs-e2e-mini build` + chunk inspection: shipped .js
uses `globalThis.process?.env?.VITEST` / `{}.VITEST` (no bare access); the rich-only
markers ("Unsupported create", "created at", stack parsing, node_modules filtering)
are DCE'd (0 across all chunks); only the small throw ships.

Tests toggle VITEST (not NODE_ENV); added a prod-small-throw test (NODE_ENV=production
+ no VITEST -> small, non-stack-parsing message). Gate: typecheck 12/12, test:unit:all
EXIT=0 (router 3496 + 3 todo, RSC 27), lint 0/0, format.

Relates to #533, #542.
@ivogt
ivogt merged commit 65d3e04 into feat/consumer-testing-primitives Jun 9, 2026
23 checks passed
@ivogt
ivogt deleted the feat/testing-capability-polish branch June 9, 2026 05:07
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