Skip to content

Commit 2727314

Browse files
refactor(effect): name the lift's rejection channel and fold boundary settlements without an unknown failure (#517)
lift.ts: `LiftedRejection` names the identity lift's fail channel (the raw thrown/rejected value, narrowed by callers), and `liftPromise` hands the helper Effect's interruption AbortSignal per rc.112's tryPromise contract. reconciler.ts: `waitSettledBoundary` folds a boundary's settlement inside the promise instead of catching an `unknown` off the Effect channel; the rejection reason stays data for `renderErrorFrom` (React's digest-bearing objects), so it is deliberately not normalized through `toRuntimeError`.
1 parent b8e9390 commit 2727314

3 files changed

Lines changed: 81 additions & 14 deletions

File tree

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,38 @@
11
import { Effect } from 'effect';
22

33
/**
4-
* Lifts for the dev seam's existing Promise/sync helpers. Both keep the
5-
* thrown/rejected value untouched in the error channel — the dev seam's
4+
* Lifts for the dev seam's existing Promise/sync helpers (Stage 3). Both keep
5+
* the thrown/rejected value untouched in the error channel — the dev seam's
66
* typed contracts are plain `Error` subclasses that must cross
77
* `src/effect/boundary.ts` identity-preserved, and several call sites
88
* re-raise non-Error values (for example an `AbortSignal.reason`) verbatim.
9+
*
10+
* A bare `Effect.tryPromise(fn)` would wrap every rejection in
11+
* `Cause.UnknownError`; these helpers are the only sanctioned way to lift a
12+
* leaf helper (`docs/effect-conventions.md` § Stage 3 "Hurt / gotchas").
913
*/
1014

11-
export const liftPromise = <A>(evaluate: () => PromiseLike<A>): Effect.Effect<A, unknown> =>
12-
Effect.tryPromise({ catch: (error) => error, try: evaluate });
15+
/**
16+
* The raw value a lifted helper threw or rejected with. It is `unknown` by
17+
* contract — the lift is an identity, not a normalizer — so callers narrow it
18+
* where they know the helper's failure contract (`instanceof`, `isErrno`,
19+
* `Effect.mapError` into a typed dev error) instead of assuming a shape. The
20+
* boundary maps whatever reaches it: typed dev errors and `Error`s rethrow
21+
* as-is, other values are wrapped, interruption becomes `AbortError`.
22+
*/
23+
export type LiftedRejection = unknown;
24+
25+
/**
26+
* Lift a Promise-returning leaf helper. `evaluate` receives Effect's
27+
* interruption `AbortSignal` (aborted when the fiber is interrupted), so a
28+
* cancellable API can be passed the signal directly; thunks that ignore it
29+
* keep working unchanged.
30+
*/
31+
export const liftPromise = <A>(
32+
evaluate: (signal: AbortSignal) => PromiseLike<A>,
33+
): Effect.Effect<A, LiftedRejection> =>
34+
Effect.tryPromise({ catch: (error): LiftedRejection => error, try: evaluate });
1335

14-
export const liftTry = <A>(evaluate: () => A): Effect.Effect<A, unknown> =>
15-
Effect.try({ catch: (error) => error, try: evaluate });
36+
/** Lift a synchronous leaf helper; a throw becomes a typed failure carrying the thrown value. */
37+
export const liftTry = <A>(evaluate: () => A): Effect.Effect<A, LiftedRejection> =>
38+
Effect.try({ catch: (error): LiftedRejection => error, try: evaluate });

‎packages/agent-bundle/tests/effect-boundary.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
runSync,
1919
toDevError,
2020
} from '../src/effect/boundary.ts';
21+
import { liftPromise, liftTry } from '../src/effect/lift.ts';
2122
import * as rootApi from '../src/index.ts';
2223

