react-router-effect 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -259,6 +259,79 @@ service-specific error that is not a declared domain error. Catch it or map it.
259
259
  defaults for declared errors. The errors of one feature or service need explicit handling.
260
260
  [The route check](#the-route-check) reports an unhandled error at compile time.
261
261
 
262
+ ## Defer data
263
+
264
+ The route factory returns `defer`. It starts an Effect without waiting for it, and gives a promise
265
+ of the result. Put the promise in the loader data. React Router streams it to `<Await>` after the
266
+ first response. `defer` is unstable: a minor version can change it.
267
+
268
+ ```tsx
269
+ // app/lib/route-factory.server.ts
270
+ export const { Respond, defer } = routeFactory;
271
+
272
+ // app/routes/product.tsx
273
+ export const loader = Effect.fn("loader.product")(function* ({
274
+ params,
275
+ }: EffectRouteArgs<Route.LoaderArgs>) {
276
+ const product = yield* (yield* Products).get(params.id);
277
+ return {
278
+ product,
279
+ reviews: yield* defer((yield* Reviews).list(params.id)),
280
+ stats: yield* (yield* Stats).load(params.id).pipe(
281
+ Effect.catchTag("StatsUnavailable", () => Effect.succeed(null)),
282
+ defer,
283
+ ),
284
+ };
285
+ });
286
+
287
+ export default function Product({ loaderData }: Route.ComponentProps) {
288
+ return (
289
+ <Suspense fallback={<p>Loading reviews</p>}>
290
+ <Await resolve={loaderData.reviews} errorElement={<ReviewsError />}>
291
+ {(reviews) => <ReviewList reviews={reviews} />}
292
+ </Await>
293
+ </Suspense>
294
+ );
295
+ }
296
+ ```
297
+
298
+ The loader yields `defer`, and the deferred work starts at that point. The work and the rest of the
299
+ loader run at the same time. The work gets the services and the span of the loader, so its spans are in the trace of the request. It
300
+ keeps running after the loader returns. The work stops in two cases:
301
+
302
+ - The request aborts, for example because the client disconnects.
303
+ - The loader fails after it deferred the work. The response then has no data that waits for it.
304
+
305
+ A client-side navigation to another page does not abort the request. React Router aborts the request
306
+ of a navigation only while its loaders run. The work then runs until it ends, or until React
307
+ Router's `streamTimeout` ends the stream.
308
+
309
+ The response starts before the deferred work fails, so a failure cannot redirect or change the
310
+ response. The error handlers of the factory turn a failure into a value or a rejection:
311
+
312
+ | Failure of the deferred Effect | The promise |
313
+ | --------------------------------------------------------------- | ------------------------------------------------- |
314
+ | A handler recovers it, with `Respond.early` or `Effect.succeed` | resolves with the value → `<Await>` |
315
+ | `Respond.early(value)` raised directly | resolves with `value` → `<Await>` |
316
+ | A handler throws it, with `Respond.throw` or `Effect.fail` | rejects with an `ErrorResponse` → `useAsyncError` |
317
+ | `Respond.throw(value, init)` raised directly | rejects with an `ErrorResponse` → `useAsyncError` |
318
+ | A handler redirects, or a domain error has no handler | rejects with a plain `Error` |
319
+ | A defect or an interrupt | rejects with a plain `Error` |
320
+
321
+ The plain `Error` has the message `deferredFailureMessage` (`"Deferred route data failed"`) and no
322
+ stack, in every server mode. The server logs the full cause of a defect. A recovered
323
+ `Respond.early(value, init)` gives `value` without `init`, because a promise has no response.
324
+
325
+ The deferred Effect can fail only with the factory's domain errors, `Respond.early` and
326
+ `Respond.throw`. Any other error does not compile, and the message names the error tags. Handle
327
+ those errors before `defer`, or make them defects with `Effect.orDie`. In a factory without domain
328
+ errors, the deferred Effect must fail with `never`.
329
+
330
+ The promise type includes what the handlers recover, so `Route.ComponentProps` types the value that
331
+ `<Await>` renders. The type is `DeferredPromise<A>`, a subtype of `Promise<A>`. The
332
+ `promiseInEffectSuccess` rule of the Effect language service reports only the global `Promise`
333
+ type, so it does not report a `yield* defer(...)`.
334
+
262
335
  ## Provide services
263
336
 
264
337
  ### App-wide services with a runtime
@@ -378,7 +451,9 @@ routes.
378
451
 
379
452
  The adapter's API is unstable, and a minor version can change it. Every export of
380
453
  `react-router-effect/server` and `react-router-effect/vite` has the JSDoc tag `@stability unstable`.
381
- The route factory and the other exports of `react-router-effect` are stable.
454
+ The route factory and the other exports of `react-router-effect` are stable, except the route
455
+ metadata. The `routeMeta` configuration, the `Register` key `routeMeta`, `RouteMetaArgs`,
456
+ `WithRouteMeta` and `routeMetaKey` are unstable, and a minor version can change them.
382
457
 
383
458
  React Router keeps doing what it does for any other server adapter: documents, `.data` requests,
384
459
  streamed promises, resource routes, the lazy route manifest and middleware. Before React Router
@@ -628,10 +703,10 @@ export const middleware = [
628
703
  `Route.ComponentProps` types the data of an Effect loader or action, including what the factory
629
704
  recovers.
630
705
  - Hooks that take `typeof loader` or `typeof action`, such as `useFetcher<typeof action>()`,
631
- read the data through React Router's `SerializeFrom`. It treats a function as a client
632
- function when its argument has the members of client loader args, and gives client functions
633
- their raw return value. Typegen's `Route.LoaderArgs` and `Route.ActionArgs` have those same
634
- members, so for an Effect the hook would type the data as the Effect itself.
706
+ read the data through React Router's `SerializeFrom`. If the argument of a function has the
707
+ members of client loader args, `SerializeFrom` treats it as a client function. It gives a
708
+ client function its raw return value. Typegen's `Route.LoaderArgs` and `Route.ActionArgs` have
709
+ those same members, so for an Effect the hook types the data as the Effect itself.
635
710
  - Take the args through `EffectRouteArgs<Route.LoaderArgs>`. It makes `pattern` optional, so the
636
711
  argument no longer matches and the hook reads the brand. A loader without args needs nothing.
637
712
  - `RouteData<typeof loader>` reads the brand directly and applies React Router's serialization.
@@ -641,8 +716,8 @@ export const middleware = [
641
716
  and the span that it provides around `next()` reach every route Effect downstream. `context.set`
642
717
  keeps working. You cannot annotate a route array with an effect middleware as
643
718
  `Route.MiddlewareFunction[]`.
644
- - Action metadata: `actionMeta` in the factory config is read after every action resolves. The
645
- adapter attaches it to the action's data under `__meta`.
719
+ - Route metadata: `routeMeta` in the factory configuration is read after every loader and action
720
+ resolves. The adapter attaches it to their data under `__meta`.
646
721
  - Tests: `routeFactory.handlers.loader(loader)(args)` runs a route function as the server does.
647
722
 
648
723
  ### Register what the factory recovers
@@ -653,7 +728,7 @@ A plain Effect loader cannot see the factory's handlers. Declare what they recov
653
728
  declare module "react-router-effect" {
654
729
  interface Register {
655
730
  recover: { FormValidationError: { reply: SubmissionResponse } };
656
- actionMeta: { realtimeEventIds: ReadonlyArray<string> };
731
+ routeMeta: { realtimeEventIds: ReadonlyArray<string> };
657
732
  }
658
733
  }
659
734
  ```
@@ -811,8 +886,8 @@ export const loader = makeLoader(() =>
811
886
 
812
887
  ## API
813
888
 
814
- - `makeEffectRouteFactory<DomainErrors>()((Respond) => ({ errorHandlers?, runtime?, requestContext?, respond?, actionMeta? }))`
815
- returns `{ makeLoader, makeAction, makeMiddleware, Respond, handlers }`. The config is a builder
889
+ - `makeEffectRouteFactory<DomainErrors>()((Respond) => ({ errorHandlers?, runtime?, requestContext?, respond?, routeMeta? }))`
890
+ returns `{ makeLoader, makeAction, makeMiddleware, Respond, defer, handlers }`. The config is a builder
816
891
  that receives the base `Respond`. All its fields are optional. The returned `Respond` is the base
817
892
  helpers merged with your `respond` extensions, and the base helpers win. `makeLoader` and
818
893
  `makeAction` are the same wrapper. `makeMiddleware` wraps a `Route.MiddlewareFunction`, and
@@ -820,6 +895,9 @@ export const loader = makeLoader(() =>
820
895
  - `handlers` (`loader`, `action`, `middleware`, `requestContext`) is the runner that the server
821
896
  adapter applies to every route module.
822
897
  - `Respond` has `early` (recover), `throw` and `redirect`, and the `respond` helpers that you add.
898
+ - `defer` (unstable) starts an Effect and gives a `DeferredPromise` of its result for loader data.
899
+ Read [Defer data](#defer-data). The types `DeferredPromise`, `DeferDiagnose`, `DeferHandled` and
900
+ `DeferRecoverOf`, and the constant `deferredFailureMessage`, go with it.
823
901
  - `ReturnableDataError`, `ThrowableDataError` and `ThrowableRedirectError` are the library route
824
902
  errors. `isRouteError` narrows them.
825
903
  - `ErrorHandler<Err>` is the handler signature type.
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { C as ReturnableDataError, D as WithActionMeta, E as isRouteError, O as actionMetaKey, S as AnyRouteError, T as ThrowableRedirectError, a as RegisteredMeta, b as RouteResult, c as Serialize, d as EffectNext, f as ErrorHandler, i as Register, l as ActionMetaArgs, n as EffectData, o as RegisteredRecover, p as FactoryTypes, r as EffectRouteArgs, s as RouteData, t as BrandData, u as EffectMiddlewareArgs, v as RequestContextKey, w as ThrowableDataError, x as makeEffectRouteFactory, y as RouteHandlers } from "./route-data-CWBxnNAA.mjs";
2
- export { type ActionMetaArgs, type AnyRouteError, type BrandData, type EffectData, type EffectMiddlewareArgs, type EffectNext, type EffectRouteArgs, type ErrorHandler, type FactoryTypes, type Register, type RegisteredMeta, type RegisteredRecover, type RequestContextKey, ReturnableDataError, type RouteData, type RouteHandlers, type RouteResult, type Serialize, ThrowableDataError, ThrowableRedirectError, type WithActionMeta, actionMetaKey, isRouteError, makeEffectRouteFactory };
1
+ import { A as ThrowableRedirectError, C as RouteHandlers, D as AnyRouteError, E as makeEffectRouteFactory, M as WithRouteMeta, N as routeMetaKey, O as ReturnableDataError, S as RequestContextKey, T as RouteResult, _ as FactoryTypes, a as RegisteredMeta, c as Serialize, d as deferredFailureMessage, f as DeferHandled, g as ErrorHandler, h as EffectNext, i as Register, j as isRouteError, k as ThrowableDataError, l as DeferDiagnose, m as EffectMiddlewareArgs, n as EffectData, o as RegisteredRecover, p as DeferRecoverOf, r as EffectRouteArgs, s as RouteData, t as BrandData, u as DeferredPromise, w as RouteMetaArgs } from "./route-data-ByiuG-Yb.mjs";
2
+ export { type AnyRouteError, type BrandData, type DeferDiagnose, type DeferHandled, type DeferRecoverOf, type DeferredPromise, type EffectData, type EffectMiddlewareArgs, type EffectNext, type EffectRouteArgs, type ErrorHandler, type FactoryTypes, type Register, type RegisteredMeta, type RegisteredRecover, type RequestContextKey, ReturnableDataError, type RouteData, type RouteHandlers, type RouteMetaArgs, type RouteResult, type Serialize, ThrowableDataError, ThrowableRedirectError, type WithRouteMeta, deferredFailureMessage, isRouteError, makeEffectRouteFactory, routeMetaKey };
package/dist/index.mjs CHANGED
@@ -1,12 +1,15 @@
1
- import { Context, Data, Effect, Predicate } from "effect";
2
- import { createContext, data, redirect } from "react-router";
1
+ import { Cause, Context, Data, Effect, Exit, Option, Predicate, Scope } from "effect";
2
+ import { UNSAFE_ErrorResponseImpl, createContext, data, redirect } from "react-router";
3
3
  import { HttpServerRespondable, HttpServerResponse } from "effect/http";
4
- // src/action-meta.ts
5
- /** The key that holds the metadata in action data. */
6
- const actionMetaKey = "__meta";
7
- const DataWithResponseInitClass = data(void 0).constructor;
8
- /** True for a `data(...)` wrapper, never for a business field that looks like one. */
9
- const isDataWithResponseInit = (value) => value instanceof DataWithResponseInitClass;
4
+ // src/route-meta.ts
5
+ /** The key that holds the metadata in loader and action data. */
6
+ const routeMetaKey = "__meta";
7
+ /**
8
+ * True for a `data(...)` wrapper, by the same test React Router applies. A
9
+ * class test fails when the app and the adapter load different copies of
10
+ * React Router, as under the Vite dev server.
11
+ */
12
+ const isDataWithResponseInit = (value) => Predicate.hasProperty(value, "type") && Predicate.hasProperty(value, "data") && Predicate.hasProperty(value, "init") && value.type === "DataWithResponseInit";
10
13
  /** True for a plain object, which can be copied without losing behavior. */
11
14
  const isPlainRecord = (value) => {
12
15
  if (!Predicate.isObject(value) || Array.isArray(value)) return false;
@@ -14,21 +17,21 @@ const isPlainRecord = (value) => {
14
17
  return prototype === Object.prototype || prototype === null;
15
18
  };
16
19
  /**
17
- * Attaches `meta` to the action result. A plain record gets a copy with the
20
+ * Attaches `meta` to a loader or action result. A plain record gets a copy with the
18
21
  * metadata key; a `data(...)` wrapper gets it on its body and keeps its init.
19
22
  * Any other value is returned unchanged.
20
23
  */
21
- const attachActionMeta = (value, meta) => {
24
+ const attachRouteMeta = (value, meta) => {
22
25
  if (isDataWithResponseInit(value)) {
23
26
  if (!isPlainRecord(value.data)) return value;
24
27
  return data({
25
28
  ...value.data,
26
- [actionMetaKey]: meta
29
+ [routeMetaKey]: meta
27
30
  }, value.init ?? void 0);
28
31
  }
29
32
  return isPlainRecord(value) ? {
30
33
  ...value,
31
- [actionMetaKey]: meta
34
+ [routeMetaKey]: meta
32
35
  } : value;
33
36
  };
34
37
  // src/errors.ts
@@ -72,6 +75,84 @@ const Respond = {
72
75
  };
73
76
  /** Narrows an unknown value to one of the library's route errors. */
74
77
  const isRouteError = (value) => value instanceof ReturnableDataError || value instanceof ThrowableDataError || value instanceof ThrowableRedirectError;
78
+ // src/defer.ts
79
+ /**
80
+ * The scope that deferred work forks into. The route runner sets it for each
81
+ * loader or action run, and closes it when the request aborts or the route
82
+ * Effect fails. Outside a route runner it is unset, and deferred work runs
83
+ * detached.
84
+ */
85
+ const DeferScope = Context.Reference("react-router-effect/DeferScope", { defaultValue: () => void 0 });
86
+ /**
87
+ * Gives a route Effect a scope for its deferred work. The scope closes, and
88
+ * interrupts the deferred fibers, when `signal` aborts or the Effect fails: a
89
+ * failed loader sends no data, so no `<Await>` waits for the work.
90
+ */
91
+ const withDeferScope = (effect, signal) => Effect.suspend(() => {
92
+ const scope = Scope.makeUnsafe("parallel");
93
+ const close = (exit) => Scope.close(scope, exit);
94
+ signal.addEventListener("abort", () => Effect.runFork(close(Exit.interrupt())), { once: true });
95
+ return effect.pipe(Effect.provideService(DeferScope, scope), Effect.onError((cause) => close(Exit.failCause(cause))));
96
+ });
97
+ /**
98
+ * The message every deferred rejection has. The server logs the cause.
99
+ *
100
+ * @stability unstable
101
+ */
102
+ const deferredFailureMessage = "Deferred route data failed";
103
+ /**
104
+ * The rejection the browser gets for a defect or an interrupt: a constant
105
+ * message and no stack, in every server mode. React Router removes server
106
+ * details only in production.
107
+ */
108
+ const deferredFailure = () => {
109
+ const error = /* @__PURE__ */ new Error(deferredFailureMessage);
110
+ error.stack = void 0;
111
+ return error;
112
+ };
113
+ /**
114
+ * Forks `self` into the run's defer scope and gives a promise of its result.
115
+ * A failure rejects the promise with its `ErrorResponse`, which React Router
116
+ * streams to `useAsyncError`. A defect or interrupt rejects it with
117
+ * `deferredFailure`, and the server logs a defect's full cause.
118
+ */
119
+ const forkDeferred = (self) => Effect.gen(function* () {
120
+ const scope = yield* DeferScope;
121
+ const work = self.pipe(Effect.tapCause((cause) => Cause.hasDies(cause) ? Effect.logError(deferredFailureMessage, cause) : Effect.void));
122
+ const fiber = yield* scope === void 0 ? Effect.forkDetach(work) : Effect.forkIn(work, scope);
123
+ const promise = new Promise((resolve, reject) => {
124
+ fiber.addObserver((exit) => {
125
+ if (Exit.isSuccess(exit)) return resolve(exit.value);
126
+ reject(Cause.hasDies(exit.cause) ? deferredFailure() : Option.getOrElse(Cause.findErrorOption(exit.cause), deferredFailure));
127
+ });
128
+ });
129
+ promise.catch(() => void 0);
130
+ return promise;
131
+ });
132
+ /** A thrown `data(...)` as an `ErrorResponse`, with React Router's defaults. */
133
+ const toErrorResponse = (thrown) => new UNSAFE_ErrorResponseImpl(thrown.init?.status ?? 500, thrown.init?.statusText ?? "Internal Server Error", thrown.data);
134
+ /**
135
+ * A library route error raised in deferred work: `Respond.early` resolves the
136
+ * promise with its value, and `Respond.throw` rejects it with an
137
+ * `ErrorResponse`. A redirect cannot happen after the response started, so it
138
+ * is a defect.
139
+ */
140
+ const deferRouteError = (e) => {
141
+ if (e instanceof ReturnableDataError) return Effect.succeed(e.data);
142
+ if (e instanceof ThrowableDataError) return Effect.fail(toErrorResponse(data(e.data, e.init)));
143
+ return Effect.die(e);
144
+ };
145
+ /**
146
+ * A handler's `Effect.fail(response)` in deferred work, as an `ErrorResponse`:
147
+ * a `data(...)` as React Router converts a thrown one, and a `Response` with
148
+ * its body read as React Router reads it, JSON or text. A redirect is a defect.
149
+ */
150
+ const deferFailureResponse = (failure) => {
151
+ if (!(failure instanceof Response)) return Effect.fail(toErrorResponse(failure));
152
+ if (failure.status >= 300 && failure.status < 400) return Effect.die(failure);
153
+ const isJson = /\bapplication\/json\b/.test(failure.headers.get("Content-Type") ?? "");
154
+ return Effect.promise(() => isJson && failure.body !== null ? failure.json() : failure.text()).pipe(Effect.flatMap((body) => Effect.fail(new UNSAFE_ErrorResponseImpl(failure.status, failure.statusText, body))));
155
+ };
75
156
  // src/factory.ts
76
157
  const internalServerError = () => Effect.fail(new Response("Internal Server Error", { status: 500 }));
77
158
  /**
@@ -197,7 +278,7 @@ function makeEffectRouteFactory() {
197
278
  * - they differ only in how they build the effect and what `context` they read.
198
279
  */
199
280
  function runProgram(effect, context, signal) {
200
- const program = effect.pipe(Effect.catchIf((_e) => true, (e) => {
281
+ const program = withDeferScope(effect, signal).pipe(Effect.catchIf((_e) => true, (e) => {
201
282
  for (const tag of userTags) if (Predicate.isTagged(e, tag)) {
202
283
  const out = userHandlers[tag](e);
203
284
  return isRouteError(out) ? processRouteError(out) : out;
@@ -269,19 +350,74 @@ function makeEffectRouteFactory() {
269
350
  function runResult(result, args) {
270
351
  return Effect.isEffect(result) ? runProgram(result, args.context, args.request.signal) : result;
271
352
  }
272
- const actionMeta = config.actionMeta;
353
+ /**
354
+ * A failure of deferred work, through the error handlers: a recovered
355
+ * value resolves the promise, a thrown response rejects it with an
356
+ * `ErrorResponse`. Anything else, such as a domain error with no handler,
357
+ * is a defect: the promise rejects with no server details.
358
+ */
359
+ function dispatchDeferred(e) {
360
+ for (const tag of userTags) if (Predicate.isTagged(e, tag)) {
361
+ const out = userHandlers[tag](e);
362
+ return isRouteError(out) ? deferRouteError(out) : out.pipe(Effect.catch(deferFailureResponse));
363
+ }
364
+ return isRouteError(e) ? deferRouteError(e) : Effect.die(e);
365
+ }
366
+ /**
367
+ * Starts an Effect without waiting for it, and gives a promise of its
368
+ * result for loader data. React Router streams the promise to `<Await>`.
369
+ *
370
+ * The work gets the services and the span of the Effect that defers it.
371
+ * It keeps running after the loader returns, and is interrupted when the
372
+ * request aborts or the loader fails. A client-side navigation away does
373
+ * not abort the request, so the work then runs until it ends or the stream
374
+ * times out.
375
+ *
376
+ * The work may fail only with the factory's domain errors,
377
+ * `Respond.early` and `Respond.throw`. The error handlers turn them into
378
+ * the promise's value, or an `ErrorResponse` for `useAsyncError`, as they
379
+ * do for a loader. A handler that redirects, a domain error with no
380
+ * handler, a defect or an interrupt rejects the promise with an error that
381
+ * holds no server details, and the server logs the full cause.
382
+ *
383
+ * ```ts
384
+ * export const { Respond, defer } = routeFactory;
385
+ *
386
+ * export const loader = Effect.fn("loader")(function* () {
387
+ * const user = yield* Users.current;
388
+ * return {
389
+ * user,
390
+ * reviews: yield* defer(Reviews.list(user.id)),
391
+ * stats: yield* Stats.load.pipe(
392
+ * Effect.catchTag("StatsUnavailable", () => Effect.succeed(null)),
393
+ * defer,
394
+ * ),
395
+ * };
396
+ * });
397
+ * ```
398
+ *
399
+ * @stability unstable
400
+ */
401
+ function defer(self) {
402
+ const work = self.pipe(Effect.catch(dispatchDeferred));
403
+ return forkDeferred(work);
404
+ }
405
+ const routeMeta = config.routeMeta;
406
+ /** Runs a loader or action's result and attaches the route metadata to its data. */
407
+ async function withRouteMeta(result, args) {
408
+ const resolved = await runResult(result, args);
409
+ return routeMeta === void 0 ? resolved : attachRouteMeta(resolved, routeMeta(args));
410
+ }
273
411
  return {
274
412
  makeLoader: makeLoaderOrAction,
275
413
  makeAction: makeLoaderOrAction,
276
414
  makeMiddleware,
277
415
  Respond: Respond$1,
416
+ defer,
278
417
  /** The route runner the server adapter applies to every route module. */
279
418
  handlers: {
280
- loader: (fn) => async (args) => runResult(fn(args), args),
281
- action: (fn) => async (args) => {
282
- const result = await runResult(fn(args), args);
283
- return actionMeta === void 0 ? result : attachActionMeta(result, actionMeta(args));
284
- },
419
+ loader: (fn) => async (args) => await withRouteMeta(fn(args), args),
420
+ action: (fn) => async (args) => await withRouteMeta(fn(args), args),
285
421
  middleware: (fn) => async (args, next) => {
286
422
  const routeArgs = args;
287
423
  return runResult(fn({
@@ -296,4 +432,4 @@ function makeEffectRouteFactory() {
296
432
  };
297
433
  };
298
434
  }
299
- export { ReturnableDataError, ThrowableDataError, ThrowableRedirectError, actionMetaKey, isRouteError, makeEffectRouteFactory };
435
+ export { ReturnableDataError, ThrowableDataError, ThrowableRedirectError, deferredFailureMessage, isRouteError, makeEffectRouteFactory, routeMetaKey };
@@ -1,22 +1,22 @@
1
- import { Context, Effect, ManagedRuntime } from "effect";
1
+ import { Context, Effect, ManagedRuntime, Scope } from "effect";
2
2
  import { ActionFunctionArgs, LoaderFunctionArgs, MiddlewareFunction, RouterContext, RouterContextProvider, UNSAFE_DataWithResponseInit, useLoaderData } from "react-router";
3
3
  import { HttpServerRespondable } from "effect/http";
4
- /** The key that holds the metadata in action data. */
5
- declare const actionMetaKey = "__meta";
4
+ /** The key that holds the metadata in loader and action data. */
5
+ declare const routeMetaKey = "__meta";
6
6
  /**
7
7
  * Adds optional metadata to a record. Only records get metadata, because a
8
8
  * structural type cannot tell a plain object from a class instance, and other
9
9
  * values (strings, arrays, `Response`s) have nowhere to put it.
10
10
  */
11
- type RecordWithMeta<T, Meta> = T extends Record<string, unknown> ? Omit<T, typeof actionMetaKey> & {
12
- [actionMetaKey]?: Meta;
11
+ type RecordWithMeta<T, Meta> = T extends Record<string, unknown> ? Omit<T, typeof routeMetaKey> & {
12
+ [routeMetaKey]?: Meta;
13
13
  } : T;
14
14
  /**
15
- * The type of action data after metadata is attached. A `data(...)` wrapper
15
+ * The type of loader or action data after metadata is attached. A `data(...)` wrapper
16
16
  * gets metadata on its body. With no configured metadata (`Meta` is `never`)
17
17
  * the data is unchanged.
18
18
  */
19
- type WithActionMeta<T, Meta> = [Meta] extends [never] ? T : T extends UNSAFE_DataWithResponseInit<infer Body> ? UNSAFE_DataWithResponseInit<RecordWithMeta<Body, Meta>> : RecordWithMeta<T, Meta>;
19
+ type WithRouteMeta<T, Meta> = [Meta] extends [never] ? T : T extends UNSAFE_DataWithResponseInit<infer Body> ? UNSAFE_DataWithResponseInit<RecordWithMeta<Body, Meta>> : RecordWithMeta<T, Meta>;
20
20
  declare const ReturnableDataError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
21
21
  readonly _tag: "ReturnableRouteError";
22
22
  } & Readonly<A>;
@@ -144,6 +144,25 @@ type ErrorHandlers<DomainError extends Tagged, Returns> = { [Tag in keyof Return
144
144
  type RecoverOf<DomainError extends Tagged, Returns, E> = { [Tag in keyof Returns]: [Extract<E, Extract<DomainError, {
145
145
  readonly _tag: Tag;
146
146
  }>>] extends [never] ? never : (Returns[Tag] extends ReturnableDataError<infer B> ? UNSAFE_DataWithResponseInit<B> : never) | (Returns[Tag] extends Effect.Effect<infer SuccessValue, any, any> ? SuccessValue : never); }[keyof Returns];
147
+ /**
148
+ * The errors the factory's `defer` handles in deferred work: the declared
149
+ * domain errors, and `Respond.early` / `Respond.throw`. A redirect is not one
150
+ * of them: it cannot happen after the response started.
151
+ *
152
+ * @stability unstable
153
+ */
154
+ type DeferHandled<DomainError> = DomainError | ReturnableDataError<unknown> | ThrowableDataError<unknown>;
155
+ /**
156
+ * What a deferred promise resolves with when the work fails with `E`: the
157
+ * value of a `Respond.early` raised directly, and what the registered
158
+ * handlers recover. Unlike loader data, a recovered `Respond.early` gives its
159
+ * value without the `data(...)` wrapper: a promise has no response to set.
160
+ *
161
+ * @stability unstable
162
+ */
163
+ type DeferRecoverOf<DomainError extends Tagged, Returns, E> = ReturnableBodyOf<E> | { [Tag in keyof Returns]: [Extract<E, Extract<DomainError, {
164
+ readonly _tag: Tag;
165
+ }>>] extends [never] ? never : (Returns[Tag] extends ReturnableDataError<infer B> ? B : never) | (Returns[Tag] extends Effect.Effect<infer SuccessValue, any, any> ? SuccessValue : never); }[keyof Returns];
147
166
  /**
148
167
  * Errors the library deals with on the handler's behalf - so the loader/action/
149
168
  * middleware needn't handle them itself:
@@ -238,8 +257,8 @@ type Diagnose<E, R, DomainError, Provided, AlsoHandled = never> = IsAny<E> exten
238
257
  * ```
239
258
  */
240
259
  type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>>;
241
- /** What `actionMeta` reads: the request and the router context of the action. */
242
- interface ActionMetaArgs {
260
+ /** What `routeMeta` reads: the request and the router context of the loader or action. */
261
+ interface RouteMetaArgs {
243
262
  readonly request: Request;
244
263
  readonly context: Readonly<RouterContextProvider>;
245
264
  }
@@ -405,27 +424,61 @@ declare function makeEffectRouteFactory<DomainError extends Tagged = never>(): <
405
424
  */
406
425
  respond?: ExtraRespond;
407
426
  /**
408
- * Request-scoped metadata to attach to every action's data under `__meta`,
409
- * read after the action resolves. The server adapter attaches it to every
410
- * action it runs, including plain functions and `makeAction` wrappers. A
411
- * thrown result gets none. Only plain records and `data(...)` bodies include
412
- * it; other values are returned unchanged.
427
+ * Request-scoped metadata to attach to the data of every loader and action
428
+ * under `__meta`, read after the function resolves. The server adapter
429
+ * attaches it to every loader and action it runs, including plain functions
430
+ * and `makeLoader` / `makeAction` wrappers. A thrown result gets none. Only
431
+ * plain records and `data(...)` bodies include it; other values are returned
432
+ * unchanged.
413
433
  */
414
- actionMeta?: (args: ActionMetaArgs) => Meta;
434
+ routeMeta?: (args: RouteMetaArgs) => Meta;
415
435
  }) => {
416
- makeLoader: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E, R>(fn: (args: Args) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices>) => (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>>;
417
- makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E, R>(fn: (args: Args) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices>) => (args: Args) => Promise<WithActionMeta<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>, Meta>>;
436
+ makeLoader: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E, R>(fn: (args: Args) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices>) => (args: Args) => Promise<WithRouteMeta<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>, Meta>>;
437
+ makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E, R>(fn: (args: Args) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices>) => (args: Args) => Promise<WithRouteMeta<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>, Meta>>;
418
438
  makeMiddleware: <Middleware extends AnyMiddlewareFunction = MiddlewareFunction, E = LibraryHandled<DomainError, FailureResponse>, R = RServices | ReqServices>(fn: (args: MiddlewareArgsOf<Middleware>, next: () => Effect.Effect<MiddlewareResultOf<Middleware>, FailureResponse>) => Effect.Effect<MiddlewareResultOf<Middleware> | void, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices, FailureResponse>) => (args: MiddlewareArgsOf<Middleware>, next: MiddlewareNextOf<Middleware>) => Promise<MiddlewareResultOf<Middleware> | undefined>;
419
439
  Respond: {
420
440
  early: <D>(value: D, init?: number | ResponseInit) => ReturnableDataError<D>;
421
441
  throw: <D>(value: D, init?: number | ResponseInit) => ThrowableDataError<D>;
422
442
  redirect: (url: string, init?: number | ResponseInit) => ThrowableRedirectError;
423
443
  } & ExtraRespond;
444
+ defer: <A, E, R>(self: Effect.Effect<A, E, R> & DeferDiagnose<E, DeferHandled<DomainError>>) => Effect.Effect<DeferredPromise<A | DeferRecoverOf<DomainError, Returns, E>>, never, R>;
424
445
  /** The route runner the server adapter applies to every route module. */
425
446
  handlers: RouteHandlers;
426
447
  /** Type-only: what the adapter's route check and `Register` read. */
427
448
  "~types": FactoryTypes<DomainError, Returns, RServices | ReqServices, Meta>;
428
449
  };
450
+ /**
451
+ * The compile-time diagnostic of the factory's `defer`. A deferred Effect
452
+ * runs after the response started, so a failure cannot change the response.
453
+ * The Effect must handle every error it can fail with before it is deferred,
454
+ * except the `Handled` ones, which the error handlers turn into a value or an
455
+ * `ErrorResponse`. Resolves to `unknown` (a no-op intersection) when every
456
+ * error is handled.
457
+ *
458
+ * @stability unstable
459
+ */
460
+ type DeferDiagnose<E, Handled> = IsAny<E> extends true ? unknown : [Exclude<E, Handled>] extends [never] ? unknown : {
461
+ "react-router-effect": "A deferred effect runs after the response started, so it can fail only with the factory's domain errors, Respond.early and Respond.throw. Handle the other errors before defer: recover them into data (e.g. Effect.catchTag), or make them defects with Effect.orDie.";
462
+ unhandledErrors: NameError<Exclude<E, Handled>>;
463
+ };
464
+ /**
465
+ * The promise that the factory's `defer` gives: a `Promise` of the deferred Effect's
466
+ * success, which React Router streams to `<Await>`. The work started when the
467
+ * Effect was deferred.
468
+ *
469
+ * It is a type of its own so that an Effect that succeeds with it states
470
+ * that the promise is not awaited on purpose. The language service's
471
+ * `promiseInEffectSuccess` rule reports only the global `Promise` type.
472
+ *
473
+ * @stability unstable
474
+ */
475
+ interface DeferredPromise<A> extends Promise<A> {}
476
+ /**
477
+ * The message every deferred rejection has. The server logs the cause.
478
+ *
479
+ * @stability unstable
480
+ */
481
+ declare const deferredFailureMessage = "Deferred route data failed";
429
482
  /**
430
483
  * Registers what the app's route factory turns failures into, so that a
431
484
  * loader or action written as a plain `Effect.fn` types its data the way a
@@ -446,8 +499,8 @@ declare function makeEffectRouteFactory<DomainError extends Tagged = never>(): <
446
499
  * interface Register {
447
500
  * // Each error tag a handler recovers, and the data it recovers into.
448
501
  * recover: { FormValidationError: { reply: SubmissionResponse } };
449
- * // What `actionMeta` returns.
450
- * actionMeta: { realtimeEventIds: ReadonlyArray<string> };
502
+ * // What `routeMeta` returns.
503
+ * routeMeta: { realtimeEventIds: ReadonlyArray<string> };
451
504
  * }
452
505
  * }
453
506
  * ```
@@ -457,9 +510,9 @@ interface Register$1 {}
457
510
  type RegisteredRecover = Register$1 extends {
458
511
  readonly recover: infer Recover;
459
512
  } ? Recover : {};
460
- /** The registered action metadata, or `never` when there is none. */
513
+ /** The registered route metadata, or `never` when there is none. */
461
514
  type RegisteredMeta = Register$1 extends {
462
- readonly actionMeta: infer Meta;
515
+ readonly routeMeta: infer Meta;
463
516
  } ? Meta : never;
464
517
  /** What a failure of type `E` recovers into: `Respond.early` bodies and registered handlers. */
465
518
  type Recovered<E> = (E extends ReturnableDataError<infer Body> ? Body : never) | (E extends {
@@ -474,9 +527,9 @@ type Serialize<T> = ReturnType<typeof useLoaderData<() => Promise<T>>>;
474
527
  /**
475
528
  * The data an Effect that succeeds with `A` and fails with `E` gives the
476
529
  * component: its success and what its failures recover into, serialized as
477
- * React Router serializes them, with the registered action metadata.
530
+ * React Router serializes them, with the registered route metadata.
478
531
  */
479
- type EffectData<A, E> = WithActionMeta<Serialize<A | Recovered<E>>, RegisteredMeta>;
532
+ type EffectData<A, E> = WithRouteMeta<Serialize<A | Recovered<E>>, RegisteredMeta>;
480
533
  /**
481
534
  * The route data of a loader or action, for React Router's hooks:
482
535
  * `useFetcher<RouteData<typeof action>>()`. A function that returns an Effect
@@ -513,7 +566,7 @@ type Unwrapped<A> = A extends Response ? never : A extends UNSAFE_DataWithRespon
513
566
  * checks library types (`skipLibCheck: false`) then gets TS2636 in Effect's own
514
567
  * declarations. `test/consumer` checks the built package for that.
515
568
  */
516
- type BrandData<A, E> = [A] extends [never] ? WithActionMeta<Recovered<E>, RegisteredMeta> : [A] extends [void] ? unknown : WithActionMeta<Unwrapped<A> | Recovered<E>, RegisteredMeta>;
569
+ type BrandData<A, E> = [A] extends [never] ? WithRouteMeta<Recovered<E>, RegisteredMeta> : [A] extends [void] ? unknown : WithRouteMeta<Unwrapped<A> | Recovered<E>, RegisteredMeta>;
517
570
  declare module "effect/Effect" {
518
571
  interface Effect<out A, out E, out R> {
519
572
  readonly unstable__ReactRouter_SerializesTo: [BrandData<A, E>];
@@ -546,4 +599,4 @@ type EffectRouteArgs<Args extends {
546
599
  }> = Omit<Args, "pattern"> & {
547
600
  readonly pattern?: string;
548
601
  };
549
- export { ReturnableDataError as C, WithActionMeta as D, isRouteError as E, actionMetaKey as O, AnyRouteError as S, ThrowableRedirectError as T, NameError as _, RegisteredMeta as a, RouteResult as b, Serialize as c, EffectNext as d, ErrorHandler as f, LibraryHandled as g, IsAny as h, Register$1 as i, ActionMetaArgs as l, FailureResponse as m, EffectData as n, RegisteredRecover as o, FactoryTypes as p, EffectRouteArgs as r, RouteData as s, BrandData as t, EffectMiddlewareArgs as u, RequestContextKey as v, ThrowableDataError as w, makeEffectRouteFactory as x, RouteHandlers as y };
602
+ export { ThrowableRedirectError as A, RouteHandlers as C, AnyRouteError as D, makeEffectRouteFactory as E, WithRouteMeta as M, routeMetaKey as N, ReturnableDataError as O, RequestContextKey as S, RouteResult as T, FactoryTypes as _, RegisteredMeta as a, LibraryHandled as b, Serialize as c, deferredFailureMessage as d, DeferHandled as f, ErrorHandler as g, EffectNext as h, Register$1 as i, isRouteError as j, ThrowableDataError as k, DeferDiagnose as l, EffectMiddlewareArgs as m, EffectData as n, RegisteredRecover as o, DeferRecoverOf as p, EffectRouteArgs as r, RouteData as s, BrandData as t, DeferredPromise as u, FailureResponse as v, RouteMetaArgs as w, NameError as x, IsAny as y };
package/dist/server.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as ReturnableDataError, _ as NameError, a as RegisteredMeta, g as LibraryHandled, h as IsAny, m as FailureResponse, o as RegisteredRecover, p as FactoryTypes, y as RouteHandlers } from "./route-data-CWBxnNAA.mjs";
1
+ import { C as RouteHandlers, O as ReturnableDataError, _ as FactoryTypes, a as RegisteredMeta, b as LibraryHandled, o as RegisteredRecover, v as FailureResponse, x as NameError, y as IsAny } from "./route-data-ByiuG-Yb.mjs";
2
2
  import { Context, Effect, FileSystem, Layer, Option, Path, Scope } from "effect";
3
3
  import { Register, RouterContextProvider, ServerBuild } from "react-router";
4
4
  import { HttpPlatform, HttpRouter, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/http";
@@ -227,7 +227,7 @@ interface RegisterMismatch<Key, Factory, Registered> {
227
227
  readonly registered: Registered;
228
228
  }
229
229
  /** The registration mismatches of a factory's types. */
230
- type RegisterMismatches<Returns, Meta> = (SameType<FactoryRecover<Returns>, RegisteredRecover> extends true ? never : RegisterMismatch<"recover", FactoryRecover<Returns>, RegisteredRecover>) | (SameType<Meta, RegisteredMeta> extends true ? never : RegisterMismatch<"actionMeta", Meta, RegisteredMeta>);
230
+ type RegisterMismatches<Returns, Meta> = (SameType<FactoryRecover<Returns>, RegisteredRecover> extends true ? never : RegisterMismatch<"recover", FactoryRecover<Returns>, RegisteredRecover>) | (SameType<Meta, RegisteredMeta> extends true ? never : RegisterMismatch<"routeMeta", Meta, RegisteredMeta>);
231
231
  /** Every failure: the routes', then the registration's. */
232
232
  type Failures<Modules, Types> = Types extends FactoryTypes<infer DomainError, infer Returns, infer Services, infer Meta> ? AllFailures<Modules, LibraryHandled<DomainError>, Services> | RegisterMismatches<Returns, Meta> : never;
233
233
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://schemastore.org/package.json",
3
3
  "name": "react-router-effect",
4
- "version": "2.2.0",
4
+ "version": "2.3.0",
5
5
  "description": "Wrap React Router framework-mode route modules with Effect, with typed recover/throw/redirect error handling.",
6
6
  "keywords": [
7
7
  "action",
package/src/check.ts CHANGED
@@ -130,7 +130,7 @@ type RegisterMismatches<Returns, Meta> =
130
130
  : RegisterMismatch<"recover", FactoryRecover<Returns>, RegisteredRecover>)
131
131
  | (SameType<Meta, RegisteredMeta> extends true
132
132
  ? never
133
- : RegisterMismatch<"actionMeta", Meta, RegisteredMeta>);
133
+ : RegisterMismatch<"routeMeta", Meta, RegisteredMeta>);
134
134
 
135
135
  /** Every failure: the routes', then the registration's. */
136
136
  type Failures<Modules, Types> =
package/src/defer.ts ADDED
@@ -0,0 +1,179 @@
1
+ import { Cause, Context, Effect, Exit, Option, Scope } from "effect";
2
+ import {
3
+ data,
4
+ UNSAFE_ErrorResponseImpl as ErrorResponseImpl,
5
+ type ErrorResponse,
6
+ type UNSAFE_DataWithResponseInit as DataWithResponseInit,
7
+ } from "react-router";
8
+
9
+ import { ReturnableDataError, ThrowableDataError, type AnyRouteError } from "./errors.ts";
10
+ import type { FailureResponse, IsAny, NameError } from "./factory.ts";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // The scope of deferred work.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /**
17
+ * The scope that deferred work forks into. The route runner sets it for each
18
+ * loader or action run, and closes it when the request aborts or the route
19
+ * Effect fails. Outside a route runner it is unset, and deferred work runs
20
+ * detached.
21
+ */
22
+ export const DeferScope = Context.Reference<Scope.Scope | undefined>(
23
+ "react-router-effect/DeferScope",
24
+ { defaultValue: () => undefined },
25
+ );
26
+
27
+ /**
28
+ * Gives a route Effect a scope for its deferred work. The scope closes, and
29
+ * interrupts the deferred fibers, when `signal` aborts or the Effect fails: a
30
+ * failed loader sends no data, so no `<Await>` waits for the work.
31
+ */
32
+ export const withDeferScope = <A, E, R>(
33
+ effect: Effect.Effect<A, E, R>,
34
+ signal: AbortSignal,
35
+ ): Effect.Effect<A, E, R> =>
36
+ Effect.suspend(() => {
37
+ const scope = Scope.makeUnsafe("parallel");
38
+ const close = <X, Y>(exit: Exit.Exit<X, Y>) => Scope.close(scope, exit);
39
+ signal.addEventListener("abort", () => Effect.runFork(close(Exit.interrupt())), {
40
+ once: true,
41
+ });
42
+ return effect.pipe(
43
+ Effect.provideService(DeferScope, scope),
44
+ Effect.onError((cause) => close(Exit.failCause(cause))),
45
+ );
46
+ });
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // The deferred promise.
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * The compile-time diagnostic of the factory's `defer`. A deferred Effect
54
+ * runs after the response started, so a failure cannot change the response.
55
+ * The Effect must handle every error it can fail with before it is deferred,
56
+ * except the `Handled` ones, which the error handlers turn into a value or an
57
+ * `ErrorResponse`. Resolves to `unknown` (a no-op intersection) when every
58
+ * error is handled.
59
+ *
60
+ * @stability unstable
61
+ */
62
+ export type DeferDiagnose<E, Handled> =
63
+ IsAny<E> extends true
64
+ ? unknown
65
+ : [Exclude<E, Handled>] extends [never]
66
+ ? unknown
67
+ : {
68
+ "react-router-effect": "A deferred effect runs after the response started, so it can fail only with the factory's domain errors, Respond.early and Respond.throw. Handle the other errors before defer: recover them into data (e.g. Effect.catchTag), or make them defects with Effect.orDie.";
69
+ unhandledErrors: NameError<Exclude<E, Handled>>;
70
+ };
71
+
72
+ /**
73
+ * The promise that the factory's `defer` gives: a `Promise` of the deferred Effect's
74
+ * success, which React Router streams to `<Await>`. The work started when the
75
+ * Effect was deferred.
76
+ *
77
+ * It is a type of its own so that an Effect that succeeds with it states
78
+ * that the promise is not awaited on purpose. The language service's
79
+ * `promiseInEffectSuccess` rule reports only the global `Promise` type.
80
+ *
81
+ * @stability unstable
82
+ */
83
+ export interface DeferredPromise<A> extends Promise<A> {}
84
+
85
+ /**
86
+ * The message every deferred rejection has. The server logs the cause.
87
+ *
88
+ * @stability unstable
89
+ */
90
+ export const deferredFailureMessage = "Deferred route data failed";
91
+
92
+ /**
93
+ * The rejection the browser gets for a defect or an interrupt: a constant
94
+ * message and no stack, in every server mode. React Router removes server
95
+ * details only in production.
96
+ */
97
+ const deferredFailure = (): Error => {
98
+ const error = new Error(deferredFailureMessage);
99
+ error.stack = undefined;
100
+ return error;
101
+ };
102
+
103
+ /**
104
+ * Forks `self` into the run's defer scope and gives a promise of its result.
105
+ * A failure rejects the promise with its `ErrorResponse`, which React Router
106
+ * streams to `useAsyncError`. A defect or interrupt rejects it with
107
+ * `deferredFailure`, and the server logs a defect's full cause.
108
+ */
109
+ export const forkDeferred = <A, R>(
110
+ self: Effect.Effect<A, ErrorResponse, R>,
111
+ ): Effect.Effect<DeferredPromise<A>, never, R> =>
112
+ Effect.gen(function* () {
113
+ const scope = yield* DeferScope;
114
+ const work = self.pipe(
115
+ Effect.tapCause((cause) =>
116
+ Cause.hasDies(cause) ? Effect.logError(deferredFailureMessage, cause) : Effect.void,
117
+ ),
118
+ );
119
+ const fiber = yield* scope === undefined ? Effect.forkDetach(work) : Effect.forkIn(work, scope);
120
+ const promise: DeferredPromise<A> = new Promise<A>((resolve, reject) => {
121
+ fiber.addObserver((exit) => {
122
+ if (Exit.isSuccess(exit)) return resolve(exit.value);
123
+ reject(
124
+ Cause.hasDies(exit.cause)
125
+ ? deferredFailure()
126
+ : Option.getOrElse(Cause.findErrorOption(exit.cause), deferredFailure),
127
+ );
128
+ });
129
+ });
130
+ // Marks the rejection handled, so a promise that React Router never reads,
131
+ // because the loader failed after it deferred, is not an unhandled rejection.
132
+ // `<Await>` still reads the rejection from `promise` itself.
133
+ promise.catch(() => undefined);
134
+ return promise;
135
+ });
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Route errors in deferred work.
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /** A thrown `data(...)` as an `ErrorResponse`, with React Router's defaults. */
142
+ const toErrorResponse = (thrown: DataWithResponseInit<unknown>): ErrorResponse =>
143
+ new ErrorResponseImpl(
144
+ thrown.init?.status ?? 500,
145
+ thrown.init?.statusText ?? "Internal Server Error",
146
+ thrown.data,
147
+ );
148
+
149
+ /**
150
+ * A library route error raised in deferred work: `Respond.early` resolves the
151
+ * promise with its value, and `Respond.throw` rejects it with an
152
+ * `ErrorResponse`. A redirect cannot happen after the response started, so it
153
+ * is a defect.
154
+ */
155
+ export const deferRouteError = (e: AnyRouteError): Effect.Effect<unknown, ErrorResponse> => {
156
+ if (e instanceof ReturnableDataError) return Effect.succeed(e.data);
157
+ if (e instanceof ThrowableDataError) return Effect.fail(toErrorResponse(data(e.data, e.init)));
158
+ return Effect.die(e);
159
+ };
160
+
161
+ /**
162
+ * A handler's `Effect.fail(response)` in deferred work, as an `ErrorResponse`:
163
+ * a `data(...)` as React Router converts a thrown one, and a `Response` with
164
+ * its body read as React Router reads it, JSON or text. A redirect is a defect.
165
+ */
166
+ export const deferFailureResponse = (
167
+ failure: FailureResponse,
168
+ ): Effect.Effect<never, ErrorResponse> => {
169
+ if (!(failure instanceof Response)) return Effect.fail(toErrorResponse(failure));
170
+ if (failure.status >= 300 && failure.status < 400) return Effect.die(failure);
171
+ const isJson = /\bapplication\/json\b/.test(failure.headers.get("Content-Type") ?? "");
172
+ return Effect.promise(() =>
173
+ isJson && failure.body !== null ? failure.json() : failure.text(),
174
+ ).pipe(
175
+ Effect.flatMap((body) =>
176
+ Effect.fail(new ErrorResponseImpl(failure.status, failure.statusText, body)),
177
+ ),
178
+ );
179
+ };
package/src/factory.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  data,
6
6
  redirect,
7
7
  type ActionFunctionArgs,
8
+ type ErrorResponse,
8
9
  type UNSAFE_DataWithResponseInit as DataWithResponseInit,
9
10
  type LoaderFunctionArgs,
10
11
  type MiddlewareFunction,
@@ -12,7 +13,15 @@ import {
12
13
  type RouterContextProvider,
13
14
  } from "react-router";
14
15
 
15
- import { attachActionMeta, type WithActionMeta } from "./action-meta.ts";
16
+ import { attachRouteMeta, type WithRouteMeta } from "./route-meta.ts";
17
+ import {
18
+ deferFailureResponse,
19
+ deferRouteError,
20
+ forkDeferred,
21
+ withDeferScope,
22
+ type DeferDiagnose,
23
+ type DeferredPromise,
24
+ } from "./defer.ts";
16
25
  import {
17
26
  isRouteError,
18
27
  Respond as baseRespond,
@@ -124,6 +133,40 @@ export type RecoverOf<DomainError extends Tagged, Returns, E> = {
124
133
  | (Returns[Tag] extends Effect.Effect<infer SuccessValue, any, any> ? SuccessValue : never);
125
134
  }[keyof Returns];
126
135
 
136
+ /**
137
+ * The errors the factory's `defer` handles in deferred work: the declared
138
+ * domain errors, and `Respond.early` / `Respond.throw`. A redirect is not one
139
+ * of them: it cannot happen after the response started.
140
+ *
141
+ * @stability unstable
142
+ */
143
+ export type DeferHandled<DomainError> =
144
+ | DomainError
145
+ | ReturnableDataError<unknown>
146
+ | ThrowableDataError<unknown>;
147
+
148
+ /**
149
+ * What a deferred promise resolves with when the work fails with `E`: the
150
+ * value of a `Respond.early` raised directly, and what the registered
151
+ * handlers recover. Unlike loader data, a recovered `Respond.early` gives its
152
+ * value without the `data(...)` wrapper: a promise has no response to set.
153
+ *
154
+ * @stability unstable
155
+ */
156
+ export type DeferRecoverOf<DomainError extends Tagged, Returns, E> =
157
+ | ReturnableBodyOf<E>
158
+ | {
159
+ [Tag in keyof Returns]: [Extract<E, Extract<DomainError, { readonly _tag: Tag }>>] extends [
160
+ never,
161
+ ]
162
+ ? never
163
+ :
164
+ | (Returns[Tag] extends ReturnableDataError<infer B> ? B : never)
165
+ | (Returns[Tag] extends Effect.Effect<infer SuccessValue, any, any>
166
+ ? SuccessValue
167
+ : never);
168
+ }[keyof Returns];
169
+
127
170
  /**
128
171
  * Errors the library deals with on the handler's behalf - so the loader/action/
129
172
  * middleware needn't handle them itself:
@@ -280,8 +323,8 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
280
323
  */
281
324
  const defaultRequestContext: RequestContextKey<never> = createContext(Context.empty());
282
325
 
283
- /** What `actionMeta` reads: the request and the router context of the action. */
284
- export interface ActionMetaArgs {
326
+ /** What `routeMeta` reads: the request and the router context of the loader or action. */
327
+ export interface RouteMetaArgs {
285
328
  readonly request: Request;
286
329
  readonly context: Readonly<RouterContextProvider>;
287
330
  }
@@ -494,13 +537,14 @@ export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
494
537
  */
495
538
  respond?: ExtraRespond;
496
539
  /**
497
- * Request-scoped metadata to attach to every action's data under `__meta`,
498
- * read after the action resolves. The server adapter attaches it to every
499
- * action it runs, including plain functions and `makeAction` wrappers. A
500
- * thrown result gets none. Only plain records and `data(...)` bodies include
501
- * it; other values are returned unchanged.
540
+ * Request-scoped metadata to attach to the data of every loader and action
541
+ * under `__meta`, read after the function resolves. The server adapter
542
+ * attaches it to every loader and action it runs, including plain functions
543
+ * and `makeLoader` / `makeAction` wrappers. A thrown result gets none. Only
544
+ * plain records and `data(...)` bodies include it; other values are returned
545
+ * unchanged.
502
546
  */
503
- actionMeta?: (args: ActionMetaArgs) => Meta;
547
+ routeMeta?: (args: RouteMetaArgs) => Meta;
504
548
  },
505
549
  ) {
506
550
  const config = builder(baseRespond);
@@ -537,7 +581,10 @@ export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
537
581
  // The internal channel is deliberately loose (`unknown` success); the caller
538
582
  // restores the precise resolved type (computed from `E` and the handler map).
539
583
  // Sound at runtime - the values produced are exactly those types.
540
- const program = effect.pipe(
584
+ // Deferred work forks into a scope of this run, closed when the request
585
+ // aborts or the effect fails. It is inside the dispatch, so a failure that
586
+ // a handler recovers also interrupts the work: its promises are not sent.
587
+ const program = withDeferScope(effect, signal).pipe(
541
588
  // Catch the full error channel and dispatch. The refinement is `e is E`
542
589
  // (provably ⊆ E, and a *refinement* not a bare predicate - the predicate
543
590
  // overload crashes tsc over a generic `E`). A declared domain error with no
@@ -602,15 +649,18 @@ export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
602
649
  fn: (
603
650
  args: Args,
604
651
  ) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices>,
605
- ): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>> {
652
+ ): (
653
+ args: Args,
654
+ ) => Promise<WithRouteMeta<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>, Meta>> {
606
655
  // The diagnostic is irrelevant inside the body (it's `unknown` for any valid
607
656
  // call); treat `fn` as the plain effect-returning function it is.
608
657
  const run = fn as unknown as (
609
658
  args: Args,
610
659
  ) => Effect.Effect<unknown, E, RServices | ReqServices>;
660
+ // The server adapter's `handlers` attach the metadata to what this resolves with.
611
661
  return (args: Args) =>
612
662
  runProgram(run(args), args.context, args.request.signal) as Promise<
613
- A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>
663
+ WithRouteMeta<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>, Meta>
614
664
  >;
615
665
  }
616
666
 
@@ -722,7 +772,80 @@ export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
722
772
  : result;
723
773
  }
724
774
 
725
- const actionMeta = config.actionMeta;
775
+ /**
776
+ * A failure of deferred work, through the error handlers: a recovered
777
+ * value resolves the promise, a thrown response rejects it with an
778
+ * `ErrorResponse`. Anything else, such as a domain error with no handler,
779
+ * is a defect: the promise rejects with no server details.
780
+ */
781
+ function dispatchDeferred(e: unknown): Effect.Effect<unknown, ErrorResponse> {
782
+ for (const tag of userTags) {
783
+ if (Predicate.isTagged(e, tag)) {
784
+ const out = userHandlers[tag](e);
785
+ return isRouteError(out)
786
+ ? deferRouteError(out)
787
+ : out.pipe(Effect.catch(deferFailureResponse));
788
+ }
789
+ }
790
+ return isRouteError(e) ? deferRouteError(e) : Effect.die(e);
791
+ }
792
+
793
+ /**
794
+ * Starts an Effect without waiting for it, and gives a promise of its
795
+ * result for loader data. React Router streams the promise to `<Await>`.
796
+ *
797
+ * The work gets the services and the span of the Effect that defers it.
798
+ * It keeps running after the loader returns, and is interrupted when the
799
+ * request aborts or the loader fails. A client-side navigation away does
800
+ * not abort the request, so the work then runs until it ends or the stream
801
+ * times out.
802
+ *
803
+ * The work may fail only with the factory's domain errors,
804
+ * `Respond.early` and `Respond.throw`. The error handlers turn them into
805
+ * the promise's value, or an `ErrorResponse` for `useAsyncError`, as they
806
+ * do for a loader. A handler that redirects, a domain error with no
807
+ * handler, a defect or an interrupt rejects the promise with an error that
808
+ * holds no server details, and the server logs the full cause.
809
+ *
810
+ * ```ts
811
+ * export const { Respond, defer } = routeFactory;
812
+ *
813
+ * export const loader = Effect.fn("loader")(function* () {
814
+ * const user = yield* Users.current;
815
+ * return {
816
+ * user,
817
+ * reviews: yield* defer(Reviews.list(user.id)),
818
+ * stats: yield* Stats.load.pipe(
819
+ * Effect.catchTag("StatsUnavailable", () => Effect.succeed(null)),
820
+ * defer,
821
+ * ),
822
+ * };
823
+ * });
824
+ * ```
825
+ *
826
+ * @stability unstable
827
+ */
828
+ function defer<A, E, R>(
829
+ self: Effect.Effect<A, E, R> & DeferDiagnose<E, DeferHandled<DomainError>>,
830
+ ): Effect.Effect<DeferredPromise<A | DeferRecoverOf<DomainError, Returns, E>>, never, R> {
831
+ const work: Effect.Effect<unknown, ErrorResponse, R> = self.pipe(
832
+ Effect.catch(dispatchDeferred),
833
+ );
834
+ // The dispatch resolves with what `DeferRecoverOf` computes from the handlers.
835
+ return forkDeferred(work) as Effect.Effect<
836
+ DeferredPromise<A | DeferRecoverOf<DomainError, Returns, E>>,
837
+ never,
838
+ R
839
+ >;
840
+ }
841
+
842
+ const routeMeta = config.routeMeta;
843
+
844
+ /** Runs a loader or action's result and attaches the route metadata to its data. */
845
+ async function withRouteMeta(result: unknown, args: RouteArgs): Promise<unknown> {
846
+ const resolved = await runResult(result, args);
847
+ return routeMeta === undefined ? resolved : attachRouteMeta(resolved, routeMeta(args));
848
+ }
726
849
 
727
850
  // React Router calls every route function with args that include at least
728
851
  // `request` and `context`, so each wrapper reads them through `RouteArgs`.
@@ -730,13 +853,9 @@ export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
730
853
  // or recovered data, or the awaited plain result. `RouteResult` is its type.
731
854
  const handlers: RouteHandlers = {
732
855
  loader: (fn) => async (args) =>
733
- runResult(fn(args), args) as RouteResult<ReturnType<typeof fn>>,
734
- action: (fn) => async (args) => {
735
- const result = await runResult(fn(args), args);
736
- return (
737
- actionMeta === undefined ? result : attachActionMeta(result, actionMeta(args))
738
- ) as RouteResult<ReturnType<typeof fn>>;
739
- },
856
+ (await withRouteMeta(fn(args), args)) as RouteResult<ReturnType<typeof fn>>,
857
+ action: (fn) => async (args) =>
858
+ (await withRouteMeta(fn(args), args)) as RouteResult<ReturnType<typeof fn>>,
740
859
  middleware: (fn) => async (args, next) => {
741
860
  const routeArgs: RouteArgs = args;
742
861
  // The runner supplies `next` as an effect in place of the one `Args`
@@ -752,20 +871,10 @@ export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
752
871
 
753
872
  return {
754
873
  makeLoader: makeLoaderOrAction,
755
- makeAction: makeLoaderOrAction as <
756
- Args extends LoaderFunctionArgs | ActionFunctionArgs,
757
- A,
758
- E,
759
- R,
760
- >(
761
- fn: (
762
- args: Args,
763
- ) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices>,
764
- ) => (
765
- args: Args,
766
- ) => Promise<WithActionMeta<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>, Meta>>,
874
+ makeAction: makeLoaderOrAction,
767
875
  makeMiddleware,
768
876
  Respond,
877
+ defer,
769
878
  /** The route runner the server adapter applies to every route module. */
770
879
  handlers,
771
880
  /** Type-only: what the adapter's route check and `Register` read. */
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
- export { actionMetaKey } from "./action-meta.ts";
2
- export type { WithActionMeta } from "./action-meta.ts";
1
+ export { routeMetaKey } from "./route-meta.ts";
2
+ export type { WithRouteMeta } from "./route-meta.ts";
3
+ export { deferredFailureMessage } from "./defer.ts";
4
+ export type { DeferDiagnose, DeferredPromise } from "./defer.ts";
3
5
  export {
4
6
  isRouteError,
5
7
  ReturnableDataError,
@@ -9,7 +11,9 @@ export {
9
11
  export type { AnyRouteError } from "./errors.ts";
10
12
  export { makeEffectRouteFactory } from "./factory.ts";
11
13
  export type {
12
- ActionMetaArgs,
14
+ DeferHandled,
15
+ DeferRecoverOf,
16
+ RouteMetaArgs,
13
17
  EffectMiddlewareArgs,
14
18
  EffectNext,
15
19
  ErrorHandler,
package/src/route-data.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Effect } from "effect";
2
2
  import type { UNSAFE_DataWithResponseInit, useLoaderData } from "react-router";
3
3
 
4
- import type { WithActionMeta } from "./action-meta.ts";
4
+ import type { WithRouteMeta } from "./route-meta.ts";
5
5
  import type { ReturnableDataError } from "./errors.ts";
6
6
 
7
7
  // ---------------------------------------------------------------------------
@@ -28,8 +28,8 @@ import type { ReturnableDataError } from "./errors.ts";
28
28
  * interface Register {
29
29
  * // Each error tag a handler recovers, and the data it recovers into.
30
30
  * recover: { FormValidationError: { reply: SubmissionResponse } };
31
- * // What `actionMeta` returns.
32
- * actionMeta: { realtimeEventIds: ReadonlyArray<string> };
31
+ * // What `routeMeta` returns.
32
+ * routeMeta: { realtimeEventIds: ReadonlyArray<string> };
33
33
  * }
34
34
  * }
35
35
  * ```
@@ -39,8 +39,8 @@ export interface Register {}
39
39
  /** The registered recover map: error tag to recovered data. */
40
40
  export type RegisteredRecover = Register extends { readonly recover: infer Recover } ? Recover : {};
41
41
 
42
- /** The registered action metadata, or `never` when there is none. */
43
- export type RegisteredMeta = Register extends { readonly actionMeta: infer Meta } ? Meta : never;
42
+ /** The registered route metadata, or `never` when there is none. */
43
+ export type RegisteredMeta = Register extends { readonly routeMeta: infer Meta } ? Meta : never;
44
44
 
45
45
  /** What a failure of type `E` recovers into: `Respond.early` bodies and registered handlers. */
46
46
  type Recovered<E> =
@@ -59,9 +59,9 @@ export type Serialize<T> = ReturnType<typeof useLoaderData<() => Promise<T>>>;
59
59
  /**
60
60
  * The data an Effect that succeeds with `A` and fails with `E` gives the
61
61
  * component: its success and what its failures recover into, serialized as
62
- * React Router serializes them, with the registered action metadata.
62
+ * React Router serializes them, with the registered route metadata.
63
63
  */
64
- export type EffectData<A, E> = WithActionMeta<Serialize<A | Recovered<E>>, RegisteredMeta>;
64
+ export type EffectData<A, E> = WithRouteMeta<Serialize<A | Recovered<E>>, RegisteredMeta>;
65
65
 
66
66
  /**
67
67
  * The route data of a loader or action, for React Router's hooks:
@@ -108,10 +108,10 @@ type Unwrapped<A> = A extends Response
108
108
  * declarations. `test/consumer` checks the built package for that.
109
109
  */
110
110
  export type BrandData<A, E> = [A] extends [never]
111
- ? WithActionMeta<Recovered<E>, RegisteredMeta>
111
+ ? WithRouteMeta<Recovered<E>, RegisteredMeta>
112
112
  : [A] extends [void]
113
113
  ? unknown
114
- : WithActionMeta<Unwrapped<A> | Recovered<E>, RegisteredMeta>;
114
+ : WithRouteMeta<Unwrapped<A> | Recovered<E>, RegisteredMeta>;
115
115
 
116
116
  declare module "effect/Effect" {
117
117
  // React Router reads loader and action data through this brand, so an
@@ -2,11 +2,11 @@ import { Predicate } from "effect";
2
2
  import { data, type UNSAFE_DataWithResponseInit as DataWithResponseInit } from "react-router";
3
3
 
4
4
  // ---------------------------------------------------------------------------
5
- // Action metadata: request-scoped facts attached to every action's data.
5
+ // Route metadata: request-scoped facts attached to every loader and action's data.
6
6
  // ---------------------------------------------------------------------------
7
7
 
8
- /** The key that holds the metadata in action data. */
9
- export const actionMetaKey = "__meta";
8
+ /** The key that holds the metadata in loader and action data. */
9
+ export const routeMetaKey = "__meta";
10
10
 
11
11
  /**
12
12
  * Adds optional metadata to a record. Only records get metadata, because a
@@ -14,27 +14,29 @@ export const actionMetaKey = "__meta";
14
14
  * values (strings, arrays, `Response`s) have nowhere to put it.
15
15
  */
16
16
  type RecordWithMeta<T, Meta> =
17
- T extends Record<string, unknown>
18
- ? Omit<T, typeof actionMetaKey> & { [actionMetaKey]?: Meta }
19
- : T;
17
+ T extends Record<string, unknown> ? Omit<T, typeof routeMetaKey> & { [routeMetaKey]?: Meta } : T;
20
18
 
21
19
  /**
22
- * The type of action data after metadata is attached. A `data(...)` wrapper
20
+ * The type of loader or action data after metadata is attached. A `data(...)` wrapper
23
21
  * gets metadata on its body. With no configured metadata (`Meta` is `never`)
24
22
  * the data is unchanged.
25
23
  */
26
- export type WithActionMeta<T, Meta> = [Meta] extends [never]
24
+ export type WithRouteMeta<T, Meta> = [Meta] extends [never]
27
25
  ? T
28
26
  : T extends DataWithResponseInit<infer Body>
29
27
  ? DataWithResponseInit<RecordWithMeta<Body, Meta>>
30
28
  : RecordWithMeta<T, Meta>;
31
29
 
32
- // React Router exports the type of the `data(...)` wrapper, but not its class.
33
- const DataWithResponseInitClass = data(undefined).constructor;
34
-
35
- /** True for a `data(...)` wrapper, never for a business field that looks like one. */
30
+ /**
31
+ * True for a `data(...)` wrapper, by the same test React Router applies. A
32
+ * class test fails when the app and the adapter load different copies of
33
+ * React Router, as under the Vite dev server.
34
+ */
36
35
  const isDataWithResponseInit = (value: unknown): value is DataWithResponseInit<unknown> =>
37
- value instanceof DataWithResponseInitClass;
36
+ Predicate.hasProperty(value, "type") &&
37
+ Predicate.hasProperty(value, "data") &&
38
+ Predicate.hasProperty(value, "init") &&
39
+ value.type === "DataWithResponseInit";
38
40
 
39
41
  /** True for a plain object, which can be copied without losing behavior. */
40
42
  const isPlainRecord = (value: unknown): value is Record<string, unknown> => {
@@ -44,15 +46,15 @@ const isPlainRecord = (value: unknown): value is Record<string, unknown> => {
44
46
  };
45
47
 
46
48
  /**
47
- * Attaches `meta` to the action result. A plain record gets a copy with the
49
+ * Attaches `meta` to a loader or action result. A plain record gets a copy with the
48
50
  * metadata key; a `data(...)` wrapper gets it on its body and keeps its init.
49
51
  * Any other value is returned unchanged.
50
52
  */
51
- export const attachActionMeta = <T>(value: T, meta: unknown): T => {
53
+ export const attachRouteMeta = <T>(value: T, meta: unknown): T => {
52
54
  if (isDataWithResponseInit(value)) {
53
55
  if (!isPlainRecord(value.data)) return value;
54
56
  // The copy has the same shape as `value` plus the metadata key.
55
- return data({ ...value.data, [actionMetaKey]: meta }, value.init ?? undefined) as T;
57
+ return data({ ...value.data, [routeMetaKey]: meta }, value.init ?? undefined) as T;
56
58
  }
57
- return isPlainRecord(value) ? ({ ...value, [actionMetaKey]: meta } as T) : value;
59
+ return isPlainRecord(value) ? ({ ...value, [routeMetaKey]: meta } as T) : value;
58
60
  };