From f941fc4f51dedb7b6146ad53a6f3906999ac611c Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Fri, 26 Jun 2026 23:41:59 +0000
Subject: [PATCH 01/19] docs: plan effect adoption
---
.../2026-06-26-effectts-plugin-adoption.md | 132 ++++++++++++++++++
...6-06-26-effectts-plugin-adoption-design.md | 78 +++++++++++
2 files changed, 210 insertions(+)
create mode 100644 docs/superpowers/plans/2026-06-26-effectts-plugin-adoption.md
create mode 100644 docs/superpowers/specs/2026-06-26-effectts-plugin-adoption-design.md
diff --git a/docs/superpowers/plans/2026-06-26-effectts-plugin-adoption.md b/docs/superpowers/plans/2026-06-26-effectts-plugin-adoption.md
new file mode 100644
index 0000000..c82be1f
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-26-effectts-plugin-adoption.md
@@ -0,0 +1,132 @@
+# EffectTS Plugin Adoption Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add the first internal Effect-backed scheduling seam to the route transform executor while preserving public Promise APIs and existing React Router generated output.
+
+**Architecture:** Effect is introduced as an internal runtime dependency. The first migration keeps the `RouteTransformExecutor` interface stable and uses Effect behind `close()` cleanup and Promise boundaries, leaving worker-side code untouched.
+
+**Tech Stack:** TypeScript, Rsbuild/Rspack, Rstest, pnpm, Effect 3.x.
+
+---
+
+## Files
+
+- Create: `docs/superpowers/specs/2026-06-26-effectts-plugin-adoption-design.md`
+- Create: `docs/superpowers/plans/2026-06-26-effectts-plugin-adoption.md`
+- Create: `src/effect-runtime.ts`
+- Modify: `package.json`
+- Modify: `pnpm-lock.yaml`
+- Modify: `src/parallel-route-transforms.ts`
+- Modify: `tests/parallel-route-transforms.test.ts`
+
+## Task 1: Add Design And Plan Artifacts
+
+- [ ] **Step 1: Write the design spec**
+
+Create `docs/superpowers/specs/2026-06-26-effectts-plugin-adoption-design.md` with the architecture, non-goals, rollout, and test plan.
+
+- [ ] **Step 2: Write this implementation plan**
+
+Create `docs/superpowers/plans/2026-06-26-effectts-plugin-adoption.md` with task-by-task implementation steps.
+
+- [ ] **Step 3: Commit docs**
+
+Run:
+
+```bash
+git add docs/superpowers/specs/2026-06-26-effectts-plugin-adoption-design.md docs/superpowers/plans/2026-06-26-effectts-plugin-adoption.md
+git commit -m "docs: plan effect adoption"
+```
+
+## Task 2: Add Lifecycle Tests
+
+- [ ] **Step 1: Add tests before production code**
+
+Add tests to `tests/parallel-route-transforms.test.ts` covering:
+
+- `close()` rejects an in-flight worker task.
+- `close()` is idempotent and `run()` after close falls back inline.
+- A `postMessage` failure clears the source cache so the next request sends full source.
+
+- [ ] **Step 2: Run focused tests and verify the new postMessage test fails**
+
+Run:
+
+```bash
+corepack pnpm exec rstest run -c ./rstest.config.ts tests/parallel-route-transforms.test.ts
+```
+
+Expected: the source-cache clearing test fails until the worker factory test seam and implementation are added.
+
+## Task 3: Add Effect Dependency And Boundary Helper
+
+- [ ] **Step 1: Add Effect dependency**
+
+Run:
+
+```bash
+corepack pnpm add effect
+```
+
+- [ ] **Step 2: Add `src/effect-runtime.ts`**
+
+Create a small internal helper that exports:
+
+```ts
+import { Effect } from 'effect';
+
+export const runPluginEffect = (
+ effect: Effect.Effect
+): Promise => Effect.runPromise(effect);
+
+export const normalizeEffectError = (cause: unknown): Error =>
+ cause instanceof Error ? cause : new Error(String(cause));
+```
+
+## Task 4: Migrate Route Transform Executor Cleanup
+
+- [ ] **Step 1: Add a worker factory test seam**
+
+Keep `createRouteTransformExecutor()` unchanged for callers. Add an internal exported test helper that accepts a worker factory and delegates to the same implementation.
+
+- [ ] **Step 2: Use Effect inside `close()`**
+
+Replace manual `Promise.all(workers.map(...terminate...))` cleanup with an Effect program that rejects pending tasks, clears maps, terminates workers, and returns through `runPluginEffect`.
+
+- [ ] **Step 3: Preserve behavior**
+
+Keep inline fallback after close, worker startup fallback, transform error propagation, and source-cache invalidation on `postMessage` failure.
+
+## Task 5: Verify And Publish
+
+- [ ] **Step 1: Run focused tests**
+
+```bash
+corepack pnpm exec rstest run -c ./rstest.config.ts tests/parallel-route-transforms.test.ts
+```
+
+- [ ] **Step 2: Run full verification**
+
+```bash
+corepack pnpm run test:core
+corepack pnpm exec tsc --noEmit
+corepack pnpm run format:check
+corepack pnpm run build
+corepack pnpm run test:package-interop
+git diff --check
+```
+
+- [ ] **Step 3: Commit implementation**
+
+```bash
+git add package.json pnpm-lock.yaml src/effect-runtime.ts src/parallel-route-transforms.ts tests/parallel-route-transforms.test.ts
+git commit -m "feat: use effect for transform executor cleanup"
+```
+
+- [ ] **Step 4: Push and open a draft PR**
+
+```bash
+git push -u origin codex/effectts-scheduling
+gh pr create --draft --base codex/parallel-dev-compilers --head codex/effectts-scheduling
+```
diff --git a/docs/superpowers/specs/2026-06-26-effectts-plugin-adoption-design.md b/docs/superpowers/specs/2026-06-26-effectts-plugin-adoption-design.md
new file mode 100644
index 0000000..379b61f
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-26-effectts-plugin-adoption-design.md
@@ -0,0 +1,78 @@
+# EffectTS Plugin Adoption Design
+
+## Goal
+
+Adopt Effect internally in `rsbuild-plugin-react-router` to make async scheduling, lifecycle cleanup, cancellation, retry, and error propagation easier to reason about without changing public plugin APIs or generated React Router framework code.
+
+## Scope
+
+Effect is allowed inside the plugin implementation and build tooling paths. It must not be emitted into generated React Router framework modules, templates, route code, or public plugin configuration types.
+
+The first implementation seam is the route transform executor in `src/parallel-route-transforms.ts`. It already exposes a Promise-shaped `RouteTransformExecutor` with `run()` and `close()`, which makes it possible to migrate internal scheduling while keeping callers unchanged.
+
+Later seams can build on the same internal patterns:
+
+- `src/dev-runtime-controller.ts` and `src/dev-generation.ts` for compiler attempt coordination, invalidation, retry-node scheduling, and stale evaluation cancellation.
+- `src/prerender-build.ts` for bounded prerender concurrency and fail-fast semantics.
+- Small config/preset hooks only when structured error aggregation is clearly useful.
+
+## Non-Goals
+
+- Do not expose Effect types through `PluginOptions`, `RouteTransformExecutor`, or `loadReactRouterServerBuild`.
+- Do not import Effect from `src/templates/*`.
+- Do not import Effect from generated virtual React Router modules.
+- Do not migrate `src/parallel-route-transform-worker.ts` in the first step; keep worker startup and per-task payloads lean.
+- Do not rewrite the whole plugin setup function in one pass.
+
+## Architecture
+
+Add `effect` as a runtime dependency and introduce a small internal adapter module for converting Effect computations back to Promises at Rsbuild/plugin boundaries. The plugin remains Promise-native externally, but internal resources can use Effect concepts where they simplify state:
+
+- `Effect.tryPromise` for worker termination and Promise-returning boundaries.
+- `Effect.runPromise` for preserving the existing async API surface.
+- `Effect.all` for deterministic close cleanup over worker resources.
+- Typed internal helpers for normalizing unknown causes into `Error`.
+
+The first migration keeps the existing `ParallelRouteTransformExecutor` class and replaces manual Promise orchestration in `close()` with Effect-backed cleanup. That is intentionally conservative: it proves dependency, build, type, and test compatibility before moving more subtle lifecycle code.
+
+## Route Transform Executor Behavior To Preserve
+
+- `parallelTransforms: false` runs all tasks inline.
+- Invalid `maxWorkers` throws the same validation error.
+- Worker startup errors disable workers and fall back to inline execution for later tasks.
+- Route transform task errors propagate normally.
+- `close()` is idempotent.
+- `close()` rejects in-flight worker tasks before terminating workers.
+- `run()` after `close()` executes inline.
+- A failed `postMessage` clears that worker's source cache entry so the next request for the same route sends full source again.
+
+## Test Plan
+
+Add focused tests in `tests/parallel-route-transforms.test.ts` for executor lifecycle behavior before changing implementation:
+
+- `close()` can be called more than once and still allows later inline execution.
+- `close()` rejects in-flight tasks.
+- A `postMessage` failure clears source cache and the next request sends full code.
+
+Keep existing tests green:
+
+- `corepack pnpm run test:core`
+- `corepack pnpm exec tsc --noEmit`
+- `corepack pnpm run format:check`
+- `corepack pnpm run build`
+- `corepack pnpm run test:package-interop`
+
+## Rollout
+
+1. Commit this spec and implementation plan.
+2. Add `effect` and the internal Promise boundary helper.
+3. Add failing lifecycle tests around route transform executor cleanup.
+4. Migrate route transform executor cleanup to Effect internally.
+5. Verify the existing suite and package interop.
+6. Open a draft PR stacked on the parallel dev compiler PR.
+
+## Risks
+
+Effect adds a real runtime dependency. That is acceptable only if it stays internal and removes enough scheduling complexity over the staged rollout. The first step is intentionally small so the PR proves integration cost before attempting dev-runtime migration.
+
+The dev runtime remains the highest-value target, but it has subtle Rsbuild timing behavior. It should be migrated only after the route transform executor establishes a tested internal Effect pattern.
From cb8360c52bca7c49f331c7fc6227166b9dfa49c7 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Fri, 26 Jun 2026 23:45:34 +0000
Subject: [PATCH 02/19] feat: use effect for transform executor cleanup
---
package.json | 1 +
pnpm-lock.yaml | 33 +++++++++++--
src/effect-runtime.ts | 16 ++++++
src/parallel-route-transforms.ts | 30 +++++++----
tests/parallel-route-transforms.test.ts | 66 +++++++++++++++++++++++++
5 files changed, 133 insertions(+), 13 deletions(-)
create mode 100644 src/effect-runtime.ts
diff --git a/package.json b/package.json
index 3530570..3cd46d1 100644
--- a/package.json
+++ b/package.json
@@ -76,6 +76,7 @@
"@react-router/node": "^7.13.0",
"@remix-run/node-fetch-server": "^0.13.0",
"@rspack/plugin-react-refresh": "^2.0.2",
+ "effect": "^3.21.4",
"execa": "^9.6.1",
"fs-extra": "11.3.3",
"isbot": "5.1.34",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5f9ebff..126e432 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,6 +20,9 @@ importers:
'@rspack/plugin-react-refresh':
specifier: ^2.0.2
version: 2.0.2(@rspack/core@2.1.0(@module-federation/runtime-tools@2.5.1(node-fetch@2.7.0(encoding@0.1.13)))(@swc/helpers@0.5.23))(react-refresh@0.18.0)
+ effect:
+ specifier: ^3.21.4
+ version: 3.21.4
execa:
specifier: ^9.6.1
version: 9.6.1
@@ -6240,6 +6243,9 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+ effect@3.21.4:
+ resolution: {integrity: sha512-B89v/xSgPbl1J2Ai2u18jxq3odpFauU1rC6/eSs4FeNHi72kwKdJp12VGigvRV2lK+kRnx+OOz41XV8guZd4gQ==}
+
electron-to-chromium@1.5.279:
resolution: {integrity: sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==}
@@ -6551,6 +6557,10 @@ packages:
extendable-error@0.1.7:
resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==}
+ fast-check@3.23.2:
+ resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
+ engines: {node: '>=8.0.0'}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -8257,6 +8267,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ pure-rand@6.1.0:
+ resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
+
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
@@ -12960,8 +12973,7 @@ snapshots:
'@speed-highlight/core@1.2.14': {}
- '@standard-schema/spec@1.1.0':
- optional: true
+ '@standard-schema/spec@1.1.0': {}
'@swc/helpers@0.5.23':
dependencies:
@@ -13282,6 +13294,7 @@ snapshots:
'@types/node@25.9.4':
dependencies:
undici-types: 7.24.6
+ optional: true
'@types/parse-json@4.0.2':
optional: true
@@ -14597,6 +14610,11 @@ snapshots:
ee-first@1.1.1: {}
+ effect@3.21.4:
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ fast-check: 3.23.2
+
electron-to-chromium@1.5.279: {}
electron-to-chromium@1.5.379: {}
@@ -15138,6 +15156,10 @@ snapshots:
extendable-error@0.1.7: {}
+ fast-check@3.23.2:
+ dependencies:
+ pure-rand: 6.1.0
+
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
@@ -15791,7 +15813,7 @@ snapshots:
jest-worker@27.5.1:
dependencies:
- '@types/node': 25.9.4
+ '@types/node': 25.0.10
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -16662,6 +16684,8 @@ snapshots:
punycode@2.3.1: {}
+ pure-rand@6.1.0: {}
+
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
@@ -17878,7 +17902,8 @@ snapshots:
undici-types@7.16.0: {}
- undici-types@7.24.6: {}
+ undici-types@7.24.6:
+ optional: true
undici@7.18.2: {}
diff --git a/src/effect-runtime.ts b/src/effect-runtime.ts
new file mode 100644
index 0000000..762359e
--- /dev/null
+++ b/src/effect-runtime.ts
@@ -0,0 +1,16 @@
+import { Effect } from 'effect';
+
+export const normalizeEffectError = (cause: unknown): Error =>
+ cause instanceof Error ? cause : new Error(String(cause));
+
+export const runPluginEffect = (
+ effect: Effect.Effect
+): Promise => Effect.runPromise(effect);
+
+export const tryPluginPromise = (
+ evaluate: () => PromiseLike | A
+): Effect.Effect =>
+ Effect.tryPromise({
+ try: () => Promise.resolve(evaluate()),
+ catch: normalizeEffectError,
+ });
diff --git a/src/parallel-route-transforms.ts b/src/parallel-route-transforms.ts
index 3dd61ff..cc04636 100644
--- a/src/parallel-route-transforms.ts
+++ b/src/parallel-route-transforms.ts
@@ -1,9 +1,11 @@
import { Worker } from 'node:worker_threads';
+import { Effect } from 'effect';
import { setBoundedCacheEntry } from './bounded-cache.js';
import {
getAvailableCpuCount,
getDefaultConcurrency,
} from './concurrency.js';
+import { runPluginEffect, tryPluginPromise } from './effect-runtime.js';
import {
executeRouteTransformTask,
type RouteTransformResult,
@@ -157,15 +159,25 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor {
this.#closed = true;
const workers = this.#workers ?? [];
this.#workers = [];
- this.#closePromise = Promise.all(
- workers.map(async state => {
- for (const pending of state.pending.values()) {
- pending.reject(new Error('Route transform worker closed.'));
- }
- state.pending.clear();
- await state.worker.terminate();
- })
- ).then(() => undefined);
+ this.#closePromise = runPluginEffect(
+ Effect.all(
+ workers.map(state =>
+ Effect.sync(() => {
+ for (const pending of state.pending.values()) {
+ pending.reject(new Error('Route transform worker closed.'));
+ }
+ state.pending.clear();
+ }).pipe(
+ Effect.zipRight(
+ tryPluginPromise(() => state.worker.terminate()).pipe(
+ Effect.asVoid
+ )
+ )
+ )
+ ),
+ { concurrency: 'unbounded' }
+ ).pipe(Effect.asVoid)
+ );
return this.#closePromise;
}
diff --git a/tests/parallel-route-transforms.test.ts b/tests/parallel-route-transforms.test.ts
index fdc4fe5..ac2e148 100644
--- a/tests/parallel-route-transforms.test.ts
+++ b/tests/parallel-route-transforms.test.ts
@@ -53,6 +53,7 @@ type FakeWorkerHandler = (value: any) => void;
class FakeRouteTransformWorker {
readonly messages: WorkerRequest[] = [];
readonly handlers = new Map();
+ failNextPostMessage = false;
terminateCalls = 0;
on(event: string, handler: FakeWorkerHandler): this {
@@ -61,6 +62,10 @@ class FakeRouteTransformWorker {
}
postMessage(message: WorkerRequest): void {
+ if (this.failNextPostMessage) {
+ this.failNextPostMessage = false;
+ throw new Error('postMessage failed');
+ }
this.messages.push(message);
}
@@ -173,6 +178,67 @@ describe('parallel route transforms', () => {
expect(worker.terminateCalls).toBe(1);
});
+ it('rejects in-flight worker tasks on idempotent close and runs inline afterward', async () => {
+ const worker = new FakeRouteTransformWorker();
+ const executor = createRouteTransformExecutorForTesting(
+ {
+ parallelTransforms: 1,
+ },
+ () => worker
+ );
+
+ const pending = executor.run(createRouteModuleTask());
+ expect(worker.messages).toHaveLength(1);
+
+ const firstClose = executor.close();
+ const secondClose = executor.close();
+
+ await expect(pending).rejects.toThrow('Route transform worker closed.');
+ await expect(Promise.all([firstClose, secondClose])).resolves.toEqual([
+ undefined,
+ undefined,
+ ]);
+ expect(worker.terminateCalls).toBe(1);
+
+ const inlineResult = await executor.run(createRouteModuleTask());
+ expect(inlineResult.code).toContain('export default _withComponentProps');
+ expect(worker.messages).toHaveLength(1);
+ });
+
+ it('sends full source again after a cached worker request fails to post', async () => {
+ const worker = new FakeRouteTransformWorker();
+ const executor = createRouteTransformExecutorForTesting(
+ {
+ parallelTransforms: 1,
+ },
+ () => worker
+ );
+ const task = createRouteModuleTask();
+
+ const firstRun = executor.run(task);
+ expect(worker.messages[0]?.task.code).toBe(task.code);
+ worker.emit('message', {
+ id: worker.messages[0]!.id,
+ ok: true,
+ result: { code: 'first' },
+ } satisfies WorkerResponse);
+ await expect(firstRun).resolves.toEqual({ code: 'first' });
+
+ worker.failNextPostMessage = true;
+ await expect(executor.run(task)).rejects.toThrow('postMessage failed');
+
+ const thirdRun = executor.run(task);
+ expect(worker.messages[1]?.task.code).toBe(task.code);
+ worker.emit('message', {
+ id: worker.messages[1]!.id,
+ ok: true,
+ result: { code: 'third' },
+ } satisfies WorkerResponse);
+ await expect(thirdRun).resolves.toEqual({ code: 'third' });
+
+ await executor.close();
+ });
+
it('executes route client entry tasks through the shared task executor', async () => {
await expect(
executeRouteTransformTask({
From 5cce8816ec134bbc4c60c1da38ea1cc273fe5ddb Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Fri, 26 Jun 2026 23:51:15 +0000
Subject: [PATCH 03/19] fix: align effect tests with transform config
---
.../specs/2026-06-26-effectts-plugin-adoption-design.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/superpowers/specs/2026-06-26-effectts-plugin-adoption-design.md b/docs/superpowers/specs/2026-06-26-effectts-plugin-adoption-design.md
index 379b61f..9e891e2 100644
--- a/docs/superpowers/specs/2026-06-26-effectts-plugin-adoption-design.md
+++ b/docs/superpowers/specs/2026-06-26-effectts-plugin-adoption-design.md
@@ -38,7 +38,7 @@ The first migration keeps the existing `ParallelRouteTransformExecutor` class an
## Route Transform Executor Behavior To Preserve
- `parallelTransforms: false` runs all tasks inline.
-- Invalid `maxWorkers` throws the same validation error.
+- Invalid explicit worker counts throw the same validation error.
- Worker startup errors disable workers and fall back to inline execution for later tasks.
- Route transform task errors propagate normally.
- `close()` is idempotent.
From 5130748866d79b89d16e81c49d06827e3e5d0b41 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Sat, 27 Jun 2026 06:25:39 +0000
Subject: [PATCH 04/19] feat: expand effect lifecycle orchestration
---
src/dev-runtime-controller.ts | 80 +++++++++------
src/dev-runtime-session.ts | 29 +++---
src/effect-runtime.ts | 28 +++++-
src/index.ts | 5 +-
src/parallel-route-transforms.ts | 5 +-
src/typegen.ts | 162 +++++++++++++++++++++++--------
tests/effect-runtime.test.ts | 22 +++++
tests/typegen.test.ts | 67 +++++++++++++
8 files changed, 307 insertions(+), 91 deletions(-)
create mode 100644 tests/effect-runtime.test.ts
create mode 100644 tests/typegen.test.ts
diff --git a/src/dev-runtime-controller.ts b/src/dev-runtime-controller.ts
index 4746d71..df67daa 100644
--- a/src/dev-runtime-controller.ts
+++ b/src/dev-runtime-controller.ts
@@ -1,4 +1,5 @@
import type { RsbuildConfig, RsbuildPluginAPI, Rspack } from '@rsbuild/core';
+import { Effect } from 'effect';
import type { ServerBuild } from 'react-router';
import { PLUGIN_NAME } from './constants.js';
import {
@@ -23,6 +24,12 @@ import {
createDevRuntimeSessionManager,
type RuntimeBinding,
} from './dev-runtime-session.js';
+import {
+ normalizeEffectError,
+ runPluginEffect,
+ tryPluginPromise,
+ tryPluginSync,
+} from './effect-runtime.js';
type ServerSetup = Exclude<
NonNullable['setup']>,
@@ -86,25 +93,38 @@ export const createReactRouterDevRuntimeController = ({
const compilationIdentities = createCompilationIdentityTracker();
const { getCompilationIdentity } = compilationIdentities;
- const finishRuntimeAttempt = async (
+ const finishRuntimeAttemptEffect = (
binding: RuntimeBinding,
pair: DevCompilerPair,
stats: Rspack.Stats | Rspack.MultiStats,
changes: Parameters[1],
identity: Parameters[2]
- ): Promise => {
- const result = await binding.runtime.finishAttempt(
- stats,
- changes,
- identity
+ ): Effect.Effect =>
+ tryPluginPromise(() =>
+ binding.runtime.finishAttempt(stats, changes, identity)
+ ).pipe(
+ Effect.flatMap(result =>
+ tryPluginSync(() => {
+ if (
+ result === 'retry-node' &&
+ sessions.getActiveBinding()?.id === binding.id
+ ) {
+ pair.node.watching?.invalidate();
+ }
+ })
+ )
+ );
+
+ const finishRuntimeAttempt = (
+ binding: RuntimeBinding,
+ pair: DevCompilerPair,
+ stats: Rspack.Stats | Rspack.MultiStats,
+ changes: Parameters[1],
+ identity: Parameters[2]
+ ): Promise =>
+ runPluginEffect(
+ finishRuntimeAttemptEffect(binding, pair, stats, changes, identity)
);
- if (
- result === 'retry-node' &&
- sessions.getActiveBinding()?.id === binding.id
- ) {
- pair.node.watching?.invalidate();
- }
- };
const flushSettledAttempt = (
binding: RuntimeBinding,
@@ -126,23 +146,23 @@ export const createReactRouterDevRuntimeController = ({
) {
return;
}
- void binding.runtime
- .finishAttempt(pending.stats, pending.changes, pending.identity)
- .then(result => {
- if (
- result === 'retry-node' &&
- sessions.getActiveBinding()?.id === binding.id
- ) {
- pair.node.watching?.invalidate();
- }
- })
- .catch(cause => {
- if (sessions.getActiveBinding()?.id === binding.id) {
- binding.runtime.failAttempt(
- cause instanceof Error ? cause : new Error(String(cause))
- );
- }
- });
+ void runPluginEffect(
+ finishRuntimeAttemptEffect(
+ binding,
+ pair,
+ pending.stats,
+ pending.changes,
+ pending.identity
+ ).pipe(
+ Effect.catchAll(cause =>
+ tryPluginSync(() => {
+ if (sessions.getActiveBinding()?.id === binding.id) {
+ binding.runtime.failAttempt(normalizeEffectError(cause));
+ }
+ })
+ )
+ )
+ );
};
const rejectUnsupportedCompiler = (reason: string): void => {
diff --git a/src/dev-runtime-session.ts b/src/dev-runtime-session.ts
index 047a3c9..d07305f 100644
--- a/src/dev-runtime-session.ts
+++ b/src/dev-runtime-session.ts
@@ -1,7 +1,13 @@
import type { RsbuildDevServer } from '@rsbuild/core';
+import { Effect } from 'effect';
import { PLUGIN_NAME } from './constants.js';
import type { ReactRouterDevRuntime } from './dev-generation.js';
import type { DevCompilerPair } from './dev-runtime-compilation.js';
+import {
+ runPluginEffect,
+ tryPluginPromise,
+ tryPluginSync,
+} from './effect-runtime.js';
export type RuntimeBinding = {
id: number;
@@ -109,18 +115,19 @@ export const createDevRuntimeSessionManager = (
if (observation.promise) {
return observation.promise;
}
- let closePromise: Promise;
- try {
- closePromise = close();
- } catch (cause) {
- closePromise = Promise.reject(cause);
- }
- observation.promise = closePromise;
- void closePromise.then(
- () => applyCloseOutcome(observation, { ok: true }),
- cause => applyCloseOutcome(observation, { ok: false, cause })
+ observation.promise = runPluginEffect(
+ tryPluginPromise(close).pipe(
+ Effect.tap(() =>
+ tryPluginSync(() => applyCloseOutcome(observation, { ok: true }))
+ ),
+ Effect.catchAll(cause =>
+ tryPluginSync(() =>
+ applyCloseOutcome(observation, { ok: false, cause })
+ ).pipe(Effect.zipRight(Effect.fail(cause)))
+ )
+ )
);
- return closePromise;
+ return observation.promise;
};
closeObservationByServer.set(server, observation);
return observation;
diff --git a/src/effect-runtime.ts b/src/effect-runtime.ts
index 762359e..9c9b559 100644
--- a/src/effect-runtime.ts
+++ b/src/effect-runtime.ts
@@ -1,11 +1,33 @@
-import { Effect } from 'effect';
+import { Cause, Effect, Exit, Option } from 'effect';
export const normalizeEffectError = (cause: unknown): Error =>
cause instanceof Error ? cause : new Error(String(cause));
-export const runPluginEffect = (
+const normalizeEffectCause = (cause: Cause.Cause): Error => {
+ const failure = Cause.failureOption(cause);
+ if (Option.isSome(failure)) {
+ return normalizeEffectError(failure.value);
+ }
+ return normalizeEffectError(Cause.squash(cause));
+};
+
+export const runPluginEffect = async (
effect: Effect.Effect
-): Promise => Effect.runPromise(effect);
+): Promise => {
+ const exit = await Effect.runPromiseExit(effect);
+ if (Exit.isSuccess(exit)) {
+ return exit.value;
+ }
+ throw normalizeEffectCause(exit.cause);
+};
+
+export const tryPluginSync = (
+ evaluate: () => A
+): Effect.Effect =>
+ Effect.try({
+ try: evaluate,
+ catch: normalizeEffectError,
+ });
export const tryPluginPromise = (
evaluate: () => PromiseLike | A
diff --git a/src/index.ts b/src/index.ts
index 8dcbbbb..32792b6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -412,8 +412,9 @@ export const pluginReactRouter = (
}
const isBuild = api.context.action === 'build';
- const shouldDependOnWebCompiler =
- !shouldParallelizeEnvironmentBuilds({ isBuild });
+ const shouldDependOnWebCompiler = !shouldParallelizeEnvironmentBuilds({
+ isBuild,
+ });
const isPrerenderEnabled =
prerenderConfig !== undefined && prerenderConfig !== false;
const isSpaMode = !ssr && !isPrerenderEnabled;
diff --git a/src/parallel-route-transforms.ts b/src/parallel-route-transforms.ts
index cc04636..33db0bc 100644
--- a/src/parallel-route-transforms.ts
+++ b/src/parallel-route-transforms.ts
@@ -1,10 +1,7 @@
import { Worker } from 'node:worker_threads';
import { Effect } from 'effect';
import { setBoundedCacheEntry } from './bounded-cache.js';
-import {
- getAvailableCpuCount,
- getDefaultConcurrency,
-} from './concurrency.js';
+import { getAvailableCpuCount, getDefaultConcurrency } from './concurrency.js';
import { runPluginEffect, tryPluginPromise } from './effect-runtime.js';
import {
executeRouteTransformTask,
diff --git a/src/typegen.ts b/src/typegen.ts
index de10f5e..fa80c9e 100644
--- a/src/typegen.ts
+++ b/src/typegen.ts
@@ -1,49 +1,129 @@
import type { RsbuildPluginAPI } from '@rsbuild/core';
import type { ResultPromise } from 'execa';
+import { Effect } from 'effect';
+import {
+ runPluginEffect,
+ tryPluginPromise,
+ tryPluginSync,
+} from './effect-runtime.js';
-export const registerReactRouterTypegen = (api: RsbuildPluginAPI): void => {
+type Execa = typeof import('execa').execa;
+type LoadExeca = () => Promise;
+
+export type ReactRouterTypegenRunner = {
+ startWatch(): Promise;
+ closeWatch(): Promise;
+ runBuild(): Promise;
+};
+
+const loadDefaultExeca: LoadExeca = async () => {
+ const { execa } = await import('execa');
+ return execa;
+};
+
+export const createReactRouterTypegenRunner = (
+ loadExeca: LoadExeca = loadDefaultExeca
+): ReactRouterTypegenRunner => {
let typegenProcess: ResultPromise | undefined;
- api.onBeforeStartDevServer(async () => {
- if (typegenProcess) {
- return;
- }
- const { execa } = await import('execa');
- const process = execa(
- 'npx',
- ['--yes', 'react-router', 'typegen', '--watch'],
- {
- stdio: 'inherit',
- detached: false,
- cleanup: true,
- }
+ const observeWatchExit = (process: ResultPromise): void => {
+ void runPluginEffect(
+ tryPluginPromise(() => process).pipe(
+ Effect.catchAll(() => Effect.void),
+ Effect.zipRight(
+ tryPluginSync(() => {
+ if (typegenProcess === process) {
+ typegenProcess = undefined;
+ }
+ })
+ )
+ )
);
- typegenProcess = process;
- process
- .catch(() => {
- // Ignore errors when the process is killed on server shutdown.
- })
- .finally(() => {
- if (typegenProcess === process) {
+ };
+
+ return {
+ startWatch(): Promise {
+ return runPluginEffect(
+ tryPluginSync(() => typegenProcess).pipe(
+ Effect.flatMap(activeProcess => {
+ if (activeProcess) {
+ return Effect.void;
+ }
+ return tryPluginPromise(loadExeca).pipe(
+ Effect.flatMap(execa =>
+ tryPluginSync(() =>
+ execa(
+ 'npx',
+ ['--yes', 'react-router', 'typegen', '--watch'],
+ {
+ stdio: 'inherit',
+ detached: false,
+ cleanup: true,
+ }
+ )
+ )
+ ),
+ Effect.flatMap(process =>
+ tryPluginSync(() => {
+ typegenProcess = process;
+ observeWatchExit(process);
+ })
+ )
+ );
+ })
+ )
+ );
+ },
+
+ closeWatch(): Promise {
+ return runPluginEffect(
+ tryPluginSync(() => {
+ const process = typegenProcess;
typegenProcess = undefined;
- }
- });
- });
-
- api.onCloseDevServer(async () => {
- const process = typegenProcess;
- typegenProcess = undefined;
- if (!process) {
- return;
- }
- process.kill('SIGTERM');
- await process.catch(() => undefined);
- });
-
- api.onBeforeBuild(async () => {
- const { execa } = await import('execa');
- await execa('npx', ['--yes', 'react-router', 'typegen'], {
- stdio: 'inherit',
- });
- });
+ return process;
+ }).pipe(
+ Effect.flatMap(process => {
+ if (!process) {
+ return Effect.void;
+ }
+ return tryPluginSync(() => {
+ process.kill('SIGTERM');
+ }).pipe(
+ Effect.zipRight(
+ tryPluginPromise(() => process).pipe(
+ Effect.catchAll(() => Effect.void),
+ Effect.asVoid
+ )
+ )
+ );
+ })
+ )
+ );
+ },
+
+ runBuild(): Promise {
+ return runPluginEffect(
+ tryPluginPromise(loadExeca).pipe(
+ Effect.flatMap(execa =>
+ tryPluginPromise(() =>
+ execa('npx', ['--yes', 'react-router', 'typegen'], {
+ stdio: 'inherit',
+ })
+ )
+ ),
+ Effect.asVoid
+ )
+ );
+ },
+ };
+};
+
+export const registerReactRouterTypegen = (api: RsbuildPluginAPI): void => {
+ const runner = createReactRouterTypegenRunner();
+
+ api.onBeforeStartDevServer(() => runner.startWatch());
+
+ api.onCloseDevServer(() => runner.closeWatch());
+
+ api.onBeforeBuild(() => runner.runBuild());
};
diff --git a/tests/effect-runtime.test.ts b/tests/effect-runtime.test.ts
new file mode 100644
index 0000000..a0e11e1
--- /dev/null
+++ b/tests/effect-runtime.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from '@rstest/core';
+import { runPluginEffect, tryPluginSync } from '../src/effect-runtime';
+
+describe('effect runtime helpers', () => {
+ it('preserves typed errors at promise boundaries', async () => {
+ const error = new Error('typed failure');
+
+ await expect(runPluginEffect(tryPluginSync(() => {
+ throw error;
+ }))).rejects.toBe(error);
+ });
+
+ it('normalizes synchronous thrown causes to errors', async () => {
+ await expect(
+ runPluginEffect(
+ tryPluginSync(() => {
+ throw 'dev runtime failed';
+ })
+ )
+ ).rejects.toThrow('dev runtime failed');
+ });
+});
diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts
new file mode 100644
index 0000000..d716e5e
--- /dev/null
+++ b/tests/typegen.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it, rstest } from '@rstest/core';
+import type { ResultPromise } from 'execa';
+import { createReactRouterTypegenRunner } from '../src/typegen';
+
+const createProcess = () => {
+ let rejectProcess!: (error: Error) => void;
+ const process = new Promise((_resolve, reject) => {
+ rejectProcess = reject;
+ }) as ResultPromise;
+ process.kill = rstest.fn(() => {
+ rejectProcess(new Error('killed'));
+ return true;
+ });
+ return { process, rejectProcess };
+};
+
+describe('React Router typegen runner', () => {
+ it('starts one watch process and kills it on close', async () => {
+ const first = createProcess();
+ const second = createProcess();
+ const execa = rstest
+ .fn()
+ .mockReturnValueOnce(first.process)
+ .mockReturnValueOnce(second.process);
+ const runner = createReactRouterTypegenRunner(async () => execa);
+
+ await runner.startWatch();
+ await runner.startWatch();
+ expect(execa).toHaveBeenCalledTimes(1);
+
+ await runner.closeWatch();
+ expect(first.process.kill).toHaveBeenCalledWith('SIGTERM');
+
+ await runner.startWatch();
+ expect(execa).toHaveBeenCalledTimes(2);
+ });
+
+ it('clears a watch process after it exits by itself', async () => {
+ const first = createProcess();
+ const second = createProcess();
+ const execa = rstest
+ .fn()
+ .mockReturnValueOnce(first.process)
+ .mockReturnValueOnce(second.process);
+ const runner = createReactRouterTypegenRunner(async () => execa);
+
+ await runner.startWatch();
+ first.rejectProcess(new Error('watch exited'));
+ await new Promise(resolve => setImmediate(resolve));
+
+ await runner.startWatch();
+ expect(execa).toHaveBeenCalledTimes(2);
+ });
+
+ it('runs one-shot build typegen through npx', async () => {
+ const execa = rstest.fn().mockResolvedValue(undefined);
+ const runner = createReactRouterTypegenRunner(async () => execa);
+
+ await runner.runBuild();
+
+ expect(execa).toHaveBeenCalledWith(
+ 'npx',
+ ['--yes', 'react-router', 'typegen'],
+ { stdio: 'inherit' }
+ );
+ });
+});
From 922efe8f9956fd391587ad07c4147fc95d57c9cb Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Sat, 27 Jun 2026 06:29:45 +0000
Subject: [PATCH 05/19] perf: defer dev typegen watch startup
---
src/typegen.ts | 19 ++++++++++++++++---
tests/typegen.test.ts | 32 +++++++++++++++++++++++++++++++-
2 files changed, 47 insertions(+), 4 deletions(-)
diff --git a/src/typegen.ts b/src/typegen.ts
index fa80c9e..9c65b0c 100644
--- a/src/typegen.ts
+++ b/src/typegen.ts
@@ -118,10 +118,23 @@ export const createReactRouterTypegenRunner = (
};
};
-export const registerReactRouterTypegen = (api: RsbuildPluginAPI): void => {
- const runner = createReactRouterTypegenRunner();
+export const registerReactRouterTypegen = (
+ api: RsbuildPluginAPI,
+ runner: ReactRouterTypegenRunner = createReactRouterTypegenRunner()
+): void => {
+ let devWatchStarted = false;
- api.onBeforeStartDevServer(() => runner.startWatch());
+ api.onAfterDevCompile(() => {
+ if (devWatchStarted) {
+ return;
+ }
+ devWatchStarted = true;
+ void runner.startWatch().catch(error => {
+ api.logger.warn(
+ `[react-router] Failed to start React Router typegen watch: ${error}`
+ );
+ });
+ });
api.onCloseDevServer(() => runner.closeWatch());
diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts
index d716e5e..66440cb 100644
--- a/tests/typegen.test.ts
+++ b/tests/typegen.test.ts
@@ -1,6 +1,10 @@
import { describe, expect, it, rstest } from '@rstest/core';
import type { ResultPromise } from 'execa';
-import { createReactRouterTypegenRunner } from '../src/typegen';
+import {
+ createReactRouterTypegenRunner,
+ registerReactRouterTypegen,
+ type ReactRouterTypegenRunner,
+} from '../src/typegen';
const createProcess = () => {
let rejectProcess!: (error: Error) => void;
@@ -64,4 +68,30 @@ describe('React Router typegen runner', () => {
{ stdio: 'inherit' }
);
});
+
+ it('starts dev watch after the first dev compile without blocking startup', () => {
+ let afterDevCompile!: () => void;
+ const runner: ReactRouterTypegenRunner = {
+ startWatch: rstest.fn().mockResolvedValue(undefined),
+ closeWatch: rstest.fn().mockResolvedValue(undefined),
+ runBuild: rstest.fn().mockResolvedValue(undefined),
+ };
+ const api = {
+ logger: { warn: rstest.fn() },
+ onAfterDevCompile: rstest.fn(callback => {
+ afterDevCompile = callback;
+ }),
+ onBeforeStartDevServer: rstest.fn(),
+ onCloseDevServer: rstest.fn(),
+ onBeforeBuild: rstest.fn(),
+ };
+
+ registerReactRouterTypegen(api as never, runner);
+
+ expect(api.onBeforeStartDevServer).not.toHaveBeenCalled();
+ const result = afterDevCompile();
+ expect(result).toBeUndefined();
+ afterDevCompile();
+ expect(runner.startWatch).toHaveBeenCalledTimes(1);
+ });
});
From 1d5b9b44a40f3f5ce3410cc929f6e361b0f7577d Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Sat, 27 Jun 2026 06:37:39 +0000
Subject: [PATCH 06/19] feat: use effect for bounded prerender scheduling
---
src/prerender-build.ts | 26 +++++++++++++---------
src/typegen.ts | 22 ++++++++++---------
tests/prerender.test.ts | 48 +++++++++++++++++++++++++++++++++++++++++
tests/typegen.test.ts | 22 +++++++++++++++++++
4 files changed, 98 insertions(+), 20 deletions(-)
diff --git a/src/prerender-build.ts b/src/prerender-build.ts
index 098782b..331141d 100644
--- a/src/prerender-build.ts
+++ b/src/prerender-build.ts
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import fsExtra from 'fs-extra';
+import { Effect } from 'effect';
import type { RsbuildPluginAPI } from '@rsbuild/core';
import {
createRequestHandler,
@@ -30,6 +31,7 @@ import type {
} from './react-router-config.js';
import { resolveServerBuildModule } from './server-utils.js';
import type { PluginOptions, Route } from './types.js';
+import { runPluginEffect, tryPluginPromise } from './effect-runtime.js';
type ReactRouterManifest = Awaited<
ReturnType
@@ -401,6 +403,19 @@ const validatePrerenderPathMatches = (
}
};
+export const runBoundedPrerenderTasks = (
+ prerenderPaths: string[],
+ concurrency: number,
+ renderPath: (path: string) => Promise
+): Promise =>
+ runPluginEffect(
+ Effect.forEach(
+ prerenderPaths,
+ path => tryPluginPromise(() => renderPath(path)),
+ { concurrency, discard: true }
+ )
+ );
+
const runPrerenderPaths = async ({
build,
requestHandler,
@@ -415,7 +430,6 @@ const runPrerenderPaths = async ({
const { api, basename, future, prerenderConfig, prerenderPaths } = options;
const buildRoutes = createPrerenderRoutes(build.routes);
const concurrency = getPrerenderConcurrency(prerenderConfig);
- const pending = new Set>();
const enqueue = async (path: string) => {
const matches = matchRoutes(buildRoutes, normalizePrerenderMatchPath(path));
@@ -492,15 +506,7 @@ const runPrerenderPaths = async ({
});
};
- for (const path of prerenderPaths) {
- const task = enqueue(path);
- pending.add(task);
- task.finally(() => pending.delete(task));
- if (pending.size >= concurrency) {
- await Promise.race(pending);
- }
- }
- await Promise.all(pending);
+ await runBoundedPrerenderTasks(prerenderPaths, concurrency, enqueue);
};
export const runReactRouterPrerenderBuild = async (
diff --git a/src/typegen.ts b/src/typegen.ts
index 9c65b0c..798724c 100644
--- a/src/typegen.ts
+++ b/src/typegen.ts
@@ -124,17 +124,19 @@ export const registerReactRouterTypegen = (
): void => {
let devWatchStarted = false;
- api.onAfterDevCompile(() => {
- if (devWatchStarted) {
- return;
- }
- devWatchStarted = true;
- void runner.startWatch().catch(error => {
- api.logger.warn(
- `[react-router] Failed to start React Router typegen watch: ${error}`
- );
+ if (api.context.action !== 'build') {
+ api.onAfterDevCompile(() => {
+ if (devWatchStarted) {
+ return;
+ }
+ devWatchStarted = true;
+ void runner.startWatch().catch(error => {
+ api.logger.warn(
+ `[react-router] Failed to start React Router typegen watch: ${error}`
+ );
+ });
});
- });
+ }
api.onCloseDevServer(() => runner.closeWatch());
diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts
index 2e827c6..d4a07af 100644
--- a/tests/prerender.test.ts
+++ b/tests/prerender.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from '@rstest/core';
+import { runBoundedPrerenderTasks } from '../src/prerender-build';
import {
createPrerenderRoutes,
getPrerenderConcurrency,
@@ -298,3 +299,50 @@ describe('prerender helpers', () => {
).toEqual([expect.stringContaining('`root` when pre-rendering')]);
});
});
+
+describe('prerender build scheduler', () => {
+ it('runs prerender tasks with a concurrency cap', async () => {
+ let active = 0;
+ let maxActive = 0;
+ const completed: string[] = [];
+
+ await runBoundedPrerenderTasks(
+ ['/slow', '/fast', '/medium'],
+ 2,
+ async path => {
+ active += 1;
+ maxActive = Math.max(maxActive, active);
+ await new Promise(resolve =>
+ setTimeout(resolve, path === '/slow' ? 15 : 1)
+ );
+ completed.push(path);
+ active -= 1;
+ }
+ );
+
+ expect(maxActive).toBeLessThanOrEqual(2);
+ expect(completed.sort()).toEqual(['/fast', '/medium', '/slow']);
+ });
+
+ it('rejects without starting later prerender tasks after an early failure', async () => {
+ const started: string[] = [];
+
+ await expect(
+ runBoundedPrerenderTasks(
+ ['/fail', '/slow', '/later'],
+ 2,
+ async path => {
+ started.push(path);
+ await new Promise(resolve =>
+ setTimeout(resolve, path === '/fail' ? 1 : 15)
+ );
+ if (path === '/fail') {
+ throw new Error('prerender failed');
+ }
+ }
+ )
+ ).rejects.toThrow('prerender failed');
+
+ expect(started).toEqual(['/fail', '/slow']);
+ });
+});
diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts
index 66440cb..7ff3ea7 100644
--- a/tests/typegen.test.ts
+++ b/tests/typegen.test.ts
@@ -77,6 +77,7 @@ describe('React Router typegen runner', () => {
runBuild: rstest.fn().mockResolvedValue(undefined),
};
const api = {
+ context: { action: 'dev' },
logger: { warn: rstest.fn() },
onAfterDevCompile: rstest.fn(callback => {
afterDevCompile = callback;
@@ -94,4 +95,25 @@ describe('React Router typegen runner', () => {
afterDevCompile();
expect(runner.startWatch).toHaveBeenCalledTimes(1);
});
+
+ it('does not register the dev watch hook during production builds', () => {
+ const runner: ReactRouterTypegenRunner = {
+ startWatch: rstest.fn().mockResolvedValue(undefined),
+ closeWatch: rstest.fn().mockResolvedValue(undefined),
+ runBuild: rstest.fn().mockResolvedValue(undefined),
+ };
+ const api = {
+ context: { action: 'build' },
+ logger: { warn: rstest.fn() },
+ onAfterDevCompile: rstest.fn(),
+ onBeforeStartDevServer: rstest.fn(),
+ onCloseDevServer: rstest.fn(),
+ onBeforeBuild: rstest.fn(),
+ };
+
+ registerReactRouterTypegen(api as never, runner);
+
+ expect(api.onAfterDevCompile).not.toHaveBeenCalled();
+ expect(api.onBeforeBuild).toHaveBeenCalled();
+ });
});
From 1238eb7e088d76abac8338a44e3f41c8b24cfbd5 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Sat, 27 Jun 2026 06:48:37 +0000
Subject: [PATCH 07/19] feat: use effect deferred for dev readiness
---
src/dev-generation.ts | 44 +++++++++++++-----------------------
tests/dev-generation.test.ts | 39 ++++++++++++++++++++++++++++++++
2 files changed, 55 insertions(+), 28 deletions(-)
diff --git a/src/dev-generation.ts b/src/dev-generation.ts
index 0c6075d..7729dfc 100644
--- a/src/dev-generation.ts
+++ b/src/dev-generation.ts
@@ -1,4 +1,5 @@
import type { RsbuildDevServer, Rspack } from '@rsbuild/core';
+import { Deferred as EffectDeferred, Effect } from 'effect';
import type { ServerBuild } from 'react-router';
import {
evaluateServerBuilds,
@@ -15,6 +16,7 @@ import {
type ReactRouterServerBuilds,
type WebArtifact,
} from './dev-runtime-artifacts.js';
+import { runPluginEffect } from './effect-runtime.js';
export { snapshotDevChangedFiles } from './dev-runtime-artifacts.js';
export type {
@@ -26,12 +28,6 @@ export type {
ReactRouterDevManifestSet,
} from './dev-runtime-artifacts.js';
-type Deferred = {
- promise: Promise;
- resolve: (value: T) => void;
- reject: (error: Error) => void;
-};
-
type CommittedGeneration = {
buildsByEntryName: ReactRouterServerBuilds;
webIdentity: DevCompilationIdentity;
@@ -44,7 +40,7 @@ type RuntimeState =
| {
kind: 'starting';
attemptId: number;
- readiness: Deferred;
+ readiness: EffectDeferred.Deferred;
}
| { kind: 'failed'; attemptId: number; error: Error }
| {
@@ -194,18 +190,10 @@ const hasOnlyCssAssetOwnershipChanges = (
});
};
-const createDeferred = (): Deferred => {
- let resolve!: (value: T) => void;
- let reject!: (error: Error) => void;
- const promise = new Promise((resolvePromise, rejectPromise) => {
- resolve = resolvePromise;
- reject = rejectPromise;
- });
- // Compilation can fail before a request asks for the build. Observe the
- // rejection now while returning the same promise to future callers.
- void promise.catch(() => undefined);
- return { promise, resolve, reject };
-};
+const createReadinessDeferred = (): EffectDeferred.Deferred<
+ CommittedGeneration,
+ Error
+> => Effect.runSync(EffectDeferred.make());
export const createReactRouterDevRuntime = ({
server,
@@ -219,7 +207,7 @@ export const createReactRouterDevRuntime = ({
let state: RuntimeState = {
kind: 'starting',
attemptId: 0,
- readiness: createDeferred(),
+ readiness: createReadinessDeferred(),
};
const manifestsByCompilation = new WeakMap<
Rspack.Compilation,
@@ -282,7 +270,7 @@ export const createReactRouterDevRuntime = ({
if (state.kind === 'starting') {
const { readiness } = state;
state = { kind: 'failed', attemptId, error };
- readiness.reject(error);
+ Effect.runSync(EffectDeferred.fail(readiness, error));
} else if (state.kind === 'ready') {
state = { ...state, pendingAttemptId: null };
}
@@ -301,7 +289,7 @@ export const createReactRouterDevRuntime = ({
if (state.kind === 'starting') {
const { readiness } = state;
state = { kind: 'ready', committed, pendingAttemptId: null };
- readiness.resolve(committed);
+ Effect.runSync(EffectDeferred.succeed(readiness, committed));
} else if (state.kind === 'ready') {
state = { kind: 'ready', committed, pendingAttemptId: null };
}
@@ -343,7 +331,7 @@ export const createReactRouterDevRuntime = ({
state = {
kind: 'starting',
attemptId,
- readiness: createDeferred(),
+ readiness: createReadinessDeferred(),
};
} else if (state.kind === 'starting') {
state = { ...state, attemptId };
@@ -552,11 +540,11 @@ export const createReactRouterDevRuntime = ({
return Promise.resolve(selectBuild(state.committed, entryName));
}
if (state.kind === 'starting') {
- const selected = state.readiness.promise.then(generation =>
- selectBuild(generation, entryName)
+ const selected = runPluginEffect(
+ EffectDeferred.await(state.readiness).pipe(
+ Effect.map(generation => selectBuild(generation, entryName))
+ )
);
- // Compilation may fail before the request awaiting this selection has
- // a chance to attach its own rejection handler.
void selected.catch(() => undefined);
return selected;
}
@@ -573,7 +561,7 @@ export const createReactRouterDevRuntime = ({
'[rsbuild-plugin-react-router] The development server closed before a React Router build was ready.'
);
if (state.kind === 'starting') {
- state.readiness.reject(closeError);
+ Effect.runSync(EffectDeferred.fail(state.readiness, closeError));
}
state = { kind: 'closed', error: closeError };
},
diff --git a/tests/dev-generation.test.ts b/tests/dev-generation.test.ts
index 9af9446..826bd77 100644
--- a/tests/dev-generation.test.ts
+++ b/tests/dev-generation.test.ts
@@ -598,6 +598,45 @@ describe('React Router development runtime', () => {
});
});
+ it('resolves all initial waiters from one committed generation', async () => {
+ const { loadBundle, runtime } = createHarness(() =>
+ createBuild('shared')
+ );
+ const web = createCompilation('web');
+ const node = createCompilation('node');
+
+ runtime.beginAttempt();
+ captureWeb(runtime, web, 'shared');
+ const firstWaiting = runtime.load();
+ const secondWaiting = runtime.load();
+ await runtime.finishAttempt(
+ createGraphStats(web, node),
+ noKnownChanges,
+ graphIdentity(web, node)
+ );
+
+ const [first, second] = await Promise.all([firstWaiting, secondWaiting]);
+ expect(first).toBe(second);
+ expect(loadBundle).toHaveBeenCalledTimes(1);
+ });
+
+ it('rejects all initial waiters when the runtime closes', async () => {
+ const { errors, runtime } = createHarness(() => createBuild('closed'));
+
+ runtime.beginAttempt();
+ const firstWaiting = runtime.load();
+ const secondWaiting = runtime.load();
+ runtime.close();
+
+ await expect(firstWaiting).rejects.toThrow(
+ 'development server closed before a React Router build was ready'
+ );
+ await expect(secondWaiting).rejects.toThrow(
+ 'development server closed before a React Router build was ready'
+ );
+ expect(errors).toEqual([]);
+ });
+
it('rejects initial waiters on a fatal compiler failure and recovers', async () => {
const { loadBundle, runtime } = createHarness(() =>
createBuild('recovered')
From c6e44abba9321ac30a3109c608727d4cf4e9289b Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Sat, 27 Jun 2026 06:53:21 +0000
Subject: [PATCH 08/19] perf: defer route topology watcher startup
---
src/index.ts | 75 +++++++++++++++++++++++++++++++++++++++++-----------
1 file changed, 59 insertions(+), 16 deletions(-)
diff --git a/src/index.ts b/src/index.ts
index 32792b6..98ef7f3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -8,6 +8,7 @@ import {
type RsbuildPlugin,
type Rspack,
} from '@rsbuild/core';
+import { Effect } from 'effect';
import { createJiti } from 'jiti';
import { relative, resolve } from 'pathe';
@@ -79,6 +80,7 @@ import {
} from './performance.js';
import { mapVirtualModules } from './virtual-modules.js';
import { createReactRouterDevRuntimeController } from './dev-runtime-controller.js';
+import { runPluginEffect, tryPluginPromise } from './effect-runtime.js';
import { registerReactRouterTypegen } from './typegen.js';
export { loadReactRouterServerBuild } from './dev-generation.js';
@@ -461,27 +463,68 @@ export const pluginReactRouter = (
...routeTopologyWatchFiles,
];
let closeRouteTopologyWatcher: (() => Promise) | undefined;
+ let routeTopologyWatcherStartup: Promise | undefined;
+ let routeTopologyWatcherClosed = false;
- api.onBeforeStartDevServer(async () => {
- await ensureDevRestartMarker(routeRestartMarkerPath);
- closeRouteTopologyWatcher = await createRouteTopologyWatcher({
- watchDirectory,
- getRouteTopology: getWatchedRouteTopology,
- initialRouteTopology: createRouteTopologySnapshot(
- rootRouteFile,
- routeConfig
- ),
- restartMarkerPath: routeRestartMarkerPath,
- onRouteTopologyChange: pluginOptions.onRouteTopologyChange,
- onError: error => {
- api.logger.warn(
- `[${PLUGIN_NAME}] Failed to watch route topology changes: ${error}`
+ const reportRouteTopologyWatcherError = (error: unknown): void => {
+ api.logger.warn(
+ `[${PLUGIN_NAME}] Failed to watch route topology changes: ${error}`
+ );
+ };
+
+ const startRouteTopologyWatcher = (): void => {
+ if (
+ routeTopologyWatcherClosed ||
+ routeTopologyWatcherStartup ||
+ closeRouteTopologyWatcher
+ ) {
+ return;
+ }
+
+ routeTopologyWatcherStartup = runPluginEffect(
+ Effect.gen(function* () {
+ yield* tryPluginPromise(() =>
+ ensureDevRestartMarker(routeRestartMarkerPath)
);
- },
+ const closeWatcher = yield* tryPluginPromise(() =>
+ createRouteTopologyWatcher({
+ watchDirectory,
+ getRouteTopology: getWatchedRouteTopology,
+ initialRouteTopology: createRouteTopologySnapshot(
+ rootRouteFile,
+ routeConfig
+ ),
+ restartMarkerPath: routeRestartMarkerPath,
+ onRouteTopologyChange: pluginOptions.onRouteTopologyChange,
+ onError: reportRouteTopologyWatcherError,
+ })
+ );
+ if (routeTopologyWatcherClosed) {
+ yield* tryPluginPromise(() => closeWatcher());
+ return;
+ }
+ closeRouteTopologyWatcher = closeWatcher;
+ })
+ )
+ .catch(reportRouteTopologyWatcherError)
+ .finally(() => {
+ routeTopologyWatcherStartup = undefined;
+ });
+ };
+
+ if (!isBuild) {
+ api.onBeforeStartDevServer(() => {
+ routeTopologyWatcherClosed = false;
});
- });
+
+ api.onAfterDevCompile(() => {
+ startRouteTopologyWatcher();
+ });
+ }
api.onCloseDevServer(async () => {
+ routeTopologyWatcherClosed = true;
+ await routeTopologyWatcherStartup;
await closeRouteTopologyWatcher?.();
closeRouteTopologyWatcher = undefined;
});
From 198112eb3f5d04e04b58e20a58340abd0ae66068 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Sat, 27 Jun 2026 06:58:19 +0000
Subject: [PATCH 09/19] perf: bound route topology directory scans
---
src/route-watch.ts | 46 ++++++++++++++++++++++++----------------------
1 file changed, 24 insertions(+), 22 deletions(-)
diff --git a/src/route-watch.ts b/src/route-watch.ts
index ec26780..f4e6deb 100644
--- a/src/route-watch.ts
+++ b/src/route-watch.ts
@@ -1,11 +1,18 @@
import { watch, type FSWatcher } from 'node:fs';
import { access, mkdir, readdir, writeFile } from 'node:fs/promises';
import type { RsbuildConfig } from '@rsbuild/core';
+import { Effect } from 'effect';
import { dirname, resolve } from 'pathe';
+import { getDefaultConcurrency } from './concurrency.js';
+import { runPluginEffect, tryPluginPromise } from './effect-runtime.js';
import type { Route } from './types.js';
const ROUTE_RESTART_MARKER_ASSET = '.react-router/route-watch';
const INITIAL_RESTART_MARKER_CONTENT = 'react-router-route-watch';
+const ROUTE_DIRECTORY_SCAN_CONCURRENCY = Math.max(
+ 1,
+ Math.min(16, getDefaultConcurrency() || 1)
+);
type RouteManifestSnapshotEntry = Pick<
Route,
@@ -111,32 +118,27 @@ const areSetsEqual = (left: Set, right: Set): boolean => {
return true;
};
-const readRouteDirectories = async (
- watchDirectory: string
-): Promise> => {
+const readRouteDirectories = (watchDirectory: string): Promise> => {
const directories = new Set();
- const walkDirectory = async (directory: string): Promise => {
- let entries;
- try {
- entries = await readdir(directory, { withFileTypes: true });
- } catch {
- return;
- }
-
- directories.add(directory);
- await Promise.all(
- entries.map(async entry => {
- const entryPath = resolve(directory, entry.name);
- if (entry.isDirectory()) {
- await walkDirectory(entryPath);
- }
- })
+ const walkDirectory = (directory: string): Effect.Effect =>
+ tryPluginPromise(() => readdir(directory, { withFileTypes: true })).pipe(
+ Effect.catchAll(() => Effect.succeed([])),
+ Effect.map(entries => {
+ directories.add(directory);
+ return entries
+ .filter(entry => entry.isDirectory())
+ .map(entry => resolve(directory, entry.name));
+ }),
+ Effect.flatMap(childDirectories =>
+ Effect.forEach(childDirectories, walkDirectory, {
+ concurrency: ROUTE_DIRECTORY_SCAN_CONCURRENCY,
+ discard: true,
+ })
+ )
);
- };
- await walkDirectory(watchDirectory);
- return directories;
+ return runPluginEffect(walkDirectory(watchDirectory)).then(() => directories);
};
export const createRouteTopologyWatcher = async ({
From 04d0f4856a8528fc91cd53112c4721be3d3c993a Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Sat, 27 Jun 2026 07:07:57 +0000
Subject: [PATCH 10/19] perf: delay dev background startup tasks
---
src/effect-runtime.ts | 61 +++++++++++++++++++++++++++++++++++-
src/index.ts | 39 +++++++++++------------
src/typegen.ts | 31 ++++++++++++------
tests/effect-runtime.test.ts | 41 ++++++++++++++++++++++--
tests/typegen.test.ts | 43 ++++++++++++++++++++++---
5 files changed, 178 insertions(+), 37 deletions(-)
diff --git a/src/effect-runtime.ts b/src/effect-runtime.ts
index 9c9b559..5d8e6f6 100644
--- a/src/effect-runtime.ts
+++ b/src/effect-runtime.ts
@@ -1,4 +1,6 @@
-import { Cause, Effect, Exit, Option } from 'effect';
+import { Cause, Duration, Effect, Exit, Fiber, Option } from 'effect';
+
+export const DEV_BACKGROUND_STARTUP_DELAY_MS = 3_000;
export const normalizeEffectError = (cause: unknown): Error =>
cause instanceof Error ? cause : new Error(String(cause));
@@ -36,3 +38,60 @@ export const tryPluginPromise = (
try: () => Promise.resolve(evaluate()),
catch: normalizeEffectError,
});
+
+type DelayedPluginTask = {
+ schedule(): void;
+ cancel(): Promise;
+};
+
+export const createDelayedPluginTask = ({
+ delayMs,
+ run,
+ onError,
+}: {
+ delayMs: number;
+ run: () => Effect.Effect;
+ onError: (error: Error) => void;
+}): DelayedPluginTask => {
+ let activeFiber: ReturnType | undefined;
+ let activeToken: symbol | undefined;
+
+ return {
+ schedule(): void {
+ if (activeFiber) {
+ return;
+ }
+
+ const token = Symbol();
+ activeToken = token;
+ activeFiber = Effect.runFork(
+ Effect.sleep(Duration.millis(delayMs)).pipe(
+ Effect.zipRight(Effect.suspend(run)),
+ Effect.catchAll(error =>
+ Effect.sync(() => {
+ onError(error);
+ })
+ ),
+ Effect.ensuring(
+ Effect.sync(() => {
+ if (activeToken === token) {
+ activeToken = undefined;
+ activeFiber = undefined;
+ }
+ })
+ )
+ )
+ );
+ },
+
+ async cancel(): Promise {
+ const fiber = activeFiber;
+ activeToken = undefined;
+ activeFiber = undefined;
+ if (!fiber) {
+ return;
+ }
+ await runPluginEffect(Fiber.interrupt(fiber).pipe(Effect.asVoid));
+ },
+ };
+};
diff --git a/src/index.ts b/src/index.ts
index 98ef7f3..01d081e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -80,7 +80,11 @@ import {
} from './performance.js';
import { mapVirtualModules } from './virtual-modules.js';
import { createReactRouterDevRuntimeController } from './dev-runtime-controller.js';
-import { runPluginEffect, tryPluginPromise } from './effect-runtime.js';
+import {
+ createDelayedPluginTask,
+ DEV_BACKGROUND_STARTUP_DELAY_MS,
+ tryPluginPromise,
+} from './effect-runtime.js';
import { registerReactRouterTypegen } from './typegen.js';
export { loadReactRouterServerBuild } from './dev-generation.js';
@@ -463,7 +467,6 @@ export const pluginReactRouter = (
...routeTopologyWatchFiles,
];
let closeRouteTopologyWatcher: (() => Promise) | undefined;
- let routeTopologyWatcherStartup: Promise | undefined;
let routeTopologyWatcherClosed = false;
const reportRouteTopologyWatcherError = (error: unknown): void => {
@@ -472,16 +475,9 @@ export const pluginReactRouter = (
);
};
- const startRouteTopologyWatcher = (): void => {
- if (
- routeTopologyWatcherClosed ||
- routeTopologyWatcherStartup ||
- closeRouteTopologyWatcher
- ) {
- return;
- }
-
- routeTopologyWatcherStartup = runPluginEffect(
+ const routeTopologyWatcherTask = createDelayedPluginTask({
+ delayMs: DEV_BACKGROUND_STARTUP_DELAY_MS,
+ run: () =>
Effect.gen(function* () {
yield* tryPluginPromise(() =>
ensureDevRestartMarker(routeRestartMarkerPath)
@@ -504,12 +500,15 @@ export const pluginReactRouter = (
return;
}
closeRouteTopologyWatcher = closeWatcher;
- })
- )
- .catch(reportRouteTopologyWatcherError)
- .finally(() => {
- routeTopologyWatcherStartup = undefined;
- });
+ }),
+ onError: reportRouteTopologyWatcherError,
+ });
+
+ const scheduleRouteTopologyWatcher = (): void => {
+ if (routeTopologyWatcherClosed || closeRouteTopologyWatcher) {
+ return;
+ }
+ routeTopologyWatcherTask.schedule();
};
if (!isBuild) {
@@ -518,13 +517,13 @@ export const pluginReactRouter = (
});
api.onAfterDevCompile(() => {
- startRouteTopologyWatcher();
+ scheduleRouteTopologyWatcher();
});
}
api.onCloseDevServer(async () => {
routeTopologyWatcherClosed = true;
- await routeTopologyWatcherStartup;
+ await routeTopologyWatcherTask.cancel();
await closeRouteTopologyWatcher?.();
closeRouteTopologyWatcher = undefined;
});
diff --git a/src/typegen.ts b/src/typegen.ts
index 798724c..9b199ae 100644
--- a/src/typegen.ts
+++ b/src/typegen.ts
@@ -2,6 +2,8 @@ import type { RsbuildPluginAPI } from '@rsbuild/core';
import type { ResultPromise } from 'execa';
import { Effect } from 'effect';
import {
+ createDelayedPluginTask,
+ DEV_BACKGROUND_STARTUP_DELAY_MS,
runPluginEffect,
tryPluginPromise,
tryPluginSync,
@@ -120,25 +122,34 @@ export const createReactRouterTypegenRunner = (
export const registerReactRouterTypegen = (
api: RsbuildPluginAPI,
- runner: ReactRouterTypegenRunner = createReactRouterTypegenRunner()
+ runner: ReactRouterTypegenRunner = createReactRouterTypegenRunner(),
+ devWatchDelayMs: number = DEV_BACKGROUND_STARTUP_DELAY_MS
): void => {
- let devWatchStarted = false;
+ let devWatchScheduled = false;
+ const devWatchTask = createDelayedPluginTask({
+ delayMs: devWatchDelayMs,
+ run: () => tryPluginPromise(() => runner.startWatch()).pipe(Effect.asVoid),
+ onError(error) {
+ api.logger.warn(
+ `[react-router] Failed to start React Router typegen watch: ${error}`
+ );
+ },
+ });
if (api.context.action !== 'build') {
api.onAfterDevCompile(() => {
- if (devWatchStarted) {
+ if (devWatchScheduled) {
return;
}
- devWatchStarted = true;
- void runner.startWatch().catch(error => {
- api.logger.warn(
- `[react-router] Failed to start React Router typegen watch: ${error}`
- );
- });
+ devWatchScheduled = true;
+ devWatchTask.schedule();
});
}
- api.onCloseDevServer(() => runner.closeWatch());
+ api.onCloseDevServer(async () => {
+ await devWatchTask.cancel();
+ await runner.closeWatch();
+ });
api.onBeforeBuild(() => runner.runBuild());
};
diff --git a/tests/effect-runtime.test.ts b/tests/effect-runtime.test.ts
index a0e11e1..3c16353 100644
--- a/tests/effect-runtime.test.ts
+++ b/tests/effect-runtime.test.ts
@@ -1,5 +1,10 @@
-import { describe, expect, it } from '@rstest/core';
-import { runPluginEffect, tryPluginSync } from '../src/effect-runtime';
+import { describe, expect, it, rstest } from '@rstest/core';
+import { Effect } from 'effect';
+import {
+ createDelayedPluginTask,
+ runPluginEffect,
+ tryPluginSync,
+} from '../src/effect-runtime';
describe('effect runtime helpers', () => {
it('preserves typed errors at promise boundaries', async () => {
@@ -19,4 +24,36 @@ describe('effect runtime helpers', () => {
)
).rejects.toThrow('dev runtime failed');
});
+
+ it('runs delayed plugin tasks after their delay', async () => {
+ const run = rstest.fn();
+ const task = createDelayedPluginTask({
+ delayMs: 10,
+ run: () => Effect.sync(run),
+ onError: error => {
+ throw error;
+ },
+ });
+
+ task.schedule();
+ expect(run).not.toHaveBeenCalled();
+ await expect.poll(() => run.mock.calls.length, { timeout: 1000 }).toBe(1);
+ });
+
+ it('cancels delayed plugin tasks before they start', async () => {
+ const run = rstest.fn();
+ const task = createDelayedPluginTask({
+ delayMs: 1000,
+ run: () => Effect.sync(run),
+ onError: error => {
+ throw error;
+ },
+ });
+
+ task.schedule();
+ await task.cancel();
+ await new Promise(resolve => setTimeout(resolve, 20));
+
+ expect(run).not.toHaveBeenCalled();
+ });
});
diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts
index 7ff3ea7..d016a74 100644
--- a/tests/typegen.test.ts
+++ b/tests/typegen.test.ts
@@ -69,10 +69,11 @@ describe('React Router typegen runner', () => {
);
});
- it('starts dev watch after the first dev compile without blocking startup', () => {
+ it('starts dev watch after the first dev compile without blocking startup', async () => {
let afterDevCompile!: () => void;
+ const startWatch = rstest.fn().mockResolvedValue(undefined);
const runner: ReactRouterTypegenRunner = {
- startWatch: rstest.fn().mockResolvedValue(undefined),
+ startWatch,
closeWatch: rstest.fn().mockResolvedValue(undefined),
runBuild: rstest.fn().mockResolvedValue(undefined),
};
@@ -87,13 +88,47 @@ describe('React Router typegen runner', () => {
onBeforeBuild: rstest.fn(),
};
- registerReactRouterTypegen(api as never, runner);
+ registerReactRouterTypegen(api as never, runner, 0);
expect(api.onBeforeStartDevServer).not.toHaveBeenCalled();
const result = afterDevCompile();
expect(result).toBeUndefined();
afterDevCompile();
- expect(runner.startWatch).toHaveBeenCalledTimes(1);
+ expect(startWatch).not.toHaveBeenCalled();
+ await expect.poll(() => startWatch.mock.calls.length).toBe(1);
+ });
+
+ it('cancels delayed dev watch startup on close', async () => {
+ let afterDevCompile!: () => void;
+ let closeDevServer!: () => Promise;
+ const startWatch = rstest.fn().mockResolvedValue(undefined);
+ const closeWatch = rstest.fn().mockResolvedValue(undefined);
+ const runner: ReactRouterTypegenRunner = {
+ startWatch,
+ closeWatch,
+ runBuild: rstest.fn().mockResolvedValue(undefined),
+ };
+ const api = {
+ context: { action: 'dev' },
+ logger: { warn: rstest.fn() },
+ onAfterDevCompile: rstest.fn(callback => {
+ afterDevCompile = callback;
+ }),
+ onBeforeStartDevServer: rstest.fn(),
+ onCloseDevServer: rstest.fn(callback => {
+ closeDevServer = callback;
+ }),
+ onBeforeBuild: rstest.fn(),
+ };
+
+ registerReactRouterTypegen(api as never, runner, 1000);
+
+ afterDevCompile();
+ await closeDevServer();
+ await new Promise(resolve => setTimeout(resolve, 20));
+
+ expect(startWatch).not.toHaveBeenCalled();
+ expect(closeWatch).toHaveBeenCalledTimes(1);
});
it('does not register the dev watch hook during production builds', () => {
From fc6d87381e5a186abfdfa7390439048f468a7167 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Sat, 27 Jun 2026 07:18:04 +0000
Subject: [PATCH 11/19] feat: use effect for bounded concurrency helper
---
src/concurrency.ts | 22 +++++++++-------------
1 file changed, 9 insertions(+), 13 deletions(-)
diff --git a/src/concurrency.ts b/src/concurrency.ts
index bf51f8e..87d9f8c 100644
--- a/src/concurrency.ts
+++ b/src/concurrency.ts
@@ -1,4 +1,6 @@
import { availableParallelism, cpus } from 'node:os';
+import { Effect } from 'effect';
+import { runPluginEffect, tryPluginPromise } from './effect-runtime.js';
const DEFAULT_RESERVED_CORES = 2;
@@ -16,19 +18,13 @@ export const mapWithConcurrency = async - (
concurrency: number,
worker: (item: Item, index: number) => Promise
): Promise => {
- const results = new Array(items.length);
- let nextIndex = 0;
const workerCount = Math.max(1, Math.min(concurrency, items.length));
- await Promise.all(
- Array.from({ length: workerCount }, async () => {
- while (true) {
- const index = nextIndex++;
- if (index >= items.length) {
- return;
- }
- results[index] = await worker(items[index], index);
- }
- })
+
+ return runPluginEffect(
+ Effect.forEach(
+ items.map((item, index) => ({ item, index })),
+ ({ item, index }) => tryPluginPromise(() => worker(item, index)),
+ { concurrency: workerCount }
+ )
);
- return results;
};
From 65388f41ea0172530d10539cf554ed72d1b225c7 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Sat, 27 Jun 2026 08:05:36 +0000
Subject: [PATCH 12/19] feat: use effect for prerender request lifecycle
---
src/prerender-build.ts | 29 ++++++++++++++++++++++++++++-
src/prerender.ts | 18 ------------------
tests/prerender.test.ts | 24 ++++++++++++++++++++++--
3 files changed, 50 insertions(+), 21 deletions(-)
diff --git a/src/prerender-build.ts b/src/prerender-build.ts
index 331141d..26ba768 100644
--- a/src/prerender-build.ts
+++ b/src/prerender-build.ts
@@ -23,7 +23,6 @@ import {
getPrerenderConcurrency,
getSsrFalsePrerenderExportErrors,
normalizePrerenderMatchPath,
- withBuildRequest,
} from './prerender.js';
import type {
Config,
@@ -133,6 +132,34 @@ const createDataRequestPath = (
: `${prerenderPath.replace(/\/$/, '')}.data`;
};
+export const createBuildRequestEffect = (
+ input: string | URL,
+ init: RequestInit | undefined,
+ handle: (request: Request) => Promise
+): Effect.Effect =>
+ Effect.acquireUseRelease(
+ Effect.sync(() => new AbortController()),
+ controller =>
+ tryPluginPromise(() =>
+ handle(
+ new Request(input, {
+ ...init,
+ signal: controller.signal,
+ })
+ )
+ ),
+ controller =>
+ Effect.sync(() => {
+ controller.abort();
+ })
+ );
+
+export const withBuildRequest = (
+ input: string | URL,
+ init: RequestInit | undefined,
+ handle: (request: Request) => Promise
+): Promise => runPluginEffect(createBuildRequestEffect(input, init, handle));
+
const prerenderData = async ({
handler,
prerenderPath,
diff --git a/src/prerender.ts b/src/prerender.ts
index db17003..9da7c0a 100644
--- a/src/prerender.ts
+++ b/src/prerender.ts
@@ -82,24 +82,6 @@ export const createPrerenderRoutes = (
export const normalizePrerenderMatchPath = (path: string): string =>
`/${path}/`.replace(/^\/\/+/, '/');
-export const withBuildRequest = async (
- input: string | URL,
- init: RequestInit | undefined,
- handle: (request: Request) => Promise
-): Promise => {
- const controller = new AbortController();
- try {
- return await handle(
- new Request(input, {
- ...init,
- signal: controller.signal,
- })
- );
- } finally {
- controller.abort();
- }
-};
-
export const getSsrFalsePrerenderExportErrors = ({
routes,
manifestRoutes,
diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts
index d4a07af..5988db0 100644
--- a/tests/prerender.test.ts
+++ b/tests/prerender.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from '@rstest/core';
-import { runBoundedPrerenderTasks } from '../src/prerender-build';
+import {
+ createBuildRequestEffect,
+ runBoundedPrerenderTasks,
+ withBuildRequest,
+} from '../src/prerender-build';
import {
createPrerenderRoutes,
getPrerenderConcurrency,
@@ -7,8 +11,8 @@ import {
getSsrFalsePrerenderExportErrors,
normalizePrerenderMatchPath,
resolvePrerenderPaths,
- withBuildRequest,
} from '../src/prerender';
+import { runPluginEffect } from '../src/effect-runtime';
import type { RouteConfigEntry } from '@react-router/dev/routes';
const routes: RouteConfigEntry[] = [
@@ -161,6 +165,22 @@ describe('prerender helpers', () => {
expect(signal?.aborted).toBe(true);
});
+ it('aborts effect build request signals after the handler rejects', async () => {
+ const failure = new Error('prerender handler failed');
+ let signal: AbortSignal | undefined;
+
+ await expect(
+ runPluginEffect(
+ createBuildRequestEffect('http://localhost/about', undefined, request => {
+ signal = request.signal;
+ throw failure;
+ })
+ )
+ ).rejects.toBe(failure);
+
+ expect(signal?.aborted).toBe(true);
+ });
+
it('returns no ssr:false prerender export errors for valid prerendered routes', () => {
const manifestRoutes = {
root: { id: 'root', file: 'root.tsx', path: '' },
From 356b2bb9c4896d2e8eecd929cf38550ef8e0a745 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Sat, 27 Jun 2026 08:14:56 +0000
Subject: [PATCH 13/19] feat: use effect for config resolution
---
src/react-router-config.ts | 158 +++++++++++++++++-------------
tests/react-router-config.test.ts | 36 ++++++-
2 files changed, 125 insertions(+), 69 deletions(-)
diff --git a/src/react-router-config.ts b/src/react-router-config.ts
index b4ceb06..c113c3a 100644
--- a/src/react-router-config.ts
+++ b/src/react-router-config.ts
@@ -4,6 +4,8 @@ import type {
} from '@react-router/dev/config';
import type { NormalizedConfig } from '@rsbuild/core';
import type { RouteConfigEntry } from '@react-router/dev/routes';
+import { Effect } from 'effect';
+import { runPluginEffect, tryPluginPromise } from './effect-runtime.js';
export type BuildEndHook = {
bivarianceHack(args: {
@@ -43,6 +45,12 @@ type RouteManifestEntry = {
type RouteManifest = Record;
+type ResolveReactRouterConfigResult = {
+ resolved: ResolvedReactRouterConfig;
+ presets: NonNullable;
+ hasConfiguredServerModuleFormat: boolean;
+};
+
export type ResolvedReactRouterConfig = Readonly<{
appDirectory: string;
basename: string;
@@ -98,10 +106,15 @@ const mergeReactRouterConfig = (...configs: Config[]): Config => {
buildEnd: async (
...args: Parameters>
) => {
- await Promise.all([
- configA.buildEnd?.(...args),
- configB.buildEnd?.(...args),
- ]);
+ await runPluginEffect(
+ Effect.all(
+ [
+ tryPluginPromise(() => configA.buildEnd?.(...args)),
+ tryPluginPromise(() => configB.buildEnd?.(...args)),
+ ],
+ { discard: true }
+ )
+ );
},
}
: {}),
@@ -123,70 +136,79 @@ const mergeReactRouterConfig = (...configs: Config[]): Config => {
return configs.reduce(reducer, {});
};
-export const resolveReactRouterConfig = async (
+export const resolveReactRouterConfigEffect = (
reactRouterUserConfig: Config
-): Promise<{
- resolved: ResolvedReactRouterConfig;
- presets: NonNullable;
- hasConfiguredServerModuleFormat: boolean;
-}> => {
- const presets = await Promise.all(
- (reactRouterUserConfig.presets ?? []).map(async preset => {
- if (!preset.name) {
- throw new Error(
- 'React Router presets must have a `name` property defined.'
- );
- }
- if (!preset.reactRouterConfig) {
- return null;
- }
- const { buildEnd: _buildEnd, ...reactRouterUserConfigForPreset } =
- reactRouterUserConfig;
- const presetConfig = await preset.reactRouterConfig({
- reactRouterUserConfig: reactRouterUserConfigForPreset,
- });
- if (!presetConfig) return null;
- const { presets: _presets, ...rest } = presetConfig as Config;
- return rest;
- })
- );
-
- const userAndPresetConfigs = mergeReactRouterConfig(
- ...(presets.filter(Boolean) as Config[]),
- reactRouterUserConfig
- );
-
- const resolvedFuture: FutureConfig = {
- ...DEFAULT_CONFIG.future,
- ...(userAndPresetConfigs.future ?? {}),
- };
- const splitRouteModules =
- userAndPresetConfigs.splitRouteModules ??
- userAndPresetConfigs.future?.v8_splitRouteModules ??
- DEFAULT_CONFIG.splitRouteModules;
-
- let resolved: ResolvedReactRouterConfig = {
- ...DEFAULT_CONFIG,
- ...userAndPresetConfigs,
- future: resolvedFuture,
- splitRouteModules,
- allowedActionOrigins:
- userAndPresetConfigs.allowedActionOrigins ??
- DEFAULT_CONFIG.allowedActionOrigins,
- routes: DEFAULT_CONFIG.routes,
- unstable_routeConfig: DEFAULT_CONFIG.unstable_routeConfig,
- };
- if (!resolved.ssr) {
- resolved = {
- ...resolved,
- serverBundles: undefined,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const presets = yield* Effect.forEach(
+ reactRouterUserConfig.presets ?? [],
+ preset =>
+ Effect.gen(function* () {
+ if (!preset.name) {
+ return yield* Effect.fail(
+ new Error(
+ 'React Router presets must have a `name` property defined.'
+ )
+ );
+ }
+ if (!preset.reactRouterConfig) {
+ return null;
+ }
+ const { buildEnd: _buildEnd, ...reactRouterUserConfigForPreset } =
+ reactRouterUserConfig;
+ const presetConfig = yield* tryPluginPromise(() =>
+ preset.reactRouterConfig?.({
+ reactRouterUserConfig: reactRouterUserConfigForPreset,
+ })
+ );
+ if (!presetConfig) return null;
+ const { presets: _presets, ...rest } = presetConfig as Config;
+ return rest;
+ }),
+ { concurrency: 'unbounded' }
+ );
+
+ const userAndPresetConfigs = mergeReactRouterConfig(
+ ...(presets.filter(Boolean) as Config[]),
+ reactRouterUserConfig
+ );
+
+ const resolvedFuture: FutureConfig = {
+ ...DEFAULT_CONFIG.future,
+ ...(userAndPresetConfigs.future ?? {}),
};
- }
+ const splitRouteModules =
+ userAndPresetConfigs.splitRouteModules ??
+ userAndPresetConfigs.future?.v8_splitRouteModules ??
+ DEFAULT_CONFIG.splitRouteModules;
- return {
- resolved,
- presets: reactRouterUserConfig.presets ?? [],
- hasConfiguredServerModuleFormat:
- userAndPresetConfigs.serverModuleFormat !== undefined,
- };
-};
+ let resolved: ResolvedReactRouterConfig = {
+ ...DEFAULT_CONFIG,
+ ...userAndPresetConfigs,
+ future: resolvedFuture,
+ splitRouteModules,
+ allowedActionOrigins:
+ userAndPresetConfigs.allowedActionOrigins ??
+ DEFAULT_CONFIG.allowedActionOrigins,
+ routes: DEFAULT_CONFIG.routes,
+ unstable_routeConfig: DEFAULT_CONFIG.unstable_routeConfig,
+ };
+ if (!resolved.ssr) {
+ resolved = {
+ ...resolved,
+ serverBundles: undefined,
+ };
+ }
+
+ return {
+ resolved,
+ presets: reactRouterUserConfig.presets ?? [],
+ hasConfiguredServerModuleFormat:
+ userAndPresetConfigs.serverModuleFormat !== undefined,
+ };
+ });
+
+export const resolveReactRouterConfig = (
+ reactRouterUserConfig: Config
+): Promise =>
+ runPluginEffect(resolveReactRouterConfigEffect(reactRouterUserConfig));
diff --git a/tests/react-router-config.test.ts b/tests/react-router-config.test.ts
index 084b4f8..1b6048c 100644
--- a/tests/react-router-config.test.ts
+++ b/tests/react-router-config.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from '@rstest/core';
-import { resolveReactRouterConfig } from '../src/react-router-config';
+import {
+ resolveReactRouterConfig,
+ resolveReactRouterConfigEffect,
+} from '../src/react-router-config';
+import { runPluginEffect } from '../src/effect-runtime';
describe('resolveReactRouterConfig', () => {
it('merges presets and combines buildEnd hooks', async () => {
@@ -31,6 +35,36 @@ describe('resolveReactRouterConfig', () => {
expect(buildEndCalls).toBe(2);
});
+ it('resolves presets through the Effect config path', async () => {
+ let buildEndCalls = 0;
+ const result = await runPluginEffect(
+ resolveReactRouterConfigEffect({
+ presets: [
+ {
+ name: 'preset-a',
+ reactRouterConfig: async () => ({
+ basename: '/effect-preset',
+ buildEnd: async () => {
+ buildEndCalls += 1;
+ },
+ }),
+ },
+ ],
+ buildEnd: async () => {
+ buildEndCalls += 1;
+ },
+ })
+ );
+
+ expect(result.resolved.basename).toBe('/effect-preset');
+ await result.resolved.buildEnd?.({
+ buildManifest: { routes: {} },
+ reactRouterConfig: result.resolved,
+ viteConfig: {} as any,
+ });
+ expect(buildEndCalls).toBe(2);
+ });
+
it('preserves server bundle selection in SSR mode', async () => {
const serverBundles = async () => 'bundle';
From 928b383beb7295e4173b3309db330befa41cd379 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Sat, 27 Jun 2026 08:22:28 +0000
Subject: [PATCH 14/19] feat: use effect for server build resolution
---
src/server-utils.ts | 118 +++++++++++++++++++++++--------------
tests/server-utils.test.ts | 15 +++++
2 files changed, 90 insertions(+), 43 deletions(-)
diff --git a/src/server-utils.ts b/src/server-utils.ts
index c189b83..8f55b9c 100644
--- a/src/server-utils.ts
+++ b/src/server-utils.ts
@@ -1,5 +1,11 @@
import { resolve } from 'pathe';
+import { Effect } from 'effect';
import type { ServerBuild } from 'react-router';
+import {
+ runPluginEffect,
+ tryPluginPromise,
+ tryPluginSync,
+} from './effect-runtime.js';
import type { Route } from './types.js';
/**
@@ -124,25 +130,31 @@ function isRouteDiscovery(value: unknown): boolean {
);
}
-async function resolveBuildExports(
+function resolveBuildExportsEffect(
build: Record
-): Promise> {
+): Effect.Effect, Error, never> {
const resolved = { ...build };
- for (const key of Object.keys(build)) {
- if (!RESOLVABLE_BUILD_EXPORTS.has(key)) {
- continue;
- }
- const value = build[key];
- if (typeof value === 'function' && value.length === 0) {
- const result = value();
- resolved[key] = isPromiseLike(result) ? await result : result;
- continue;
- }
- if (isPromiseLike(value)) {
- resolved[key] = await value;
- }
- }
- return resolved;
+ return Effect.forEach(
+ Object.keys(build),
+ key =>
+ Effect.gen(function* () {
+ if (!RESOLVABLE_BUILD_EXPORTS.has(key)) {
+ return;
+ }
+ const value = build[key];
+ if (typeof value === 'function' && value.length === 0) {
+ const result = yield* tryPluginSync(() => value());
+ resolved[key] = isPromiseLike(result)
+ ? yield* tryPluginPromise(() => result)
+ : result;
+ return;
+ }
+ if (isPromiseLike(value)) {
+ resolved[key] = yield* tryPluginPromise(() => value);
+ }
+ }),
+ { discard: true }
+ ).pipe(Effect.as(resolved));
}
function isServerBuild(value: unknown): value is ServerBuild {
@@ -164,47 +176,67 @@ function isServerBuild(value: unknown): value is ServerBuild {
);
}
-async function resolveServerBuildCandidate(
+function resolveServerBuildCandidateEffect(
candidate: unknown
-): Promise {
+): Effect.Effect {
if (!isRecord(candidate)) {
- return undefined;
+ return Effect.succeed(undefined);
}
- const resolved = await resolveBuildExports(candidate);
- return isServerBuild(resolved) ? resolved : undefined;
+ return resolveBuildExportsEffect(candidate).pipe(
+ Effect.map(resolved => (isServerBuild(resolved) ? resolved : undefined))
+ );
}
-export async function resolveServerBuildModule(
+export function resolveServerBuildModuleEffect(
buildModule: unknown,
source: string
-): Promise {
- const moduleValue = await buildModule;
- const candidates = [() => moduleValue];
- if (isRecord(moduleValue)) {
- if ('default' in moduleValue) {
- candidates.push(() => moduleValue.default);
- }
- if ('module.exports' in moduleValue) {
- candidates.push(() => moduleValue['module.exports']);
+): Effect.Effect {
+ return Effect.gen(function* () {
+ const moduleValue = isPromiseLike(buildModule)
+ ? yield* tryPluginPromise(() => buildModule)
+ : buildModule;
+ const candidates = [() => moduleValue];
+ if (isRecord(moduleValue)) {
+ if ('default' in moduleValue) {
+ candidates.push(() => moduleValue.default);
+ }
+ if ('module.exports' in moduleValue) {
+ candidates.push(() => moduleValue['module.exports']);
+ }
}
- }
- for (const getCandidate of candidates) {
- const candidate = await getCandidate();
- const serverBuild = await resolveServerBuildCandidate(candidate);
- if (serverBuild) {
- return serverBuild;
+ for (const getCandidate of candidates) {
+ const candidate = yield* tryPluginSync(getCandidate);
+ const serverBuild = yield* resolveServerBuildCandidateEffect(candidate);
+ if (serverBuild) {
+ return serverBuild;
+ }
}
- }
- throw new Error(
- `[rsbuild-plugin-react-router] ${source} did not contain a valid React Router ServerBuild.`
- );
+ return yield* Effect.fail(
+ new Error(
+ `[rsbuild-plugin-react-router] ${source} did not contain a valid React Router ServerBuild.`
+ )
+ );
+ });
+}
+
+export function resolveServerBuildModule(
+ buildModule: unknown,
+ source: string
+): Promise {
+ return runPluginEffect(resolveServerBuildModuleEffect(buildModule, source));
+}
+
+export function resolveReactRouterServerBuildEffect(
+ buildModule: unknown
+): Effect.Effect {
+ return resolveServerBuildModuleEffect(buildModule, 'Imported module');
}
export function resolveReactRouterServerBuild(
buildModule: unknown
): Promise {
- return resolveServerBuildModule(buildModule, 'Imported module');
+ return runPluginEffect(resolveReactRouterServerBuildEffect(buildModule));
}
export { generateServerBuild };
diff --git a/tests/server-utils.test.ts b/tests/server-utils.test.ts
index 5547ffe..b8b718e 100644
--- a/tests/server-utils.test.ts
+++ b/tests/server-utils.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from '@rstest/core';
import type { ServerBuild } from 'react-router';
import { resolveReactRouterServerBuild } from '../src';
+import { runPluginEffect } from '../src/effect-runtime';
+import { resolveReactRouterServerBuildEffect } from '../src/server-utils';
const createBuild = (version: string): ServerBuild =>
({
@@ -71,6 +73,19 @@ describe('resolveReactRouterServerBuild', () => {
).resolves.toMatchObject({ assets: { version: 'async' } });
});
+ it('resolves server builds through the Effect path', async () => {
+ const build = createBuild('effect');
+
+ await expect(
+ runPluginEffect(
+ resolveReactRouterServerBuildEffect({
+ ...build,
+ assets: async () => build.assets,
+ })
+ )
+ ).resolves.toMatchObject({ assets: { version: 'effect' } });
+ });
+
it('rejects modules without a React Router server build', async () => {
await expect(
resolveReactRouterServerBuild({ default: { routes: {} } })
From 3c4ec9c87a24c4e328dcfb4555d2034b17301f33 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Sat, 27 Jun 2026 19:35:16 +0000
Subject: [PATCH 15/19] feat: add effect performance profiling path
---
src/performance.ts | 32 ++++++++++++++++++++++++++++++++
tests/performance.test.ts | 26 ++++++++++++++++++++++++++
2 files changed, 58 insertions(+)
diff --git a/src/performance.ts b/src/performance.ts
index f15c8e2..8a16af2 100644
--- a/src/performance.ts
+++ b/src/performance.ts
@@ -1,3 +1,5 @@
+import { Effect } from 'effect';
+
type OperationTiming = {
count: number;
// Total sums every recorded duration, so parallel work can make it larger
@@ -70,6 +72,12 @@ export type ReactRouterPerformanceProfiler = {
resource: string,
callback: () => T
): T;
+ recordEffect(
+ environment: string | undefined,
+ operation: string,
+ resource: string,
+ effect: Effect.Effect
+ ): Effect.Effect;
flush(
environment: string | undefined,
details?: Pick
@@ -225,6 +233,30 @@ export const createReactRouterPerformanceProfiler = ({
recordDuration(resolvedEnvironment, operation, resource, start, end);
}
},
+ recordEffect(environment, operation, resource, effect) {
+ if (!enabled) {
+ return effect;
+ }
+
+ const resolvedEnvironment = environment ?? 'unknown';
+ return Effect.suspend(() => {
+ const start = performance.now();
+ return effect.pipe(
+ Effect.ensuring(
+ Effect.sync(() => {
+ const end = performance.now();
+ recordDuration(
+ resolvedEnvironment,
+ operation,
+ resource,
+ start,
+ end
+ );
+ })
+ )
+ );
+ });
+ },
flush(environment, details = {}) {
if (!enabled) {
return;
diff --git a/tests/performance.test.ts b/tests/performance.test.ts
index ab9179d..13e9b4c 100644
--- a/tests/performance.test.ts
+++ b/tests/performance.test.ts
@@ -1,4 +1,6 @@
import { describe, expect, it } from '@rstest/core';
+import { Effect } from 'effect';
+import { runPluginEffect } from '../src/effect-runtime';
import { createReactRouterPerformanceProfiler } from '../src/performance';
const parsePerformanceReport = (message: string) => {
@@ -202,6 +204,30 @@ describe('React Router performance profiler', () => {
expect(report.operations['route:module'].count).toBe(1);
});
+ it('records Effect operations and preserves failures', async () => {
+ const logs: string[] = [];
+ const profiler = createReactRouterPerformanceProfiler({
+ enabled: true,
+ log: message => logs.push(message),
+ });
+ const failure = new Error('effect operation failed');
+
+ await expect(
+ runPluginEffect(
+ profiler.recordEffect(
+ 'web',
+ 'route:module',
+ 'app/routes/effect.tsx',
+ Effect.fail(failure)
+ )
+ )
+ ).rejects.toBe(failure);
+ profiler.flush('web');
+
+ const report = parsePerformanceReport(logs[0]);
+ expect(report.operations['route:module'].count).toBe(1);
+ });
+
it('does not evaluate timers or log output when disabled', async () => {
const logs: string[] = [];
const originalNow = performance.now;
From 84caf8ea2fb27fc0c61e551fffa6cf5cffb9d381 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Sat, 27 Jun 2026 19:40:09 +0000
Subject: [PATCH 16/19] feat: use effect for build manifest generation
---
src/build-manifest.ts | 173 ++++++++++++++++++++---------------
src/index.ts | 15 +--
tests/build-manifest.test.ts | 36 ++++++++
3 files changed, 144 insertions(+), 80 deletions(-)
diff --git a/src/build-manifest.ts b/src/build-manifest.ts
index 5c49bab..13e87a8 100644
--- a/src/build-manifest.ts
+++ b/src/build-manifest.ts
@@ -1,4 +1,6 @@
import { relative, resolve } from 'pathe';
+import { Effect } from 'effect';
+import { runPluginEffect, tryPluginPromise } from './effect-runtime.js';
import type { Config } from './react-router-config.js';
import type { Route } from './types.js';
@@ -57,11 +59,7 @@ const configRouteToBranchRoute = (route: Route) => ({
index: route.index,
});
-export const getBuildManifest = async ({
- reactRouterConfig,
- routes,
- rootDirectory,
-}: {
+type GetBuildManifestOptions = {
reactRouterConfig: Required<
Pick<
Config,
@@ -71,80 +69,107 @@ export const getBuildManifest = async ({
Pick;
routes: Record;
rootDirectory: string;
-}): Promise => {
- const {
- serverBundles,
- appDirectory,
- buildDirectory,
- serverBuildFile,
- future,
- } = reactRouterConfig;
-
- if (!serverBundles) {
- return { routes };
- }
+};
- const rootRelativeRoutes = Object.fromEntries(
- Object.entries(routes).map(([id, route]) => {
- const filePath = resolve(appDirectory, route.file);
- return [
- id,
- { ...route, file: normalizePath(relative(rootDirectory, filePath)) },
- ];
- })
- );
+export const getBuildManifestEffect = ({
+ reactRouterConfig,
+ routes,
+ rootDirectory,
+}: GetBuildManifestOptions): Effect.Effect<
+ BuildManifest | undefined,
+ Error,
+ never
+> =>
+ Effect.gen(function* () {
+ const {
+ serverBundles,
+ appDirectory,
+ buildDirectory,
+ serverBuildFile,
+ future,
+ } = reactRouterConfig;
+
+ if (!serverBundles) {
+ return { routes };
+ }
- const serverBuildDirectory = resolve(buildDirectory, 'server');
-
- const buildManifest: BuildManifest = {
- routes: rootRelativeRoutes,
- serverBundles: {},
- routeIdToServerBundleId: {},
- };
-
- await Promise.all(
- getAddressableRoutes(routes).map(async route => {
- const branch = getRouteBranch(routes, route.id);
- const serverBundleId = await serverBundles({
- branch: branch.map(branchRoute =>
- configRouteToBranchRoute({
- ...branchRoute,
- file: resolve(appDirectory, branchRoute.file),
- })
- ),
- });
-
- if (typeof serverBundleId !== 'string') {
- throw new Error('The "serverBundles" function must return a string');
- }
+ const rootRelativeRoutes = Object.fromEntries(
+ Object.entries(routes).map(([id, route]) => {
+ const filePath = resolve(appDirectory, route.file);
+ return [
+ id,
+ { ...route, file: normalizePath(relative(rootDirectory, filePath)) },
+ ];
+ })
+ );
+
+ const serverBuildDirectory = resolve(buildDirectory, 'server');
+
+ const buildManifest: BuildManifest = {
+ routes: rootRelativeRoutes,
+ serverBundles: {},
+ routeIdToServerBundleId: {},
+ };
- if (future?.v8_viteEnvironmentApi) {
- if (!/^[a-zA-Z0-9_]+$/.test(serverBundleId)) {
- throw new Error(
- 'The "serverBundles" function must only return strings containing alphanumeric characters and underscores.'
+ yield* Effect.forEach(
+ getAddressableRoutes(routes),
+ route =>
+ Effect.gen(function* () {
+ const branch = getRouteBranch(routes, route.id);
+ const serverBundleId = yield* tryPluginPromise(() =>
+ serverBundles({
+ branch: branch.map(branchRoute =>
+ configRouteToBranchRoute({
+ ...branchRoute,
+ file: resolve(appDirectory, branchRoute.file),
+ })
+ ),
+ })
);
- }
- } else if (!/^[a-zA-Z0-9-_]+$/.test(serverBundleId)) {
- throw new Error(
- 'The "serverBundles" function must only return strings containing alphanumeric characters, hyphens and underscores.'
- );
- }
-
- buildManifest.routeIdToServerBundleId![route.id] = serverBundleId;
- buildManifest.serverBundles![serverBundleId] ??= {
- id: serverBundleId,
- file: normalizePath(
- relative(
- rootDirectory,
- resolve(serverBuildDirectory, serverBundleId, serverBuildFile)
- )
- ),
- };
- })
- );
- return buildManifest;
-};
+ if (typeof serverBundleId !== 'string') {
+ return yield* Effect.fail(
+ new Error('The "serverBundles" function must return a string')
+ );
+ }
+
+ if (future?.v8_viteEnvironmentApi) {
+ if (!/^[a-zA-Z0-9_]+$/.test(serverBundleId)) {
+ return yield* Effect.fail(
+ new Error(
+ 'The "serverBundles" function must only return strings containing alphanumeric characters and underscores.'
+ )
+ );
+ }
+ } else if (!/^[a-zA-Z0-9-_]+$/.test(serverBundleId)) {
+ return yield* Effect.fail(
+ new Error(
+ 'The "serverBundles" function must only return strings containing alphanumeric characters, hyphens and underscores.'
+ )
+ );
+ }
+
+ buildManifest.routeIdToServerBundleId![route.id] = serverBundleId;
+ buildManifest.serverBundles![serverBundleId] ??= {
+ id: serverBundleId,
+ file: normalizePath(
+ relative(
+ rootDirectory,
+ resolve(serverBuildDirectory, serverBundleId, serverBuildFile)
+ )
+ ),
+ };
+ }),
+ { concurrency: 'unbounded', discard: true }
+ );
+
+ return buildManifest;
+ });
+
+export const getBuildManifest = (
+ options: GetBuildManifestOptions
+): Promise =>
+ runPluginEffect(getBuildManifestEffect(options));
export const getRoutesByServerBundleId = (
buildManifest: BuildManifest | undefined,
diff --git a/src/index.ts b/src/index.ts
index 01d081e..e631d3e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -64,7 +64,7 @@ import {
} from './route-watch.js';
import { validateRouteConfig } from './route-config.js';
import {
- getBuildManifest,
+ getBuildManifestEffect,
getRoutesByServerBundleId,
} from './build-manifest.js';
import {
@@ -83,6 +83,7 @@ import { createReactRouterDevRuntimeController } from './dev-runtime-controller.
import {
createDelayedPluginTask,
DEV_BACKGROUND_STARTUP_DELAY_MS,
+ runPluginEffect,
tryPluginPromise,
} from './effect-runtime.js';
import { registerReactRouterTypegen } from './typegen.js';
@@ -637,11 +638,13 @@ export const pluginReactRouter = (
},
{} as Record
);
- const buildManifest = await getBuildManifest({
- reactRouterConfig: resolvedConfigWithRoutes,
- routes,
- rootDirectory: process.cwd(),
- });
+ const buildManifest = await runPluginEffect(
+ getBuildManifestEffect({
+ reactRouterConfig: resolvedConfigWithRoutes,
+ routes,
+ rootDirectory: process.cwd(),
+ })
+ );
const routesByServerBundleId = getRoutesByServerBundleId(
buildManifest,
routes
diff --git a/tests/build-manifest.test.ts b/tests/build-manifest.test.ts
index 5361c74..d70d14e 100644
--- a/tests/build-manifest.test.ts
+++ b/tests/build-manifest.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, it } from '@rstest/core';
+import { runPluginEffect } from '../src/effect-runtime';
import type { Config } from '../src/react-router-config';
import {
getBuildManifest,
+ getBuildManifestEffect,
getRoutesByServerBundleId,
} from '../src/build-manifest';
@@ -60,6 +62,40 @@ describe('build manifest', () => {
expect(bundleRoutes['routes/about'].file).toBe('routes/about.tsx');
});
+ it('builds server bundle mapping through the Effect path', async () => {
+ const routes = {
+ root: { id: 'root', file: 'root.tsx', path: '' },
+ 'routes/about': {
+ id: 'routes/about',
+ parentId: 'root',
+ file: 'routes/about.tsx',
+ path: 'about',
+ },
+ };
+
+ const serverBundles: Config['serverBundles'] = async ({ branch }) => {
+ return `bundle_${branch.length}`;
+ };
+
+ const result = await runPluginEffect(
+ getBuildManifestEffect({
+ reactRouterConfig: {
+ appDirectory: 'app',
+ buildDirectory: 'build',
+ serverBuildFile: 'index.js',
+ future: {},
+ serverBundles,
+ },
+ routes,
+ rootDirectory: process.cwd(),
+ })
+ );
+
+ const bundleRoutes = getRoutesByServerBundleId(result, routes).bundle_2;
+ expect(bundleRoutes.root.file).toBe('root.tsx');
+ expect(bundleRoutes['routes/about'].file).toBe('routes/about.tsx');
+ });
+
it('validates server bundle IDs based on vite environment API flag', async () => {
const routes = {
root: { id: 'root', file: 'root.tsx', path: '' },
From e8ae114d81799e586617f76640a6420ba1bff848 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Sat, 27 Jun 2026 19:45:03 +0000
Subject: [PATCH 17/19] feat: use effect for dev server build evaluation
---
src/dev-generation.ts | 6 ++--
src/dev-runtime-artifacts.ts | 59 ++++++++++++++++++++++++++----------
tests/dev-generation.test.ts | 21 +++++++++++++
3 files changed, 68 insertions(+), 18 deletions(-)
diff --git a/src/dev-generation.ts b/src/dev-generation.ts
index 7729dfc..065b516 100644
--- a/src/dev-generation.ts
+++ b/src/dev-generation.ts
@@ -2,7 +2,7 @@ import type { RsbuildDevServer, Rspack } from '@rsbuild/core';
import { Deferred as EffectDeferred, Effect } from 'effect';
import type { ServerBuild } from 'react-router';
import {
- evaluateServerBuilds,
+ evaluateServerBuildsEffect,
getEnvironmentStats,
isSafeOneSidedChange,
pinServerBuildsToManifests,
@@ -472,7 +472,9 @@ export const createReactRouterDevRuntime = ({
try {
const buildsByEntryName = shouldEvaluateNode
- ? await evaluateServerBuilds(server, buildPlan.entryNames)
+ ? await runPluginEffect(
+ evaluateServerBuildsEffect(server, buildPlan.entryNames)
+ )
: previous!.buildsByEntryName;
if (!isCurrentAttempt(attemptId)) {
return 'ignored';
diff --git a/src/dev-runtime-artifacts.ts b/src/dev-runtime-artifacts.ts
index 7e3a4a2..2f3ec21 100644
--- a/src/dev-runtime-artifacts.ts
+++ b/src/dev-runtime-artifacts.ts
@@ -1,8 +1,14 @@
import { isAbsolute, relative } from 'node:path';
import type { RsbuildDevServer, Rspack } from '@rsbuild/core';
+import { Effect } from 'effect';
import type { ServerBuild } from 'react-router';
import type { ReactRouterManifestForDev } from './manifest.js';
-import { resolveServerBuildModule } from './server-utils.js';
+import {
+ normalizeEffectError,
+ runPluginEffect,
+ tryPluginPromise,
+} from './effect-runtime.js';
+import { resolveServerBuildModuleEffect } from './server-utils.js';
export type ReactRouterDevManifest = ReactRouterManifestForDev;
@@ -118,29 +124,50 @@ export const getEnvironmentStats = (
});
};
-const evaluateServerBuild = async (
+const startServerBuildEvaluationEffect = (
server: RsbuildDevServer,
entryName: string
-): Promise => {
- const loaded = await server.environments.node.loadBundle(entryName);
- return resolveServerBuildModule(
- loaded,
- `Server entry ${JSON.stringify(entryName)}`
+): Effect.Effect => {
+ let loaded: PromiseLike | unknown;
+ try {
+ loaded = server.environments.node.loadBundle(entryName);
+ } catch (cause) {
+ return Effect.fail(normalizeEffectError(cause));
+ }
+
+ return tryPluginPromise(() => loaded).pipe(
+ Effect.flatMap(buildModule =>
+ resolveServerBuildModuleEffect(
+ buildModule,
+ `Server entry ${JSON.stringify(entryName)}`
+ )
+ )
);
};
-export const evaluateServerBuilds = async (
+export const evaluateServerBuildsEffect = (
server: RsbuildDevServer,
entryNames: readonly string[]
-): Promise => {
- const evaluated = await Promise.all(
- entryNames.map(async entryName => [
- entryName,
- await evaluateServerBuild(server, entryName),
- ])
+): Effect.Effect =>
+ Effect.forEach(
+ entryNames.map(entryName =>
+ startServerBuildEvaluationEffect(server, entryName).pipe(
+ Effect.map(build => [entryName, build] as const)
+ )
+ ),
+ evaluation => evaluation,
+ { concurrency: 'unbounded' }
+ ).pipe(
+ Effect.map(
+ evaluated => Object.fromEntries(evaluated) as Record
+ )
);
- return Object.fromEntries(evaluated) as Record;
-};
+
+export const evaluateServerBuilds = (
+ server: RsbuildDevServer,
+ entryNames: readonly string[]
+): Promise =>
+ runPluginEffect(evaluateServerBuildsEffect(server, entryNames));
const assertBuildMatchesManifest = (
entryName: string,
diff --git a/tests/dev-generation.test.ts b/tests/dev-generation.test.ts
index 826bd77..b0f6d57 100644
--- a/tests/dev-generation.test.ts
+++ b/tests/dev-generation.test.ts
@@ -2,6 +2,7 @@ import type { RsbuildDevServer, Rspack } from '@rsbuild/core';
import { describe, expect, it, rstest } from '@rstest/core';
import type { ServerBuild } from 'react-router';
import { loadReactRouterServerBuild } from '../src';
+import { evaluateServerBuildsEffect } from '../src/dev-runtime-artifacts';
import {
createReactRouterDevRuntime,
registerReactRouterDevRuntime,
@@ -11,6 +12,7 @@ import {
type ReactRouterDevManifest,
type ReactRouterDevRuntime,
} from '../src/dev-generation';
+import { runPluginEffect } from '../src/effect-runtime';
const noKnownChanges: DevGraphChanges = {
web: { known: false, files: new Set() },
@@ -174,6 +176,25 @@ const createHarness = (
};
describe('React Router development runtime', () => {
+ it('evaluates server builds through the Effect path', async () => {
+ const defaultBuild = createBuild('default');
+ const nestedBuild = createBuild('nested');
+ const loadBundle = rstest.fn((entryName: string) =>
+ entryName === 'nested/entry' ? Promise.resolve(nestedBuild) : defaultBuild
+ );
+ const server = {
+ environments: { node: { loadBundle } },
+ } as unknown as RsbuildDevServer;
+
+ const builds = await runPluginEffect(
+ evaluateServerBuildsEffect(server, ['nested/entry', 'static/js/app'])
+ );
+
+ expect(builds['nested/entry']).toMatchObject({ marker: 'nested' });
+ expect(builds['static/js/app']).toMatchObject({ marker: 'default' });
+ expect(loadBundle).toHaveBeenCalledTimes(2);
+ });
+
it('publishes a validated server build pinned to its exact web manifest', async () => {
const rawBuild = createBuild('raw');
const { runtime } = createHarness(() => rawBuild);
From 1f11065baf232d11282076113c2ec82c714023c7 Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Sat, 27 Jun 2026 19:57:37 +0000
Subject: [PATCH 18/19] feat: expand effect adoption across plugin lifecycle
---
src/effect-runtime.ts | 23 +-
src/index.ts | 93 +++++---
src/manifest.ts | 414 ++++++++++++++++++---------------
src/prerender-build.ts | 113 ++++++---
src/route-artifacts.ts | 157 ++++++++-----
src/route-export-resolution.ts | 21 +-
src/route-watch.ts | 179 +++++++++-----
src/typegen.ts | 15 +-
tests/client-modules.test.ts | 39 +++-
tests/effect-runtime.test.ts | 17 ++
tests/manifest.test.ts | 35 +++
tests/prerender.test.ts | 28 +++
tests/route-artifacts.test.ts | 52 +++++
13 files changed, 795 insertions(+), 391 deletions(-)
diff --git a/src/effect-runtime.ts b/src/effect-runtime.ts
index 5d8e6f6..064ffc0 100644
--- a/src/effect-runtime.ts
+++ b/src/effect-runtime.ts
@@ -41,6 +41,7 @@ export const tryPluginPromise = (
type DelayedPluginTask = {
schedule(): void;
+ cancelEffect(): Effect.Effect;
cancel(): Promise;
};
@@ -56,6 +57,18 @@ export const createDelayedPluginTask = ({
let activeFiber: ReturnType | undefined;
let activeToken: symbol | undefined;
+ const cancelEffect = (): Effect.Effect =>
+ Effect.sync(() => {
+ const fiber = activeFiber;
+ activeToken = undefined;
+ activeFiber = undefined;
+ return fiber;
+ }).pipe(
+ Effect.flatMap(fiber =>
+ fiber ? Fiber.interrupt(fiber).pipe(Effect.asVoid) : Effect.void
+ )
+ );
+
return {
schedule(): void {
if (activeFiber) {
@@ -84,14 +97,10 @@ export const createDelayedPluginTask = ({
);
},
+ cancelEffect,
+
async cancel(): Promise {
- const fiber = activeFiber;
- activeToken = undefined;
- activeFiber = undefined;
- if (!fiber) {
- return;
- }
- await runPluginEffect(Fiber.interrupt(fiber).pipe(Effect.asVoid));
+ await runPluginEffect(cancelEffect());
},
};
};
diff --git a/src/index.ts b/src/index.ts
index e631d3e..24c31b6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -522,18 +522,33 @@ export const pluginReactRouter = (
});
}
- api.onCloseDevServer(async () => {
- routeTopologyWatcherClosed = true;
- await routeTopologyWatcherTask.cancel();
- await closeRouteTopologyWatcher?.();
- closeRouteTopologyWatcher = undefined;
- });
- api.onCloseBuild(async () => {
- await routeTransformExecutor.close();
- });
- api.onCloseDevServer(async () => {
- await routeTransformExecutor.close();
- });
+ const closeRouteTopologyWatcherEffect = (): Effect.Effect<
+ void,
+ Error,
+ never
+ > =>
+ Effect.gen(function* () {
+ routeTopologyWatcherClosed = true;
+ yield* tryPluginPromise(() => routeTopologyWatcherTask.cancel());
+ yield* tryPluginPromise(() => closeRouteTopologyWatcher?.());
+ closeRouteTopologyWatcher = undefined;
+ });
+ const closeRouteTransformExecutorEffect = (): Effect.Effect<
+ void,
+ Error,
+ never
+ > => tryPluginPromise(() => routeTransformExecutor.close());
+
+ api.onCloseDevServer(() =>
+ runPluginEffect(
+ closeRouteTopologyWatcherEffect().pipe(
+ Effect.zipRight(closeRouteTransformExecutorEffect())
+ )
+ )
+ );
+ api.onCloseBuild(() =>
+ runPluginEffect(closeRouteTransformExecutorEffect())
+ );
type ReactRouterManifest = Awaited<
ReturnType
@@ -695,31 +710,35 @@ export const pluginReactRouter = (
}
);
- api.onAfterBuild(async ({ environments }) => {
- await runReactRouterPrerenderBuild({
- api,
- hasWebEnvironment: Boolean(environments.web),
- buildDirectory,
- serverBuildFile,
- ssr,
- isPrerenderEnabled,
- prerenderConfig,
- prerenderPaths,
- basename,
- future,
- routes,
- latestBrowserManifest,
- latestBrowserManifestModuleExports,
- clientStats,
- pluginOptions,
- appDirectory,
- assetPrefix,
- routeChunkOptions,
- buildManifest,
- resolvedConfigWithRoutes,
- buildEnd,
- });
- });
+ api.onAfterBuild(({ environments }) =>
+ runPluginEffect(
+ tryPluginPromise(() =>
+ runReactRouterPrerenderBuild({
+ api,
+ hasWebEnvironment: Boolean(environments.web),
+ buildDirectory,
+ serverBuildFile,
+ ssr,
+ isPrerenderEnabled,
+ prerenderConfig,
+ prerenderPaths,
+ basename,
+ future,
+ routes,
+ latestBrowserManifest,
+ latestBrowserManifestModuleExports,
+ clientStats,
+ pluginOptions,
+ appDirectory,
+ assetPrefix,
+ routeChunkOptions,
+ buildManifest,
+ resolvedConfigWithRoutes,
+ buildEnd,
+ })
+ )
+ )
+ );
const allowedActionOriginsForBuild =
allowedActionOrigins === false ? undefined : allowedActionOrigins;
diff --git a/src/manifest.ts b/src/manifest.ts
index 3a0cb42..955bd28 100644
--- a/src/manifest.ts
+++ b/src/manifest.ts
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto';
import { dirname, isAbsolute, relative, resolve } from 'pathe';
+import { Effect } from 'effect';
import type { Route, PluginOptions, RouteManifestItem } from './types.js';
import type { RouteConfigEntry } from '@react-router/dev/routes';
import { combineURLs, createRouteId } from './plugin-utils.js';
@@ -15,7 +16,8 @@ import {
type RouteChunkConfig,
} from './route-chunks.js';
import { getRouteModuleAnalysis } from './export-utils.js';
-import { getDefaultConcurrency, mapWithConcurrency } from './concurrency.js';
+import { getDefaultConcurrency } from './concurrency.js';
+import { runPluginEffect, tryPluginPromise } from './effect-runtime.js';
const ROUTE_ANALYSIS_CONCURRENCY = Math.max(
1,
@@ -273,204 +275,248 @@ export const getReactRouterManifestChunkNames = (
return chunkNames;
};
-export async function generateReactRouterManifestForDev(
+export function generateReactRouterManifestForDevEffect(
routes: Record,
_options: PluginOptions,
clientStats: ReactRouterManifestStats | undefined,
context: string,
assetPrefix = '/',
routeChunkOptions?: RouteChunkManifestOptions
-): Promise {
- const result: Record = {};
- const splitRouteModules = routeChunkOptions?.splitRouteModules ?? false;
- const enforceSplitRouteModules = splitRouteModules === 'enforce';
- const isBuild = routeChunkOptions?.isBuild ?? false;
- const routeChunkConfig: RouteChunkConfig | null =
- splitRouteModules && routeChunkOptions?.rootRouteFile
- ? {
- splitRouteModules,
- appDirectory: context,
- rootRouteFile: routeChunkOptions.rootRouteFile,
- }
- : null;
-
- const getAssetsForChunk = (chunkName: string): string[] => {
- const assets = clientStats?.assetsByChunkName?.[chunkName];
- if (!assets) {
- return [`${DEFAULT_MANIFEST_DIR}/${chunkName}.js`];
- }
- const entrypointCssAssets =
- clientStats?.entrypointFilesByName?.[chunkName]?.filter(asset =>
- asset.endsWith('.css')
- ) ?? [];
- const cssAssets = [
- ...assets.filter(asset => asset.endsWith('.css')),
- ...entrypointCssAssets,
- ].filter((asset, index, all) => all.indexOf(asset) === index);
- const nonCssAssets = assets.filter(asset => !asset.endsWith('.css'));
-
- if (!nonCssAssets.some(asset => asset.endsWith('.js'))) {
- return [
- `${DEFAULT_MANIFEST_DIR}/${chunkName}.js`,
- ...nonCssAssets,
- ...cssAssets,
- ];
- }
- return [...nonCssAssets, ...cssAssets];
- };
-
- const getModulePathForChunk = (chunkName: string): string | undefined => {
- const assets = getAssetsForChunk(chunkName);
- const jsAssets = assets.filter(asset => asset.endsWith('.js'));
- return jsAssets[0] ? combineURLs(assetPrefix, jsAssets[0]) : undefined;
- };
+): Effect.Effect {
+ return Effect.gen(function* () {
+ const result: Record = {};
+ const splitRouteModules = routeChunkOptions?.splitRouteModules ?? false;
+ const enforceSplitRouteModules = splitRouteModules === 'enforce';
+ const isBuild = routeChunkOptions?.isBuild ?? false;
+ const routeChunkConfig: RouteChunkConfig | null =
+ splitRouteModules && routeChunkOptions?.rootRouteFile
+ ? {
+ splitRouteModules,
+ appDirectory: context,
+ rootRouteFile: routeChunkOptions.rootRouteFile,
+ }
+ : null;
+
+ const getAssetsForChunk = (chunkName: string): string[] => {
+ const assets = clientStats?.assetsByChunkName?.[chunkName];
+ if (!assets) {
+ return [`${DEFAULT_MANIFEST_DIR}/${chunkName}.js`];
+ }
+ const entrypointCssAssets =
+ clientStats?.entrypointFilesByName?.[chunkName]?.filter(asset =>
+ asset.endsWith('.css')
+ ) ?? [];
+ const cssAssets = [
+ ...assets.filter(asset => asset.endsWith('.css')),
+ ...entrypointCssAssets,
+ ].filter((asset, index, all) => all.indexOf(asset) === index);
+ const nonCssAssets = assets.filter(asset => !asset.endsWith('.css'));
+
+ if (!nonCssAssets.some(asset => asset.endsWith('.js'))) {
+ return [
+ `${DEFAULT_MANIFEST_DIR}/${chunkName}.js`,
+ ...nonCssAssets,
+ ...cssAssets,
+ ];
+ }
+ return [...nonCssAssets, ...cssAssets];
+ };
- const manifestEntries = await mapWithConcurrency(
- Object.entries(routes),
- ROUTE_ANALYSIS_CONCURRENCY,
- async ([key, route]) => {
- const routeEntryName = getRouteEntryName(route);
- const assets = getAssetsForChunk(routeEntryName);
+ const getModulePathForChunk = (chunkName: string): string | undefined => {
+ const assets = getAssetsForChunk(chunkName);
const jsAssets = assets.filter(asset => asset.endsWith('.js'));
- let cssAssets = assets.filter(asset => asset.endsWith('.css'));
- const routeFilePath = resolve(context, route.file);
- let exports = new Set();
- let routeModuleExports: readonly string[] = [];
- let hasRouteChunkByExportName: ReturnType<
- typeof createEmptyRouteChunkByExportName
- > | null = null;
-
- try {
- const { code, exports: exportNames } =
- await getRouteModuleAnalysis(routeFilePath);
- if (
- !isBuild &&
- cssAssets.length === 0 &&
- /\.(?:css|less|sass|scss)(?:\?[^'"`]+)?['"`]/.test(code)
- ) {
- cssAssets = [
- `${DEFAULT_MANIFEST_DIR.replace('/js', '/css')}/${routeEntryName}.css`,
- ];
- }
- routeModuleExports = exportNames;
- exports = new Set(exportNames);
-
- if (isBuild && routeChunkConfig) {
- const { hasRouteChunkByExportName: chunkInfo } =
- await detectRouteChunksIfEnabled(
- routeChunkOptions?.cache,
- routeChunkConfig,
- routeFilePath,
- code
- );
- hasRouteChunkByExportName = chunkInfo;
- }
- } catch (error) {
- if (isBuild) {
- throw error;
- }
- console.error(`Failed to analyze route file ${routeFilePath}:`, error);
- }
+ return jsAssets[0] ? combineURLs(assetPrefix, jsAssets[0]) : undefined;
+ };
- const hasClientAction = exports.has(CLIENT_EXPORTS.clientAction);
- const hasClientLoader = exports.has(CLIENT_EXPORTS.clientLoader);
- const hasClientMiddleware = exports.has(CLIENT_EXPORTS.clientMiddleware);
- const hasDefaultExport = exports.has('default');
- const routeChunkMap = hasRouteChunkByExportName;
-
- if (isBuild && enforceSplitRouteModules && routeChunkConfig) {
- validateRouteChunks({
- config: routeChunkConfig,
- id: routeFilePath,
- valid: buildManifestChunkValidity(
- exports,
- routeChunkMap ?? createEmptyRouteChunkByExportName()
- ),
- });
+ const manifestEntries = yield* Effect.forEach(
+ Object.entries(routes),
+ ([key, route]) =>
+ Effect.gen(function* () {
+ const routeEntryName = getRouteEntryName(route);
+ const assets = getAssetsForChunk(routeEntryName);
+ const jsAssets = assets.filter(asset => asset.endsWith('.js'));
+ let cssAssets = assets.filter(asset => asset.endsWith('.css'));
+ const routeFilePath = resolve(context, route.file);
+ let exports = new Set();
+ let routeModuleExports: readonly string[] = [];
+ let hasRouteChunkByExportName: ReturnType<
+ typeof createEmptyRouteChunkByExportName
+ > | null = null;
+
+ const routeAnalysis = yield* Effect.gen(function* () {
+ const { code, exports: exportNames } = yield* tryPluginPromise(() =>
+ getRouteModuleAnalysis(routeFilePath)
+ );
+ const fallbackCssAssets =
+ !isBuild &&
+ cssAssets.length === 0 &&
+ /\.(?:css|less|sass|scss)(?:\?[^'"`]+)?['"`]/.test(code)
+ ? [
+ `${DEFAULT_MANIFEST_DIR.replace('/js', '/css')}/${routeEntryName}.css`,
+ ]
+ : cssAssets;
+ const chunkInfo =
+ isBuild && routeChunkConfig
+ ? yield* tryPluginPromise(() =>
+ detectRouteChunksIfEnabled(
+ routeChunkOptions?.cache,
+ routeChunkConfig,
+ routeFilePath,
+ code
+ )
+ )
+ : null;
+
+ return {
+ cssAssets: fallbackCssAssets,
+ exports: new Set(exportNames),
+ routeModuleExports: exportNames,
+ hasRouteChunkByExportName:
+ chunkInfo?.hasRouteChunkByExportName ?? null,
+ };
+ }).pipe(
+ Effect.catchAll(error => {
+ if (isBuild) {
+ return Effect.fail(error);
+ }
+ return Effect.sync(() => {
+ console.error(
+ `Failed to analyze route file ${routeFilePath}:`,
+ error
+ );
+ return {
+ cssAssets,
+ exports,
+ routeModuleExports,
+ hasRouteChunkByExportName,
+ };
+ });
+ })
+ );
+
+ cssAssets = routeAnalysis.cssAssets;
+ exports = routeAnalysis.exports;
+ routeModuleExports = routeAnalysis.routeModuleExports;
+ hasRouteChunkByExportName = routeAnalysis.hasRouteChunkByExportName;
+
+ const hasClientAction = exports.has(CLIENT_EXPORTS.clientAction);
+ const hasClientLoader = exports.has(CLIENT_EXPORTS.clientLoader);
+ const hasClientMiddleware = exports.has(
+ CLIENT_EXPORTS.clientMiddleware
+ );
+ const hasDefaultExport = exports.has('default');
+ const routeChunkMap = hasRouteChunkByExportName;
+
+ if (isBuild && enforceSplitRouteModules && routeChunkConfig) {
+ validateRouteChunks({
+ config: routeChunkConfig,
+ id: routeFilePath,
+ valid: buildManifestChunkValidity(
+ exports,
+ routeChunkMap ?? createEmptyRouteChunkByExportName()
+ ),
+ });
+ }
+
+ return [
+ key,
+ {
+ id: route.id,
+ parentId: route.parentId,
+ path: route.path,
+ index: route.index,
+ caseSensitive: route.caseSensitive,
+ module: combineURLs(assetPrefix, jsAssets[0] || ''),
+ clientActionModule: routeChunkMap?.clientAction
+ ? getModulePathForChunk(
+ getRouteChunkEntryName(route.id, 'clientAction')
+ )
+ : undefined,
+ clientLoaderModule: routeChunkMap?.clientLoader
+ ? getModulePathForChunk(
+ getRouteChunkEntryName(route.id, 'clientLoader')
+ )
+ : undefined,
+ clientMiddlewareModule: routeChunkMap?.clientMiddleware
+ ? getModulePathForChunk(
+ getRouteChunkEntryName(route.id, 'clientMiddleware')
+ )
+ : undefined,
+ hydrateFallbackModule: routeChunkMap?.HydrateFallback
+ ? getModulePathForChunk(
+ getRouteChunkEntryName(route.id, 'HydrateFallback')
+ )
+ : undefined,
+ hasAction: exports.has(SERVER_EXPORTS.action),
+ hasLoader: exports.has(SERVER_EXPORTS.loader),
+ hasClientAction,
+ hasClientLoader,
+ hasClientMiddleware,
+ hasDefaultExport,
+ hasErrorBoundary: exports.has(CLIENT_EXPORTS.ErrorBoundary),
+ imports: jsAssets.map(asset => combineURLs(assetPrefix, asset)),
+ css: cssAssets.map(asset => combineURLs(assetPrefix, asset)),
+ },
+ routeModuleExports,
+ ] as const;
+ }),
+ {
+ concurrency: Math.max(
+ 1,
+ Math.min(ROUTE_ANALYSIS_CONCURRENCY, Object.keys(routes).length)
+ ),
}
-
- return [
- key,
- {
- id: route.id,
- parentId: route.parentId,
- path: route.path,
- index: route.index,
- caseSensitive: route.caseSensitive,
- module: combineURLs(assetPrefix, jsAssets[0] || ''),
- clientActionModule: routeChunkMap?.clientAction
- ? getModulePathForChunk(
- getRouteChunkEntryName(route.id, 'clientAction')
- )
- : undefined,
- clientLoaderModule: routeChunkMap?.clientLoader
- ? getModulePathForChunk(
- getRouteChunkEntryName(route.id, 'clientLoader')
- )
- : undefined,
- clientMiddlewareModule: routeChunkMap?.clientMiddleware
- ? getModulePathForChunk(
- getRouteChunkEntryName(route.id, 'clientMiddleware')
- )
- : undefined,
- hydrateFallbackModule: routeChunkMap?.HydrateFallback
- ? getModulePathForChunk(
- getRouteChunkEntryName(route.id, 'HydrateFallback')
- )
- : undefined,
- hasAction: exports.has(SERVER_EXPORTS.action),
- hasLoader: exports.has(SERVER_EXPORTS.loader),
- hasClientAction,
- hasClientLoader,
- hasClientMiddleware,
- hasDefaultExport,
- hasErrorBoundary: exports.has(CLIENT_EXPORTS.ErrorBoundary),
- imports: jsAssets.map(asset => combineURLs(assetPrefix, asset)),
- css: cssAssets.map(asset => combineURLs(assetPrefix, asset)),
- },
- routeModuleExports,
- ] as const;
+ );
+
+ const routeModuleExportsByRouteId: RouteManifestModuleExports = {};
+ for (const [
+ key,
+ routeManifestItem,
+ routeModuleExports,
+ ] of manifestEntries) {
+ result[key] = routeManifestItem;
+ routeModuleExportsByRouteId[key] = routeModuleExports;
}
- );
- const routeModuleExportsByRouteId: RouteManifestModuleExports = {};
- for (const [key, routeManifestItem, routeModuleExports] of manifestEntries) {
- result[key] = routeManifestItem;
- routeModuleExportsByRouteId[key] = routeModuleExports;
- }
+ const entryAssets = getAssetsForChunk('entry.client');
+ const entryJsAssets = entryAssets.filter(asset => asset.endsWith('.js'));
+ const entryCssAssets = entryAssets.filter(asset => asset.endsWith('.css'));
+
+ const fingerprintedValues = {
+ entry: {
+ module: combineURLs(assetPrefix, entryJsAssets[0] || ''),
+ imports: entryJsAssets.map(asset => combineURLs(assetPrefix, asset)),
+ css: entryCssAssets.map(asset => combineURLs(assetPrefix, asset)),
+ },
+ routes: result,
+ };
+ const version = getManifestVersion(fingerprintedValues, isBuild);
+ const manifestPath = getReactRouterManifestPath({
+ version,
+ isBuild,
+ entryModulePath: entryJsAssets[0],
+ });
+
+ const manifest = {
+ version,
+ url: combineURLs(assetPrefix, manifestPath),
+ hmr: undefined,
+ entry: fingerprintedValues.entry,
+ sri: undefined,
+ routes: result,
+ };
- const entryAssets = getAssetsForChunk('entry.client');
- const entryJsAssets = entryAssets.filter(asset => asset.endsWith('.js'));
- const entryCssAssets = entryAssets.filter(asset => asset.endsWith('.css'));
-
- const fingerprintedValues = {
- entry: {
- module: combineURLs(assetPrefix, entryJsAssets[0] || ''),
- imports: entryJsAssets.map(asset => combineURLs(assetPrefix, asset)),
- css: entryCssAssets.map(asset => combineURLs(assetPrefix, asset)),
- },
- routes: result,
- };
- const version = getManifestVersion(fingerprintedValues, isBuild);
- const manifestPath = getReactRouterManifestPath({
- version,
- isBuild,
- entryModulePath: entryJsAssets[0],
+ return {
+ manifest,
+ moduleExportsByRouteId: routeModuleExportsByRouteId,
+ };
});
+}
- const manifest = {
- version,
- url: combineURLs(assetPrefix, manifestPath),
- hmr: undefined,
- entry: fingerprintedValues.entry,
- sri: undefined,
- routes: result,
- };
-
- return {
- manifest,
- moduleExportsByRouteId: routeModuleExportsByRouteId,
- };
+export async function generateReactRouterManifestForDev(
+ ...args: Parameters
+): Promise {
+ return runPluginEffect(generateReactRouterManifestForDevEffect(...args));
}
export async function getReactRouterManifestForDev(
diff --git a/src/prerender-build.ts b/src/prerender-build.ts
index 26ba768..e66a397 100644
--- a/src/prerender-build.ts
+++ b/src/prerender-build.ts
@@ -436,29 +436,50 @@ export const runBoundedPrerenderTasks = (
renderPath: (path: string) => Promise
): Promise =>
runPluginEffect(
- Effect.forEach(
- prerenderPaths,
- path => tryPluginPromise(() => renderPath(path)),
- { concurrency, discard: true }
+ createBoundedPrerenderTasksEffect(prerenderPaths, concurrency, path =>
+ tryPluginPromise(() => renderPath(path))
)
);
-const runPrerenderPaths = async ({
+export const createBoundedPrerenderTasksEffect = (
+ prerenderPaths: string[],
+ concurrency: number,
+ renderPath: (path: string) => Effect.Effect
+): Effect.Effect =>
+ Effect.forEach(prerenderPaths, renderPath, { concurrency, discard: true });
+
+const createPrerenderDataEffect = (
+ options: Parameters[0]
+): Effect.Effect =>
+ tryPluginPromise(() => prerenderData(options));
+
+const createPrerenderRouteEffect = (
+ options: Parameters[0]
+): Effect.Effect =>
+ tryPluginPromise(() => prerenderRoute(options));
+
+const createPrerenderResourceRouteEffect = (
+ options: Parameters[0]
+): Effect.Effect =>
+ tryPluginPromise(() => prerenderResourceRoute(options));
+
+const createPrerenderPathEffect = ({
+ path,
build,
+ buildRoutes,
requestHandler,
clientBuildDir,
options,
}: {
+ path: string;
build: PrerenderServerBuild;
+ buildRoutes: ReturnType;
requestHandler: (request: Request) => Promise;
clientBuildDir: string;
options: RunReactRouterPrerenderBuildOptions;
-}): Promise => {
- const { api, basename, future, prerenderConfig, prerenderPaths } = options;
- const buildRoutes = createPrerenderRoutes(build.routes);
- const concurrency = getPrerenderConcurrency(prerenderConfig);
-
- const enqueue = async (path: string) => {
+}): Effect.Effect =>
+ Effect.gen(function* () {
+ const { api, basename, future } = options;
const matches = matchRoutes(buildRoutes, normalizePrerenderMatchPath(path));
if (!matches) {
return;
@@ -472,7 +493,7 @@ const runPrerenderPaths = async ({
if (isResourceRoute) {
if (manifestRoute.loader && routeId) {
- await prerenderData({
+ yield* createPrerenderDataEffect({
handler: requestHandler,
prerenderPath: path,
onlyRoutes: [routeId],
@@ -482,7 +503,7 @@ const runPrerenderPaths = async ({
future.unstable_trailingSlashAwareDataRequests,
api,
});
- await prerenderResourceRoute({
+ yield* createPrerenderResourceRouteEffect({
handler: requestHandler,
prerenderPath: path,
clientBuildDir,
@@ -490,9 +511,11 @@ const runPrerenderPaths = async ({
api,
});
} else {
- api.logger.warn(
- `⚠️ Skipping prerendering for resource route without a loader: ${routeId}`
- );
+ yield* Effect.sync(() => {
+ api.logger.warn(
+ `⚠️ Skipping prerendering for resource route without a loader: ${routeId}`
+ );
+ });
}
return;
}
@@ -504,20 +527,20 @@ const runPrerenderPaths = async ({
}
return build.assets?.routes?.[matchedRouteId]?.hasLoader;
});
- let data: string | undefined;
- if (hasLoaders) {
- data = await prerenderData({
- handler: requestHandler,
- prerenderPath: path,
- onlyRoutes: null,
- clientBuildDir,
- basename,
- trailingSlashAwareDataRequests:
- future.unstable_trailingSlashAwareDataRequests,
- api,
- });
- }
- await prerenderRoute({
+ const data = hasLoaders
+ ? yield* createPrerenderDataEffect({
+ handler: requestHandler,
+ prerenderPath: path,
+ onlyRoutes: null,
+ clientBuildDir,
+ basename,
+ trailingSlashAwareDataRequests:
+ future.unstable_trailingSlashAwareDataRequests,
+ api,
+ })
+ : undefined;
+
+ yield* createPrerenderRouteEffect({
handler: requestHandler,
prerenderPath: path,
clientBuildDir,
@@ -531,9 +554,35 @@ const runPrerenderPaths = async ({
}
: undefined,
});
- };
+ });
- await runBoundedPrerenderTasks(prerenderPaths, concurrency, enqueue);
+const runPrerenderPaths = async ({
+ build,
+ requestHandler,
+ clientBuildDir,
+ options,
+}: {
+ build: PrerenderServerBuild;
+ requestHandler: (request: Request) => Promise;
+ clientBuildDir: string;
+ options: RunReactRouterPrerenderBuildOptions;
+}): Promise => {
+ const { prerenderConfig, prerenderPaths } = options;
+ const buildRoutes = createPrerenderRoutes(build.routes);
+ const concurrency = getPrerenderConcurrency(prerenderConfig);
+
+ await runPluginEffect(
+ createBoundedPrerenderTasksEffect(prerenderPaths, concurrency, path =>
+ createPrerenderPathEffect({
+ path,
+ build,
+ buildRoutes,
+ requestHandler,
+ clientBuildDir,
+ options,
+ })
+ )
+ );
};
export const runReactRouterPrerenderBuild = async (
diff --git a/src/route-artifacts.ts b/src/route-artifacts.ts
index 58dd4de..1cbdab9 100644
--- a/src/route-artifacts.ts
+++ b/src/route-artifacts.ts
@@ -2,6 +2,8 @@ import {
CLIENT_ROUTE_EXPORTS_SET,
SERVER_ONLY_ROUTE_EXPORTS_SET,
} from './constants.js';
+import { Effect } from 'effect';
+import { runPluginEffect, tryPluginPromise } from './effect-runtime.js';
import { getExportNames } from './export-utils.js';
import {
buildEnforceChunkValidity,
@@ -70,86 +72,113 @@ export const buildRouteClientEntryCode = ({
return `export { ${reexports.join(', ')} } from ${JSON.stringify(target)};`;
};
-export const createRouteClientEntryArtifact = async ({
+export const createRouteClientEntryArtifactEffect = ({
code,
resourcePath,
environmentName,
isBuild,
routeChunkCache,
routeChunkConfig,
-}: RouteClientEntryArtifactOptions): Promise => {
- const isServer = environmentName === 'node';
- const mightHaveRouteChunks =
- !isServer &&
- isBuild &&
- shouldAnalyzeRouteChunks(routeChunkConfig, resourcePath, code);
- const routeChunkInfo = mightHaveRouteChunks
- ? await detectRouteChunksIfEnabled(
- routeChunkCache,
- routeChunkConfig,
+}: RouteClientEntryArtifactOptions): Effect.Effect<
+ RouteClientEntryArtifact,
+ Error,
+ never
+> =>
+ Effect.gen(function* () {
+ const isServer = environmentName === 'node';
+ const mightHaveRouteChunks =
+ !isServer &&
+ isBuild &&
+ shouldAnalyzeRouteChunks(routeChunkConfig, resourcePath, code);
+ const routeChunkInfo = mightHaveRouteChunks
+ ? yield* tryPluginPromise(() =>
+ detectRouteChunksIfEnabled(
+ routeChunkCache,
+ routeChunkConfig,
+ resourcePath,
+ code
+ )
+ )
+ : null;
+ const exportNames =
+ routeChunkInfo?.exportNames ??
+ (yield* tryPluginPromise(() => getExportNames(code)));
+ const chunkedExports = routeChunkInfo?.chunkedExports ?? [];
+ return {
+ code: buildRouteClientEntryCode({
+ exportNames,
+ chunkedExports,
+ isServer,
resourcePath,
- code
- )
- : null;
- const exportNames =
- routeChunkInfo?.exportNames ?? (await getExportNames(code));
- const chunkedExports = routeChunkInfo?.chunkedExports ?? [];
- return {
- code: buildRouteClientEntryCode({
- exportNames,
- chunkedExports,
- isServer,
- resourcePath,
- }),
- };
-};
+ }),
+ };
+ });
+
+export const createRouteClientEntryArtifact = (
+ options: RouteClientEntryArtifactOptions
+): Promise =>
+ runPluginEffect(createRouteClientEntryArtifactEffect(options));
-export const createRouteChunkArtifact = async ({
+export const createRouteChunkArtifactEffect = ({
code,
resource,
resourcePath,
isBuild,
routeChunkCache,
routeChunkConfig,
-}: RouteChunkArtifactOptions): Promise => {
- const splitRouteModules = routeChunkConfig.splitRouteModules;
- if (!isBuild || !splitRouteModules) {
- return {
- code: emptyRouteChunkSnippet(),
- map: null,
- };
- }
+}: RouteChunkArtifactOptions): Effect.Effect<
+ RouteChunkArtifact,
+ Error,
+ never
+> =>
+ Effect.gen(function* () {
+ const splitRouteModules = routeChunkConfig.splitRouteModules;
+ if (!isBuild || !splitRouteModules) {
+ return {
+ code: emptyRouteChunkSnippet(),
+ map: null,
+ };
+ }
+
+ const chunkName = getRouteChunkNameFromModuleId(resource);
+ if (!chunkName) {
+ return yield* Effect.fail(
+ new Error(`Invalid route chunk name in "${resource}"`)
+ );
+ }
+ if (chunkName !== 'main' && !code.includes(chunkName)) {
+ return {
+ code: emptyRouteChunkSnippet(),
+ map: null,
+ };
+ }
+
+ const chunk = yield* tryPluginPromise(() =>
+ getRouteChunkIfEnabled(
+ routeChunkCache,
+ routeChunkConfig,
+ resourcePath,
+ chunkName,
+ code
+ )
+ );
+
+ if (splitRouteModules === 'enforce' && chunkName === 'main' && chunk) {
+ const exportNames = yield* tryPluginPromise(() => getExportNames(chunk));
+ validateRouteChunks({
+ config: routeChunkConfig,
+ id: resourcePath,
+ valid: buildEnforceChunkValidity(exportNames),
+ });
+ }
- const chunkName = getRouteChunkNameFromModuleId(resource);
- if (!chunkName) {
- throw new Error(`Invalid route chunk name in "${resource}"`);
- }
- if (chunkName !== 'main' && !code.includes(chunkName)) {
return {
- code: emptyRouteChunkSnippet(),
+ code: chunk ?? emptyRouteChunkSnippet(),
map: null,
};
- }
-
- const chunk = await getRouteChunkIfEnabled(
- routeChunkCache,
- routeChunkConfig,
- resourcePath,
- chunkName,
- code
- );
+ });
- if (splitRouteModules === 'enforce' && chunkName === 'main' && chunk) {
- const exportNames = await getExportNames(chunk);
- validateRouteChunks({
- config: routeChunkConfig,
- id: resourcePath,
- valid: buildEnforceChunkValidity(exportNames),
- });
- }
-
- return {
- code: chunk ?? emptyRouteChunkSnippet(),
- map: null,
- };
-};
+export const createRouteChunkArtifact = (
+ options: RouteChunkArtifactOptions
+): Promise =>
+ runPluginEffect(createRouteChunkArtifactEffect(options));
diff --git a/src/route-export-resolution.ts b/src/route-export-resolution.ts
index 8aa2bfd..23cb6f2 100644
--- a/src/route-export-resolution.ts
+++ b/src/route-export-resolution.ts
@@ -1,7 +1,9 @@
import { readFileSync, statSync, type Stats } from 'node:fs';
import { createRequire } from 'node:module';
+import { Effect } from 'effect';
import { dirname, relative, resolve } from 'pathe';
import { JS_EXTENSIONS, PLUGIN_NAME } from './constants.js';
+import { runPluginEffect } from './effect-runtime.js';
import {
getExportNamesAndExportAll,
getRouteModuleAnalysis,
@@ -261,14 +263,23 @@ export type RouteModuleResolver = (
callback: RouteModuleResolveCallback
) => void;
+const resolveBundlerRouteModuleEffect = (
+ resolveModule: RouteModuleResolver,
+ specifier: string,
+ importerPath: string
+): Effect.Effect =>
+ Effect.async(resume => {
+ resolveModule(dirname(importerPath), specifier, (error, resolved) => {
+ resume(Effect.succeed(error || !resolved ? null : resolved));
+ });
+ });
+
export const createBundlerRouteExportResolver =
(resolveModule: RouteModuleResolver): RouteExportResolver =>
(specifier, importerPath) =>
- new Promise(resolveResolvedPath => {
- resolveModule(dirname(importerPath), specifier, (error, resolved) => {
- resolveResolvedPath(error || !resolved ? null : resolved);
- });
- });
+ runPluginEffect(
+ resolveBundlerRouteModuleEffect(resolveModule, specifier, importerPath)
+ );
export const collectClientOnlyStubExportNames = async (
code: string,
diff --git a/src/route-watch.ts b/src/route-watch.ts
index f4e6deb..5a495fd 100644
--- a/src/route-watch.ts
+++ b/src/route-watch.ts
@@ -1,7 +1,7 @@
import { watch, type FSWatcher } from 'node:fs';
import { access, mkdir, readdir, writeFile } from 'node:fs/promises';
import type { RsbuildConfig } from '@rsbuild/core';
-import { Effect } from 'effect';
+import { Duration, Effect, Fiber } from 'effect';
import { dirname, resolve } from 'pathe';
import { getDefaultConcurrency } from './concurrency.js';
import { runPluginEffect, tryPluginPromise } from './effect-runtime.js';
@@ -9,6 +9,7 @@ import type { Route } from './types.js';
const ROUTE_RESTART_MARKER_ASSET = '.react-router/route-watch';
const INITIAL_RESTART_MARKER_CONTENT = 'react-router-route-watch';
+const ROUTE_TOPOLOGY_RESCAN_DEBOUNCE_MS = 100;
const ROUTE_DIRECTORY_SCAN_CONCURRENCY = Math.max(
1,
Math.min(16, getDefaultConcurrency() || 1)
@@ -119,8 +120,13 @@ const areSetsEqual = (left: Set, right: Set): boolean => {
};
const readRouteDirectories = (watchDirectory: string): Promise> => {
- const directories = new Set();
+ return runPluginEffect(readRouteDirectoriesEffect(watchDirectory));
+};
+const readRouteDirectoriesEffect = (
+ watchDirectory: string
+): Effect.Effect, Error, never> => {
+ const directories = new Set();
const walkDirectory = (directory: string): Effect.Effect =>
tryPluginPromise(() => readdir(directory, { withFileTypes: true })).pipe(
Effect.catchAll(() => Effect.succeed([])),
@@ -138,7 +144,7 @@ const readRouteDirectories = (watchDirectory: string): Promise> => {
)
);
- return runPluginEffect(walkDirectory(watchDirectory)).then(() => directories);
+ return walkDirectory(watchDirectory).pipe(Effect.as(directories));
};
export const createRouteTopologyWatcher = async ({
@@ -180,14 +186,19 @@ export const createRouteTopologyWatcher = async ({
routeTopology: initialRouteTopology ?? discoveredState.routeTopology,
};
let closed = false;
- let rescanTimer: ReturnType | undefined;
+ let scheduledRescanFiber: ReturnType | undefined;
+ let scheduledRescanToken: symbol | undefined;
let rescanQueue = Promise.resolve();
const directoryWatchers = new Map();
- const touchRestartMarker = async (): Promise => {
- await mkdir(dirname(restartMarkerPath), { recursive: true });
- await writeFile(restartMarkerPath, String(Date.now()));
- };
+ const touchRestartMarkerEffect = (): Effect.Effect =>
+ tryPluginPromise(() =>
+ mkdir(dirname(restartMarkerPath), { recursive: true })
+ ).pipe(
+ Effect.zipRight(
+ tryPluginPromise(() => writeFile(restartMarkerPath, String(Date.now())))
+ )
+ );
const closeRemovedDirectoryWatchers = (
nextDirectories: Set
@@ -226,74 +237,129 @@ export const createRouteTopologyWatcher = async ({
watchNewDirectories(nextDirectories);
};
- const applyNextState = async (nextState: RouteDirectoryState) => {
- if (closed) {
- return;
- }
- syncDirectoryWatchers(nextState.directories);
- if (!areSetsEqual(state.routeTopology, nextState.routeTopology)) {
- if (onRouteTopologyChange) {
- // This is a notification boundary, not part of the rescan
- // transaction. A custom-server callback may close this watcher while
- // replacing its compiler, so awaiting it here would deadlock close().
- state = nextState;
- try {
- void Promise.resolve(onRouteTopologyChange()).catch(onError);
- } catch (error) {
- onError(error);
- }
- return;
- } else {
- await touchRestartMarker();
- }
+ const applyNextStateEffect = (
+ nextState: RouteDirectoryState
+ ): Effect.Effect =>
+ Effect.suspend(() => {
if (closed) {
- return;
+ return Effect.void;
+ }
+ syncDirectoryWatchers(nextState.directories);
+ if (!areSetsEqual(state.routeTopology, nextState.routeTopology)) {
+ if (onRouteTopologyChange) {
+ // This is a notification boundary, not part of the rescan
+ // transaction. A custom-server callback may close this watcher while
+ // replacing its compiler, so awaiting it here would deadlock close().
+ state = nextState;
+ return Effect.sync(() => {
+ try {
+ void Promise.resolve(onRouteTopologyChange()).catch(onError);
+ } catch (error) {
+ onError(error);
+ }
+ });
+ }
+ return touchRestartMarkerEffect().pipe(
+ Effect.zipRight(
+ Effect.sync(() => {
+ if (!closed) {
+ state = nextState;
+ }
+ })
+ )
+ );
}
state = nextState;
- return;
- }
- state = nextState;
- };
+ return Effect.void;
+ });
- const runRescan = async (): Promise => {
- if (closed) {
- return;
- }
+ const runRescanEffect = (): Effect.Effect => {
let nextDirectories: Set | undefined;
- try {
- nextDirectories = await readRouteDirectories(watchDirectory);
+ return Effect.gen(function* () {
+ if (closed) {
+ return;
+ }
+ nextDirectories = yield* readRouteDirectoriesEffect(watchDirectory);
if (closed) {
return;
}
const nextState = {
directories: nextDirectories,
- routeTopology: await getRouteTopology(),
+ routeTopology: yield* tryPluginPromise(getRouteTopology),
};
if (closed) {
return;
}
- await applyNextState(nextState);
- } catch (error) {
- if (nextDirectories && !closed) {
- syncDirectoryWatchers(nextDirectories);
- }
- onError(error);
- }
+ yield* applyNextStateEffect(nextState);
+ }).pipe(
+ Effect.catchAll(error =>
+ Effect.sync(() => {
+ if (nextDirectories && !closed) {
+ syncDirectoryWatchers(nextDirectories);
+ }
+ onError(error);
+ })
+ )
+ );
};
const rescan = (): Promise => {
- rescanQueue = rescanQueue.then(runRescan, runRescan);
+ rescanQueue = rescanQueue.then(
+ () => runPluginEffect(runRescanEffect()),
+ () => runPluginEffect(runRescanEffect())
+ );
return rescanQueue;
};
+ const cancelScheduledRescan = (): Promise => {
+ const fiber = scheduledRescanFiber;
+ scheduledRescanFiber = undefined;
+ scheduledRescanToken = undefined;
+ if (!fiber) {
+ return Promise.resolve();
+ }
+ return runPluginEffect(Fiber.interrupt(fiber).pipe(Effect.asVoid));
+ };
+
const scheduleRescan = (): void => {
- if (rescanTimer) {
- clearTimeout(rescanTimer);
+ const previousFiber = scheduledRescanFiber;
+ if (previousFiber) {
+ void runPluginEffect(
+ Fiber.interrupt(previousFiber).pipe(Effect.asVoid)
+ ).catch(onError);
}
- rescanTimer = setTimeout(() => {
- rescanTimer = undefined;
- void rescan();
- }, 100);
+
+ const token = Symbol();
+ scheduledRescanToken = token;
+ scheduledRescanFiber = Effect.runFork(
+ Effect.sleep(Duration.millis(ROUTE_TOPOLOGY_RESCAN_DEBOUNCE_MS)).pipe(
+ Effect.zipRight(
+ Effect.suspend(() => {
+ if (closed || scheduledRescanToken !== token) {
+ return Effect.void;
+ }
+ return tryPluginPromise(rescan).pipe(Effect.asVoid);
+ })
+ ),
+ Effect.catchAll(error =>
+ Effect.sync(() => {
+ onError(error);
+ })
+ ),
+ Effect.ensuring(
+ Effect.sync(() => {
+ if (scheduledRescanToken === token) {
+ scheduledRescanFiber = undefined;
+ scheduledRescanToken = undefined;
+ }
+ })
+ )
+ )
+ );
+ };
+
+ const applyNextState = async (nextState: RouteDirectoryState) => {
+ await runPluginEffect(applyNextStateEffect(nextState));
};
try {
@@ -304,13 +370,12 @@ export const createRouteTopologyWatcher = async ({
return async () => {
if (closed) {
+ await cancelScheduledRescan();
await rescanQueue;
return;
}
closed = true;
- if (rescanTimer) {
- clearTimeout(rescanTimer);
- }
+ await cancelScheduledRescan();
for (const watcher of directoryWatchers.values()) {
watcher.close();
}
diff --git a/src/typegen.ts b/src/typegen.ts
index 9b199ae..81d4b18 100644
--- a/src/typegen.ts
+++ b/src/typegen.ts
@@ -146,10 +146,17 @@ export const registerReactRouterTypegen = (
});
}
- api.onCloseDevServer(async () => {
- await devWatchTask.cancel();
- await runner.closeWatch();
- });
+ api.onCloseDevServer(() =>
+ runPluginEffect(
+ devWatchTask
+ .cancelEffect()
+ .pipe(
+ Effect.zipRight(
+ tryPluginPromise(() => runner.closeWatch()).pipe(Effect.asVoid)
+ )
+ )
+ )
+ );
api.onBeforeBuild(() => runner.runBuild());
};
diff --git a/tests/client-modules.test.ts b/tests/client-modules.test.ts
index 1f952a2..b985a33 100644
--- a/tests/client-modules.test.ts
+++ b/tests/client-modules.test.ts
@@ -5,7 +5,10 @@ import { resolve } from 'pathe';
import { describe, expect, it } from '@rstest/core';
import { createStubRsbuild } from '@scripts/test-helper';
import { pluginReactRouter } from '../src';
-import { collectClientOnlyStubExportNames } from '../src/route-export-resolution';
+import {
+ collectClientOnlyStubExportNames,
+ createBundlerRouteExportResolver,
+} from '../src/route-export-resolution';
describe('client-only module transforms', () => {
const createConditionalClientPackage = async (
@@ -225,4 +228,38 @@ describe('client-only module transforms', () => {
await rm(root, { recursive: true, force: true });
}
});
+
+ it('adapts the Rsbuild callback resolver to the route export resolver API', async () => {
+ const routeResolver = createBundlerRouteExportResolver(
+ (context, specifier, callback) => {
+ expect(context).toBe('/app/routes');
+ expect(specifier).toBe('@client/exports');
+ callback(null, '/app/generated/client-exports.ts');
+ }
+ );
+
+ await expect(
+ routeResolver('@client/exports', '/app/routes/example.client.ts')
+ ).resolves.toBe('/app/generated/client-exports.ts');
+ });
+
+ it('treats bundler resolver errors and false results as unresolved', async () => {
+ const failedResolver = createBundlerRouteExportResolver(
+ (_context, _specifier, callback) => {
+ callback(new Error('not found'));
+ }
+ );
+ const falseResolver = createBundlerRouteExportResolver(
+ (_context, _specifier, callback) => {
+ callback(null, false);
+ }
+ );
+
+ await expect(
+ failedResolver('@client/missing', '/app/routes/example.client.ts')
+ ).resolves.toBeNull();
+ await expect(
+ falseResolver('@client/false', '/app/routes/example.client.ts')
+ ).resolves.toBeNull();
+ });
});
diff --git a/tests/effect-runtime.test.ts b/tests/effect-runtime.test.ts
index 3c16353..65fd2fc 100644
--- a/tests/effect-runtime.test.ts
+++ b/tests/effect-runtime.test.ts
@@ -56,4 +56,21 @@ describe('effect runtime helpers', () => {
expect(run).not.toHaveBeenCalled();
});
+
+ it('supports Effect-based cancellation for delayed plugin tasks', async () => {
+ const run = rstest.fn();
+ const task = createDelayedPluginTask({
+ delayMs: 1000,
+ run: () => Effect.sync(run),
+ onError: error => {
+ throw error;
+ },
+ });
+
+ task.schedule();
+ await runPluginEffect(task.cancelEffect());
+ await new Promise(resolve => setTimeout(resolve, 20));
+
+ expect(run).not.toHaveBeenCalled();
+ });
});
diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts
index 17b8ead..738f45a 100644
--- a/tests/manifest.test.ts
+++ b/tests/manifest.test.ts
@@ -2,10 +2,12 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from '@rstest/core';
+import { runPluginEffect } from '../src/effect-runtime';
import {
createReactRouterManifestStats,
configRoutesToRouteManifest,
configRoutesToRouteManifestEntries,
+ generateReactRouterManifestForDevEffect,
generateReactRouterManifestForDev,
getReactRouterManifestForDev,
getReactRouterManifestChunkNames,
@@ -380,6 +382,39 @@ describe('manifest', () => {
}
});
+ it('generates manifests through the Effect API', async () => {
+ const { root, appDir } = createTempApp(`
+ export async function loader() { return null; }
+ export default function Page() { return null; }
+ `);
+ try {
+ const { manifest, moduleExportsByRouteId } = await runPluginEffect(
+ generateReactRouterManifestForDevEffect(
+ routes,
+ {},
+ clientStats,
+ appDir,
+ '/',
+ {
+ isBuild: true,
+ rootRouteFile: 'root.tsx',
+ splitRouteModules: false,
+ }
+ )
+ );
+
+ expect(manifest.routes['routes/page']).toMatchObject({
+ hasLoader: true,
+ hasDefaultExport: true,
+ });
+ expect(moduleExportsByRouteId['routes/page']).toEqual(
+ expect.arrayContaining(['loader', 'default'])
+ );
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
it('preserves dev css fallback when route analysis uses transformed code', async () => {
const { root, appDir } = createTempApp(`
import './page.css';
diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts
index 5988db0..ffa8460 100644
--- a/tests/prerender.test.ts
+++ b/tests/prerender.test.ts
@@ -1,5 +1,7 @@
import { describe, expect, it } from '@rstest/core';
+import { Effect } from 'effect';
import {
+ createBoundedPrerenderTasksEffect,
createBuildRequestEffect,
runBoundedPrerenderTasks,
withBuildRequest,
@@ -321,6 +323,32 @@ describe('prerender helpers', () => {
});
describe('prerender build scheduler', () => {
+ it('runs prerender task effects with a concurrency cap', async () => {
+ let active = 0;
+ let maxActive = 0;
+ const completed: string[] = [];
+
+ await runPluginEffect(
+ createBoundedPrerenderTasksEffect(
+ ['/slow', '/fast', '/medium'],
+ 2,
+ path =>
+ Effect.promise(async () => {
+ active += 1;
+ maxActive = Math.max(maxActive, active);
+ await new Promise(resolve =>
+ setTimeout(resolve, path === '/slow' ? 15 : 1)
+ );
+ completed.push(path);
+ active -= 1;
+ })
+ )
+ );
+
+ expect(maxActive).toBeLessThanOrEqual(2);
+ expect(completed.sort()).toEqual(['/fast', '/medium', '/slow']);
+ });
+
it('runs prerender tasks with a concurrency cap', async () => {
let active = 0;
let maxActive = 0;
diff --git a/tests/route-artifacts.test.ts b/tests/route-artifacts.test.ts
index f32d094..f86fdef 100644
--- a/tests/route-artifacts.test.ts
+++ b/tests/route-artifacts.test.ts
@@ -1,7 +1,10 @@
import { describe, expect, it } from '@rstest/core';
+import { runPluginEffect } from '../src/effect-runtime';
import {
createRouteChunkArtifact,
+ createRouteChunkArtifactEffect,
createRouteClientEntryArtifact,
+ createRouteClientEntryArtifactEffect,
} from '../src/route-artifacts';
import {
emptyRouteChunkSnippet,
@@ -132,6 +135,27 @@ describe('route artifact helpers', () => {
)};`,
});
});
+
+ it('generates route reexports through the Effect API', async () => {
+ const result = await runPluginEffect(
+ createRouteClientEntryArtifactEffect({
+ code: `
+ export async function clientLoader() { return null; }
+ export default function Route() { return null; }
+ `,
+ resourcePath,
+ environmentName: 'web',
+ isBuild: false,
+ routeChunkConfig: disabledRouteChunkConfig,
+ })
+ );
+
+ expect(result).toEqual({
+ code: `export { clientLoader, default } from ${JSON.stringify(
+ routeRequest
+ )};`,
+ });
+ });
});
describe('createRouteChunkArtifact', () => {
@@ -184,6 +208,34 @@ describe('route artifact helpers', () => {
expect(result).toEqual({ code: expectedCode, map: null });
});
+ it('generates route chunks through the Effect API', async () => {
+ const source = `
+ export const clientAction = async () => {};
+ export default function Route() { return null; }
+ `;
+ const cache: RouteChunkCache = new Map();
+ const expectedCode = await getRouteChunkIfEnabled(
+ cache,
+ routeChunkConfig,
+ resourcePath,
+ 'clientAction',
+ source
+ );
+
+ const result = await runPluginEffect(
+ createRouteChunkArtifactEffect({
+ code: source,
+ resource: getRouteChunkModuleId(resourcePath, 'clientAction'),
+ resourcePath,
+ routeChunkConfig,
+ routeChunkCache: cache,
+ isBuild: true,
+ })
+ );
+
+ expect(result).toEqual({ code: expectedCode, map: null });
+ });
+
it('skips ESM transforms for named chunks when no route chunk exports exist', async () => {
await expect(
createRouteChunkArtifact({
From 02bcedf006b22fbca3e62301cdeb6edecf66f62b Mon Sep 17 00:00:00 2001
From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com>
Date: Sun, 28 Jun 2026 19:58:01 +0000
Subject: [PATCH 19/19] chore: simplify effect scheduling cleanup
---
src/index.ts | 2 +-
src/parallel-route-transforms.ts | 12 +++++-----
tests/parallel-route-transforms.test.ts | 31 +++++++++++++------------
3 files changed, 23 insertions(+), 22 deletions(-)
diff --git a/src/index.ts b/src/index.ts
index d002fce..c8a8e1e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -532,7 +532,7 @@ export const pluginReactRouter = (
> =>
Effect.gen(function* () {
routeTopologyWatcherClosed = true;
- yield* tryPluginPromise(() => routeTopologyWatcherTask.cancel());
+ yield* routeTopologyWatcherTask.cancelEffect();
yield* tryPluginPromise(() => closeRouteTopologyWatcher?.());
closeRouteTopologyWatcher = undefined;
});
diff --git a/src/parallel-route-transforms.ts b/src/parallel-route-transforms.ts
index 33db0bc..c2dbb7c 100644
--- a/src/parallel-route-transforms.ts
+++ b/src/parallel-route-transforms.ts
@@ -157,8 +157,9 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor {
const workers = this.#workers ?? [];
this.#workers = [];
this.#closePromise = runPluginEffect(
- Effect.all(
- workers.map(state =>
+ Effect.forEach(
+ workers,
+ state =>
Effect.sync(() => {
for (const pending of state.pending.values()) {
pending.reject(new Error('Route transform worker closed.'));
@@ -170,10 +171,9 @@ class ParallelRouteTransformExecutor implements RouteTransformExecutor {
Effect.asVoid
)
)
- )
- ),
- { concurrency: 'unbounded' }
- ).pipe(Effect.asVoid)
+ ),
+ { concurrency: 'unbounded', discard: true }
+ )
);
return this.#closePromise;
}
diff --git a/tests/parallel-route-transforms.test.ts b/tests/parallel-route-transforms.test.ts
index ac2e148..8e4c22e 100644
--- a/tests/parallel-route-transforms.test.ts
+++ b/tests/parallel-route-transforms.test.ts
@@ -3,6 +3,7 @@ import { mapWithConcurrency } from '../src/concurrency';
import { getExportNames } from '../src/export-utils';
import {
executeRouteTransformTask,
+ type RouteTransformResult,
type RouteModuleTransformTask,
} from '../src/route-transform-tasks';
import {
@@ -81,6 +82,18 @@ class FakeRouteTransformWorker {
}
}
+const resolveWorkerMessage = (
+ worker: FakeRouteTransformWorker,
+ result: RouteTransformResult,
+ messageIndex = worker.messages.length - 1
+): void => {
+ worker.emit('message', {
+ id: worker.messages[messageIndex]!.id,
+ ok: true,
+ result,
+ } satisfies WorkerResponse);
+};
+
describe('parallel route transforms', () => {
it.each([
[1, 0],
@@ -167,11 +180,7 @@ describe('parallel route transforms', () => {
const pending = executor.run(createRouteModuleTask());
expect(createdWorkers).toBe(1);
- worker.emit('message', {
- id: worker.messages[0]!.id,
- ok: true,
- result: { code: 'created lazily' },
- } satisfies WorkerResponse);
+ resolveWorkerMessage(worker, { code: 'created lazily' });
await expect(pending).resolves.toEqual({ code: 'created lazily' });
await executor.close();
@@ -217,11 +226,7 @@ describe('parallel route transforms', () => {
const firstRun = executor.run(task);
expect(worker.messages[0]?.task.code).toBe(task.code);
- worker.emit('message', {
- id: worker.messages[0]!.id,
- ok: true,
- result: { code: 'first' },
- } satisfies WorkerResponse);
+ resolveWorkerMessage(worker, { code: 'first' }, 0);
await expect(firstRun).resolves.toEqual({ code: 'first' });
worker.failNextPostMessage = true;
@@ -229,11 +234,7 @@ describe('parallel route transforms', () => {
const thirdRun = executor.run(task);
expect(worker.messages[1]?.task.code).toBe(task.code);
- worker.emit('message', {
- id: worker.messages[1]!.id,
- ok: true,
- result: { code: 'third' },
- } satisfies WorkerResponse);
+ resolveWorkerMessage(worker, { code: 'third' }, 1);
await expect(thirdRun).resolves.toEqual({ code: 'third' });
await executor.close();