2324
describe('effect boundary (agent-bundle dev seam)', () => {
@@ -90,3 +91,41 @@ describe('effect boundary (agent-bundle dev seam)', () => {
9091
await expect(runPromise(abortToInterrupt(controller.signal))).rejects.toSatisfy(isAbortError);
9192
});
9293
});
94+
95+
describe('effect lifts (src/effect/lift.ts)', () => {
96+
it('keeps the rejected or thrown value identity-preserved on the fail channel', async () => {
97+
const typed = new EpochStoreError('EPOCH_NOT_FOUND', 'Epoch "e1" does not exist.');
98+
const reason = { code: 'ECUSTOM', message: 'not an Error' };
99+
const rejected = await runPromiseExit(liftPromise(() => Promise.reject(typed)));
100+
expect(Exit.isFailure(rejected) && Cause.squash(rejected.cause)).toBe(typed);
101+
const rawReason = await runPromiseExit(liftPromise(() => Promise.reject(reason)));
102+
expect(Exit.isFailure(rawReason) && Cause.squash(rawReason.cause)).toBe(reason);
103+
const thrown = await runPromiseExit(liftTry((): never => { throw typed; }));
104+
expect(Exit.isFailure(thrown) && Cause.squash(thrown.cause)).toBe(typed);
105+
expect(runSync(liftTry(() => 7))).toBe(7);
106+
// The Promise edge rethrows typed errors as-is and wraps non-Error values,
107+
// exactly per the boundary's mapping table — the lift itself never
108+
// normalizes, so a caller that must re-raise a raw reason reads the Exit.
109+
await expect(runPromise(liftPromise(() => Promise.reject(typed)))).rejects.toBe(typed);
110+
await expect(runPromise(liftPromise(() => Promise.reject(reason)))).rejects.toEqual(new Error(String(reason)));
111+
});
112+
113+
it('hands the lifted helper an AbortSignal that aborts when the fiber is interrupted', async () => {
114+
const host = new AbortController();
115+
let observed: AbortSignal | undefined;
116+
const pending = runPromise(
117+
liftPromise((signal) => {
118+
observed = signal;
119+
return new Promise<never>((_, reject) => {
120+
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
121+
});
122+
}),
123+
{ signal: host.signal },
124+
);
125+
await Promise.resolve();
126+
expect(observed?.aborted).toBe(false);
127+
host.abort();
128+
await expect(pending).rejects.toSatisfy(isAbortError);
129+
expect(observed?.aborted).toBe(true);
130+
});
131+
});

‎packages/rsc-runtime/src/reconciler.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -285,18 +285,23 @@ type SettledBoundary = {
285285
readonly ok: boolean;
286286
};
287287

288+
/**
289+
* Waits for the first pending boundary to settle either way. The rejection
290+
* reason is data, not a failure to normalize: React rejects a boundary with a
291+
* `{ message, digest }` object that `renderErrorFrom` reads, so mapping it
292+
* through `toRuntimeError` would erase the digest. Both settlements are folded
293+
* inside the promise, which therefore cannot reject and never puts an
294+
* `unknown` on the Effect error channel.
295+
*/
288296
const waitSettledBoundary = (
289297
pending: readonly PendingBoundary[],
290-
): Effect.Effect<SettledBoundary, Error> =>
298+
): Effect.Effect<SettledBoundary> =>
291299
Effect.raceAll(
292300
pending.map((boundary) =>
293-
Effect.tryPromise({
294-
catch: (error) => error,
295-
try: () => Promise.resolve(boundary.thenable),
296-
}).pipe(
297-
Effect.map(() => ({ boundary, ok: true as const })),
298-
Effect.catch((error) => Effect.succeed({ boundary, error, ok: false as const })),
299-
),
301+
Effect.promise((): Promise<SettledBoundary> => Promise.resolve(boundary.thenable).then(
302+
() => ({ boundary, ok: true as const }),
303+
(error: unknown) => ({ boundary, error, ok: false as const }),
304+
)),
300305
),
301306
);
302307

0 commit comments

Comments
 (0)