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 +88 -10
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +156 -20
- package/dist/{route-data-CWBxnNAA.d.mts → route-data-ByiuG-Yb.d.mts} +78 -25
- package/dist/server.d.mts +2 -2
- package/package.json +1 -1
- package/src/check.ts +1 -1
- package/src/defer.ts +179 -0
- package/src/factory.ts +141 -32
- package/src/index.ts +7 -3
- package/src/route-data.ts +9 -9
- package/src/{action-meta.ts → route-meta.ts} +19 -17
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`.
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
members, so for an Effect the hook
|
|
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
|
-
-
|
|
645
|
-
adapter attaches it to
|
|
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
|
-
|
|
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?,
|
|
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
|
|
2
|
-
export { type
|
|
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/
|
|
5
|
-
/** The key that holds the metadata in action data. */
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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
|
|
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
|
-
[
|
|
29
|
+
[routeMetaKey]: meta
|
|
27
30
|
}, value.init ?? void 0);
|
|
28
31
|
}
|
|
29
32
|
return isPlainRecord(value) ? {
|
|
30
33
|
...value,
|
|
31
|
-
[
|
|
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
|
-
|
|
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) =>
|
|
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,
|
|
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
|
|
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
|
|
12
|
-
[
|
|
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
|
|
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 `
|
|
242
|
-
interface
|
|
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
|
|
409
|
-
* read after the
|
|
410
|
-
* action it runs, including plain functions
|
|
411
|
-
* thrown result gets none. Only
|
|
412
|
-
* it; other values are returned
|
|
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
|
-
|
|
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<
|
|
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 `
|
|
450
|
-
*
|
|
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
|
|
513
|
+
/** The registered route metadata, or `never` when there is none. */
|
|
461
514
|
type RegisteredMeta = Register$1 extends {
|
|
462
|
-
readonly
|
|
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
|
|
530
|
+
* React Router serializes them, with the registered route metadata.
|
|
478
531
|
*/
|
|
479
|
-
type EffectData<A, E> =
|
|
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] ?
|
|
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 {
|
|
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
|
|
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<"
|
|
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.
|
|
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<"
|
|
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 {
|
|
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 `
|
|
284
|
-
export interface
|
|
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
|
|
498
|
-
* read after the
|
|
499
|
-
* action it runs, including plain functions
|
|
500
|
-
* thrown result gets none. Only
|
|
501
|
-
* it; other values are returned
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
): (
|
|
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
|
-
|
|
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
|
-
|
|
734
|
-
action: (fn) => async (args) =>
|
|
735
|
-
|
|
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
|
|
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 {
|
|
2
|
-
export type {
|
|
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
|
-
|
|
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 {
|
|
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 `
|
|
32
|
-
*
|
|
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
|
|
43
|
-
export type RegisteredMeta = Register extends { readonly
|
|
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
|
|
62
|
+
* React Router serializes them, with the registered route metadata.
|
|
63
63
|
*/
|
|
64
|
-
export type EffectData<A, E> =
|
|
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
|
-
?
|
|
111
|
+
? WithRouteMeta<Recovered<E>, RegisteredMeta>
|
|
112
112
|
: [A] extends [void]
|
|
113
113
|
? unknown
|
|
114
|
-
:
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
|
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
|
|
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, [
|
|
57
|
+
return data({ ...value.data, [routeMetaKey]: meta }, value.init ?? undefined) as T;
|
|
56
58
|
}
|
|
57
|
-
return isPlainRecord(value) ? ({ ...value, [
|
|
59
|
+
return isPlainRecord(value) ? ({ ...value, [routeMetaKey]: meta } as T) : value;
|
|
58
60
|
};
|