Skip to content

fix: SPA/prerender build hangs on module-scope handles; stale server manifest under buildCache - #137

Open
ScriptedAlchemy wants to merge 4 commits into
mainfrom
fix/spa-build-process
Open

fix: SPA/prerender build hangs on module-scope handles; stale server manifest under buildCache#137
ScriptedAlchemy wants to merge 4 commits into
mainfrom
fix/spa-build-process

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Fixes #135 and #136 (both reported with clone-and-run repros; both re-run against this branch below).

#135rsbuild build never exits with ssr: false

SPA-mode index.html generation and prerendering import()ed the freshly built server bundle into the build process. Any ref'd handle created at module scope in the app's server graph (the reporter's BroadcastChannel) then kept the event loop alive after onAfterBuild returned, and Rsbuild's build command only process.exits on the error path.

Fix: the server bundle is now evaluated in a worker_threads Worker (dist/server-build-worker.js) that is terminate()d once rendering is done. The worker serves requests over a small message protocol and, for classic builds, returns a plain-data description of the build (route table, export presence, assets.routes[*].hasLoader, prerender) — everything the prerender code reads. IS_RR_BUILD_REQUEST=yes is set inside the worker only, so app-side guards keep working and the flag no longer leaks into the build process. Applies to classic SPA mode, classic prerender, and RSC prerender.

#136 — warm buildCache build renders index.html against the previous build's assets

The node virtual/react-router/server-manifest module has constant source; its real content is injected by a transform from the web compilation's emitted asset names. Nothing Rspack hashes for that module changes between builds, so the persistent cache legitimately restored the previous build's module — and the SPA index.html imported manifest-<old>.js / root.<old>.js that no longer existed.

Fix: the plugin writes the captured manifests to <cachePath>/react-router/server-manifest.json whenever they are captured, and the server-manifest transform declares that file as a dependency (addDependency / addMissingDependency). The cache now invalidates exactly when the manifest changes; dev mode is unaffected (dev/HMR suites green).

Tests

tests/react-router-framework/integration/spa-build-process-test.ts (real builds):

  • ssr: false, prerender, and RSC prerender each exit with status 0 while the root route's graph creates a module-scope BroadcastChannel (build() helper gained a timeout + SIGKILL so a hang fails instead of stalling CI).
  • ssr: false + performance.buildCache: true: cold build → edit root → warm build; every /static/js/*.js referenced by index.html exists in the warm output and differs from the cold set.

Negative controls run locally against the pre-fix source: the hang test hangs (the process even ignores timeout's SIGTERM), and the cache test fails with the warm index.html listing the cold build's scripts.

The RSC prerender unit test mocks the worker client (the worker ships in dist/, which doesn't exist when unit tests run from source); the worker is exercised by the integration suite.

Verification

  • pnpm test (typecheck + 676 unit tests) green
  • Integration: spa-build-process, spa-mode, prerender, rsc-client-version, build, route-entry-names — 99 passed; hmr-hdr, dev — 16 passed
  • Reporter's repro repo with this branch's tarball: pnpm repro:hang → "NOT REPRODUCED: the build exited on its own with code 0 after 5.7s"; pnpm repro:stale-manifest → "NOT REPRODUCED: every URL the warm index.html references exists in the warm output"
  • README SPA Mode section now states that the server bundle is evaluated in a terminated worker with IS_RR_BUILD_REQUEST set.

Review follow-up (9ea6ee3)

  • Abort relay: the worker creates an AbortController per request and passes its signal to the app's Request; aborted when the parent releases the request (relayed abort message) or once the response is consumed / the handler fails — same contract as in-process rendering.
  • Terminal exit: every worker exit/error is recorded regardless of pending requests; later handler() calls reject immediately, and close() is terminal.
  • Protocol: the classic build description rides on the ready message (no describe round-trip).
  • Tests: tests/server-build-worker.test.ts drives the built worker with real worker threads (abort-on-release, in-flight abort relay, error mapping, idle exit, close, import failure); CI builds before unit tests. Real-build cases added to spa-build-process-test.ts (loader-observed aborts; app exiting the worker mid-build fails deterministically). Reintroducing either defect fails the corresponding tests.

Changeset: patch.

…fest

- SPA-mode index.html and prerendering (classic and RSC) evaluate the server
  bundle in a worker thread that is terminated afterwards, so a module-scope
  handle in the app's server graph cannot keep rsbuild build alive (#135).
- The node server-manifest module declares a file dependency on the captured
  manifest so Rspack's persistent cache invalidates it when the web build's
  asset names change (#136).
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-12T00:13:15.086232Z 6bf1896 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pkg-pr-new

pkg-pr-new Bot commented Sep 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

pnpm add https://pkg.pr.new/rsbuild-plugin-react-router@e2e635e

commit: e2e635e

@github-actions

github-actions Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Benchmark results

Case Base Head Delta
build-256-ssr 1589.0 ms 1601.6 ms +0.8%
dev-48-ssr 679.4 ms 676.6 ms -0.4%

@codspeed-hq

codspeed-hq Bot commented Sep 12, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 1 untouched benchmark
⏩ 2 skipped benchmarks1


Comparing fix/spa-build-process (e2e635e) with main (2b04e53)2

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (8109c7d) during the generation of this report, so 2b04e53 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@ScriptedAlchemy ScriptedAlchemy left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of 58c133a — two worker-lifecycle corrections before merge

The old #134 findings remain resolved on the unchanged base 8109c7d; this review is about the new #137 delta. Keep mandatory MF async startup and non-eager sharing. Nothing here calls for disabling either requirement, changing browser automatic public paths, or reopening the previous filename/split-chunk findings.

I checked the 13-file diff and relevant prerender callers, and verified Build Test run 34661224734: unit tests, package build, publint, ecosystem tests and example E2E all succeeded at this head. The worker isolation and declared cache dependency address the right boundaries; do not replace them with process-wide handle scanning, forced process.exit, or broad cache disabling.

The two inline findings are reproducible in the new transport:

  1. The Request reconstructed in the worker loses the caller's abort signal. Existing createBuildRequestEffect / withBuildRequest finalizers still abort the parent Request, but cleanup attached to the actual handler's Request no longer executes. A direct-handler control left zero outstanding abort-cleanup registrations after release; the worker path left one. An in-flight handler waiting for abort also remained pending after the parent aborted.
  2. An exit with an empty pending map does not mark the worker terminal. A synthetic RSC handler returned a response and then called process.exit(0) on a timer; after the worker had exited, the next handler call remained pending instead of rejecting. A worker exit must be remembered independently of whether an RPC happens to be in flight.

Verification scope

These are actual Node worker-thread probes using copies of server-build-worker-client.ts and server-build-worker.ts whose Git blob hashes match this PR (7aaba4d273a641767b9225efbb372cbed175c3ac and d33d1c10017166ce75d1141f5d56048ae8b09d7b). Only TypeScript types were erased. A synthetic RSC fetch handler supplied the controlled behavior; the unused classic React Router/resolution imports were stubbed. This is not represented as a full Rsbuild/browser reproduction. Local repository/dependency downloads were blocked by DNS.

Keep the correction small

Preserve the request lifetime inside the worker, including abort-on-completion/error and cancellation of live requests. Make worker exit/close an unconditional terminal state and reject subsequent requests. Extend an actual worker-boundary fixture; the existing abort helper unit tests cannot observe the Request inside the worker. No retries, worker pool, generic RPC framework, or additional browser matrix is needed.

A non-blocking simplification: the worker already computes the classic build description before announcing readiness. Include it in the ready reply and retain it in the client, removing the separate describe request/response branch. This reduces protocol and lifecycle states rather than adding another abstraction.

For the manifest stamp, keep the explicit dependency. Avoid rewriting identical contents where practical, but that is an I/O/cache-efficiency cleanup, not a separately reproduced stale-cache defect.

Recommendation: address the two lifecycle findings before merging; retain the architecture and current behavioral regression coverage.

Comment on lines +137 to +142
const response = await handler(
new Request(message.url, {
method: message.method,
headers: message.headers,
body: message.body as BodyInit | undefined,
})

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Preserve abort-on-release for the Request the app actually receives

This constructs a new Request with an independent, never-aborted signal. createBuildRequestEffect in prerender-build.ts still aborts the caller's controller when rendering completes/fails, and tests/prerender.test.ts explicitly checks that contract, but that abort now stops at the worker boundary. A handler that registers request-scoped cleanup with request.signal.addEventListener('abort', cleanup) no longer receives it; because one worker serves the entire prerender batch, those resources can accumulate until the whole batch is terminated.

Reproduced with the exact type-erased worker/client source and a synthetic RSC handler: increment a counter on /resource, decrement it on that Request's abort, return a body, consume the parent Response and abort the parent controller, then query /state. Direct-handler control: counter 0. Worker path: counter 1. A handler waiting for abort also never completed when the parent aborted.

Give each worker-side request an AbortController and dispose it in finally after body consumption/error; relay cancellation for requests still in flight using the existing request IDs. Add a real worker-boundary regression for cleanup on success/failure rather than another parent-only signal assertion. Do not rely solely on terminating the worker after all routes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ea6ee3. The worker now creates an AbortController per request and passes its signal to the Request the app receives; it is aborted when the parent releases the request (relayed as an abort message using the request id) or, as in the in-process path, once the response body has been consumed or the handler failed. Also took the simplification: the classic build description rides on the ready message, so the describe branch is gone.

Covered by a real-worker unit test against the built worker (tests/server-build-worker.test.ts: abort-on-release observed via the app's own request.signal listener, and an in-flight request that only resolves when the parent's abort reaches it) plus a real-build case in spa-build-process-test.ts where a root loader logs each aborted request path. Reintroducing the defect fails exactly those tests.

Comment thread src/server-build-worker-client.ts Outdated
Comment on lines +71 to +78
worker.on('exit', code => {
if (pending.size > 0) {
failAll(
new Error(
`Server build worker exited with code ${code} while rendering`
)
);
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — Record every worker exit as terminal, even when no request is pending

When the worker exits between requests, this condition leaves failure unset. The next send() adds a pending entry and posts to a dead worker, so there is no responder or future exit event to settle that Promise. Prerendering does asynchronous body/output processing between handler calls, so an exit can occur in exactly this idle window.

Reproduced with the exact client and worker source: an RSC handler returns a response and schedules process.exit(0) shortly afterwards; consume the response, wait for exit, then call handler() again. The new request stays pending instead of rejecting. This can strand a programmatic build or let a CLI stop without normal completion; it should be a deterministic build error.

Set terminal state on every exit, reject existing pending work, and reject future sends immediately. Mark explicit close() terminal too, distinguishing expected termination from unexpected exit. The pending count should control only whether there are promises to reject, not whether the exit is remembered. No restart/retry machinery is needed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ea6ee3. Every exit (and error) is now recorded as terminal regardless of the pending count, so outstanding requests reject and any later handler() call rejects immediately with Server build worker exited with code N; close() is terminal as well. No restart/retry.

Covered by tests/server-build-worker.test.ts: the fixture's fetch schedules process.exit(0) after replying, the test waits until nothing is pending, and the next two calls must reject. Reintroducing the pending.size > 0 guard makes that test fail. CI now builds before unit tests so the worker entry exists.

…exit as terminal

- Each worker-side request gets its own AbortController; the Request the app
  receives is aborted when the parent releases it (relayed 'abort' message)
  or once its response has been consumed, matching the in-process contract.
- Any worker exit is recorded as terminal regardless of pending requests, so a
  request sent to a worker that exited while idle rejects deterministically;
  close() is terminal too.
- The classic build description rides on the ready message; the separate
  describe round-trip is gone.
- Real-worker unit test (tests/server-build-worker.test.ts) against the built
  worker covers abort-on-release, in-flight abort relay, error mapping, idle
  exit, close(), and import failure; CI builds before unit tests.
- Type describeClassicBuild against ServerBuild; drop BuildRouteLike and the
  as-unknown cast. Type wire bodies as Uint8Array<ArrayBuffer> so no BodyInit
  or transfer-list casts remain. Drop the unread basename field.
- Share headerEntries via the protocol module; reuse normalizeEffectError in
  the client; inline the one-liner in server-build-resolution so the worker
  bundle no longer pulls in the Effect runtime (361 KB -> 3 KB shared chunk).
- Write the manifest stamp only when its content changed (no spurious cache
  misses or node rebuilds if the cache dir is watched); stamp the base
  manifest only; remove the unreachable non-classic write.
- Terminal-failure check moved inside the request executor; null body by
  byteLength; request.body as the has-body condition.
- Tests: rely on the harness default rsbuild.config (rsbuildConfig.basic gained
  buildCache), createEditor for the root edit, shared expectBuildSucceeded,
  one lifecycle fixture builder; drop the existsSync guard that setup.ts mocks.
- Remove dead resolveServerBuildModule re-export and PrerenderServerBuild alias.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant