@alxia/react-router 0.37.0 → 0.38.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
@@ -1109,6 +1109,20 @@ const { fetcher } = useJsonFetcher<typeof action>('save');
1109
1109
  const answer = useFetcherAnswer(fetcher, (result) => toast(result.message));
1110
1110
  ```
1111
1111
 
1112
+ To await one submission's answer as a Promise (a confirm dialog whose `onConfirm` stays open and shows the error until it settles), `useFetcherPromise` resolves with the next new answer, or rejects with an `Error` when your `refusal(answer)` returns a message:
1113
+
1114
+ ```tsx
1115
+ import { useJsonFetcher } from '@alxia/react-router/errors';
1116
+ import { useFetcherPromise } from '@alxia/react-router/fetcher';
1117
+
1118
+ const { fetcher, post } = useJsonFetcher<typeof action>('delete');
1119
+ const run = useFetcherPromise(fetcher, {
1120
+ refusal: (result) => (result.ok ? undefined : result.error),
1121
+ });
1122
+ // a confirm dialog that stays open, and shows the error, while this rejects
1123
+ const onConfirm = () => run(() => post({ intent: 'delete', id }));
1124
+ ```
1125
+
1112
1126
  A resource route loaded for what is typed (a typeahead, a preview): one load
1113
1127
  after a pause, from a minimum length, the previous answer kept (`stale`) until
1114
1128
  the new one lands, and a retry:
@@ -1421,6 +1435,7 @@ From `@alxia/react-router/fetcher` (`react` and `react-router` are its imports;
1421
1435
  | export | |
1422
1436
  | --- | --- |
1423
1437
  | `useFetcherAnswer(fetcher, onAnswer?)`, `AnswerSource` | `onAnswer(answer)` once per answer that arrives while the fetcher is idle and differs from the last one seen (the data present at mount counts as seen); the handler is read from a ref; returns the latest answer handed over, `undefined` before the first |
1438
+ | `useFetcherPromise(fetcher, { refusal? }?)`, `FetcherPromiseOptions`, `FetcherPromiseAborted` | returns a stable `run(submit)` that calls `submit` (`() => fetcher.submit(...)`) and returns a Promise settled by the fetcher's next new answer: resolved with it, or rejected with an `Error` when `refusal(answer)` returns a message (or your own `Error`); an answer held before the call never settles it; a second `run` while one is pending rejects the older with `FetcherPromiseAborted` (`reason: 'superseded'`); an unmount rejects the pending one (`'unmounted'`); a `submit` that throws rejects with its error |
1424
1439
  | `useFormRefusal(result)`, `FormRefusalResult` | `useFormRefusal(fetcher.data)` returns the `FormErrors` of a fetcher answer that carries `fieldErrors` (`apiErrors.formRefusal`'s), `undefined` for no answer, a done step or a refusal without them; stable for one answer |
1425
1440
  | `useDebouncedFetcher(value, to, { delayMs?, minLength?, enabled?, key?, keep? }?)`, `DebouncedFetcher`, `DebouncedFetcherOptions`, `DebouncedFetcherState` | a resource route loaded for what is typed: one `fetcher.load` after `delayMs` (250) without a change and from `minLength` (1) trimmed characters; `to` is a URL or `(value) => url`; returns `{ data, stale, state, retry }`: `data` is the answer for the value as it stands, else the previous one with `stale` set; `state` is `'idle' \| 'waiting' \| 'loading' \| 'done'`; `keep(answer)` filters what may stay as the stale answer; `retry()` (stable) asks again at once |
1426
1441
 
@@ -1448,7 +1463,7 @@ The `alxia-react-router` bin, run with `bunx`:
1448
1463
  - [Installable and offline](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/pwa.md): what the subpaths hold, the worker's contract, the worker, what it caches, the offline page, building the worker with `alxia()` or `buildWorker`, the hooks in React, registering the worker and offering an update, serving the worker's script, the web app manifest, and clearing at sign-out.
1449
1464
  - [An API's refusals](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/errors.md): what `/errors` holds, recognising an API error, the request id, the page a request is for, the catalogue store, the form checks in the API's words, the provider, wording a refusal, loaders and the error page, actions, JSON actions and CSRF, and keeping the catalogue fresh.
1450
1465
  - [A BFF session](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/session.md): redirects that stay on the site, the cookies, the sign-in route, the second factor, the viewer and sign-out, the step-up, and the pages a mail's link opens.
1451
- - [A fetcher's fresh answer](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/fetcher.md): `useFetcherAnswer`: a handler called once per new answer of a keyed fetcher, not for the data it held at mount; `useDebouncedFetcher`: a resource route loaded for what is typed.
1466
+ - [A fetcher's fresh answer](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/fetcher.md): `useFetcherAnswer`: a handler called once per new answer of a keyed fetcher, not for the data it held at mount; `useFetcherPromise`: one submission's answer as a Promise; `useDebouncedFetcher`: a resource route loaded for what is typed.
1452
1467
  - [Lists whose state is the URL](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/lists.md): `useSearchChange`, `useSameRouteLoading`, `usePagedRows`: a search that drops the cursor, a skeleton for the same page reloading, a load-more button.
1453
1468
  - [Subscriptions for a BFF](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/subscriptions.md): exposing a GraphQL subscription to the browser through a resource route: the allow-list, the session's bearer, the headers, the heartbeat, abort and rate limits.
1454
1469
  - [Troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md): each message, and the traps that print none.
@@ -5,5 +5,6 @@
5
5
  */
6
6
  export { type DebouncedFetcher, type DebouncedFetcherOptions, type DebouncedFetcherState, useDebouncedFetcher, } from './use-debounced-fetcher';
7
7
  export { type AnswerSource, useFetcherAnswer } from './use-fetcher-answer';
8
+ export { FetcherPromiseAborted, type FetcherPromiseOptions, useFetcherPromise, } from './use-fetcher-promise';
8
9
  export { type FormRefusalResult, useFormRefusal } from './use-form-refusal';
9
10
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/fetcher/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACN,KAAK,gBAAgB,EACrB,KAAK,uBAAuB,EAC5B,KAAK,qBAAqB,EAC1B,mBAAmB,GACnB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,KAAK,YAAY,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAC3E,OAAO,EAAE,KAAK,iBAAiB,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/fetcher/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACN,KAAK,gBAAgB,EACrB,KAAK,uBAAuB,EAC5B,KAAK,qBAAqB,EAC1B,mBAAmB,GACnB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,KAAK,YAAY,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAC3E,OAAO,EACN,qBAAqB,EACrB,KAAK,qBAAqB,EAC1B,iBAAiB,GACjB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,KAAK,iBAAiB,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC"}
@@ -92,6 +92,60 @@ function useFetcherAnswer(fetcher, onAnswer) {
92
92
  }, [answer]);
93
93
  return latest;
94
94
  }
95
+ // src/fetcher/use-fetcher-promise.ts
96
+ import { useCallback as useCallback2, useEffect as useEffect3, useRef as useRef3 } from "react";
97
+ class FetcherPromiseAborted extends Error {
98
+ name = "FetcherPromiseAborted";
99
+ reason;
100
+ constructor(reason) {
101
+ super(reason === "superseded" ? "useFetcherPromise: a newer call replaced this one before the fetcher answered" : "useFetcherPromise: the component unmounted before the fetcher answered");
102
+ this.reason = reason;
103
+ }
104
+ }
105
+ function useFetcherPromise(fetcher, options = {}) {
106
+ const waiting = useRef3(null);
107
+ const refusal = useRef3(options.refusal);
108
+ refusal.current = options.refusal;
109
+ useFetcherAnswer(fetcher, (answer) => {
110
+ const pending = waiting.current;
111
+ if (!pending)
112
+ return;
113
+ waiting.current = null;
114
+ let refused;
115
+ try {
116
+ refused = refusal.current?.(answer);
117
+ } catch (error) {
118
+ pending.reject(error);
119
+ return;
120
+ }
121
+ if (refused === undefined)
122
+ pending.resolve(answer);
123
+ else
124
+ pending.reject(typeof refused === "string" ? new Error(refused) : refused);
125
+ });
126
+ useEffect3(() => () => {
127
+ const pending = waiting.current;
128
+ waiting.current = null;
129
+ pending?.reject(new FetcherPromiseAborted("unmounted"));
130
+ }, []);
131
+ return useCallback2((submit) => {
132
+ waiting.current?.reject(new FetcherPromiseAborted("superseded"));
133
+ return new Promise((resolve, reject) => {
134
+ const mine = { resolve, reject };
135
+ waiting.current = mine;
136
+ const fail = (error) => {
137
+ if (waiting.current === mine)
138
+ waiting.current = null;
139
+ reject(error);
140
+ };
141
+ try {
142
+ Promise.resolve(submit()).catch(fail);
143
+ } catch (error) {
144
+ fail(error);
145
+ }
146
+ });
147
+ }, []);
148
+ }
95
149
  // src/fetcher/use-form-refusal.ts
96
150
  import { useMemo } from "react";
97
151
  function useFormRefusal(result) {
@@ -101,10 +155,12 @@ function useFormRefusal(result) {
101
155
  } : undefined, [result]);
102
156
  }
103
157
  export {
158
+ FetcherPromiseAborted,
104
159
  useDebouncedFetcher,
105
160
  useFetcherAnswer,
161
+ useFetcherPromise,
106
162
  useFormRefusal
107
163
  };
108
164
 
109
- //# debugId=42D032646A4DF0F764756E2164756E21
165
+ //# debugId=6FFF1F15C0AB3E6F64756E2164756E21
110
166
  //# sourceMappingURL=index.js.map
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/fetcher/use-debounced-fetcher.ts", "../src/fetcher/use-fetcher-answer.ts", "../src/fetcher/use-form-refusal.ts"],
3
+ "sources": ["../src/fetcher/use-debounced-fetcher.ts", "../src/fetcher/use-fetcher-answer.ts", "../src/fetcher/use-fetcher-promise.ts", "../src/fetcher/use-form-refusal.ts"],
4
4
  "sourcesContent": [
5
5
  "import {\n\tstartTransition,\n\tuseCallback,\n\tuseEffect,\n\tuseLayoutEffect,\n\tuseRef,\n\tuseState,\n} from 'react';\nimport { useFetcher } from 'react-router';\n\n/** What `useFetcher<Data>` hands back as `data` (`Data` may be `typeof loader`). */\ntype FetcherData<Data> = ReturnType<typeof useFetcher<Data>>['data'];\n\n/** What `useDebouncedFetcher` is doing for the value typed. */\nexport type DebouncedFetcherState =\n\t/** Nothing to ask: disabled, or the value is shorter than `minLength`. */\n\t| 'idle'\n\t/** The value changed and the pause after the last change is running. */\n\t| 'waiting'\n\t/** The resource route is loading. */\n\t| 'loading'\n\t/** The route answered for the value as it stands. */\n\t| 'done';\n\nexport interface DebouncedFetcherOptions<Data = unknown> {\n\t/** Pause after the last change before the route is asked. Default 250. */\n\treadonly delayMs?: number;\n\t/** The trimmed value must have this many characters. Default 1. */\n\treadonly minLength?: number;\n\t/** `false` asks nothing and forgets the previous answer. Default `true`. */\n\treadonly enabled?: boolean;\n\t/** The fetcher's key, to share it with another component. */\n\treadonly key?: string;\n\t/**\n\t * Which answers may be kept as the stale `data` while the next value waits\n\t * or loads. Default: all. An answer it refuses (a failure) is still `data`\n\t * while it is the current one, but is never shown as the previous one: after a refused answer, the stale\n\t * data is the last *kept* answer, not the refused one.\n\t */\n\treadonly keep?: (data: NonNullable<FetcherData<Data>>) => boolean;\n}\n\nexport interface DebouncedFetcher<Data> {\n\t/**\n\t * The route's answer for the value as it stands, else (`stale`) the last\n\t * answer for an earlier value, kept until the new one lands. `undefined`\n\t * before any answer, and again once the hook goes `idle`.\n\t */\n\treadonly data: FetcherData<Data>;\n\t/** `data` answers an earlier value (or none is current yet). */\n\treadonly stale: boolean;\n\treadonly state: DebouncedFetcherState;\n\t/** Asks the route again for the value as it stands, without waiting. */\n\treadonly retry: () => void;\n}\n\n/**\n * A resource route loaded through a fetcher for a value that changes as the\n * user types. After `delayMs` without a change, and from `minLength`\n * characters, it calls `fetcher.load` once (a newer load cancels the\n * previous one). An answer only counts for the value it was asked for: while\n * a newer value waits or loads, `data` is the previous answer with `stale`\n * set, and an answer for an older value is never returned as current.\n * `to` is the route's URL, or a function of the trimmed value that builds it\n * (a string without a function is loaded as it is, the value being only the\n * trigger). A refusal the route answers as data is `data` like any answer.\n */\nexport function useDebouncedFetcher<Data>(\n\tvalue: string,\n\tto: string | ((value: string) => string),\n\toptions: DebouncedFetcherOptions<Data> = {},\n): DebouncedFetcher<Data> {\n\tconst { delayMs = 250, minLength = 1, enabled = true, key, keep } = options;\n\tconst keepRef = useRef(keep);\n\tkeepRef.current = keep;\n\tconst fetcher = useFetcher<Data>(key === undefined ? undefined : { key });\n\tconst load = useRef(fetcher.load);\n\tload.current = fetcher.load;\n\tconst text = value.trim();\n\tconst url =\n\t\tenabled && text.length >= minLength\n\t\t\t? typeof to === 'function'\n\t\t\t\t? to(text)\n\t\t\t\t: to\n\t\t\t: null;\n\t// The URL the fetcher was last told to load.\n\tconst [loaded, setLoaded] = useState<string | null>(null);\n\tconst previous = useRef<FetcherData<Data>>(undefined);\n\t// React Router applies the fetcher's `loading` state in a transition, so\n\t// right after `load()` a render can still show `idle` and the previous\n\t// answer. A load has landed only once the fetcher was seen busy for that very load (the load number is kept, so a busy render of a superseded load does not count), or\n\t// its `load()` promise settled and a transition queued after it committed\n\t// (a fast load whose busy state was never rendered, or a route answering\n\t// the very same object twice). `seq` numbers the loads, so a superseded\n\t// one never counts.\n\tconst seq = useRef(0);\n\tconst [settled, setSettled] = useState(0);\n\tconst busySeq = useRef(0);\n\tuseLayoutEffect(() => {\n\t\tif (fetcher.state !== 'idle') busySeq.current = seq.current;\n\t}, [fetcher.state]);\n\tconst ask = (target: string) => {\n\t\tconst mine = ++seq.current;\n\t\tsetLoaded(target);\n\t\tvoid Promise.resolve(load.current(target)).then(() => {\n\t\t\tif (mine === seq.current) startTransition(() => setSettled(mine));\n\t\t});\n\t};\n\tconst askRef = useRef(ask);\n\taskRef.current = ask;\n\n\tuseEffect(() => {\n\t\tif (url === null) {\n\t\t\tprevious.current = undefined;\n\t\t\tsetLoaded(null);\n\t\t\treturn;\n\t\t}\n\t\tconst timer = setTimeout(() => {\n\t\t\taskRef.current(url);\n\t\t}, delayMs);\n\t\treturn () => clearTimeout(timer);\n\t}, [url, text, delayMs]);\n\n\tconst landed =\n\t\tfetcher.state === 'idle' &&\n\t\t(busySeq.current === seq.current || settled === seq.current);\n\tconst current =\n\t\turl !== null && loaded === url && landed && fetcher.data !== undefined;\n\tconst answer = current ? fetcher.data : undefined;\n\tuseEffect(() => {\n\t\tif (answer === undefined || answer === null) return;\n\t\tif (keepRef.current?.(answer) ?? true) previous.current = answer;\n\t}, [answer]);\n\n\tconst data = current ? answer : url === null ? undefined : previous.current;\n\tlet state: DebouncedFetcherState;\n\tif (url === null) state = 'idle';\n\telse if (loaded !== url) state = 'waiting';\n\telse state = landed ? 'done' : 'loading';\n\n\tconst urlRef = useRef(url);\n\turlRef.current = url;\n\tconst retry = useCallback(() => {\n\t\tconst now = urlRef.current;\n\t\tif (now === null) return;\n\t\taskRef.current(now);\n\t}, []);\n\treturn { data, stale: state !== 'done' && data !== undefined, state, retry };\n}\n",
6
6
  "import { useEffect, useRef, useState } from 'react';\n\n/** What the hook reads of a React Router fetcher. */\nexport interface AnswerSource<Data> {\n\treadonly state: string;\n\treadonly data: Data | undefined;\n}\n\n/**\n * The answers a fetcher gives, one at a time. A keyed fetcher outlives its\n * component, so the data it holds when the component mounts is an earlier\n * visit's, not this one's. `onAnswer` is called once for each answer that\n * arrives while the fetcher is idle and is another object than the last one\n * seen (the data present at mount counts as seen). The handler is read from\n * a ref, so a new function each render never re-fires it. Returns the latest\n * answer handed over, `undefined` until one arrives.\n */\nexport function useFetcherAnswer<Data>(\n\tfetcher: AnswerSource<Data>,\n\tonAnswer?: (answer: Data) => void,\n): Data | undefined {\n\tconst seen = useRef(fetcher.data);\n\tconst handler = useRef(onAnswer);\n\thandler.current = onAnswer;\n\tconst [latest, setLatest] = useState<Data | undefined>(undefined);\n\tconst answer = fetcher.state === 'idle' ? fetcher.data : undefined;\n\tuseEffect(() => {\n\t\tif (answer === undefined || answer === null || answer === seen.current) {\n\t\t\treturn;\n\t\t}\n\t\tseen.current = answer;\n\t\tsetLatest(answer);\n\t\thandler.current?.(answer);\n\t}, [answer]);\n\treturn latest;\n}\n",
7
+ "import { useCallback, useEffect, useRef } from 'react';\nimport { type AnswerSource, useFetcherAnswer } from './use-fetcher-answer';\n\n/** What `useFetcherPromise` accepts. */\nexport interface FetcherPromiseOptions<Data> {\n\t/**\n\t * Decides whether an answer is a refusal: the message (or the `Error`) the\n\t * promise rejects with, `undefined` to resolve with the answer. Read from a\n\t * ref, so a new function each render is fine. Without it every answer\n\t * resolves.\n\t */\n\trefusal?: (answer: Data) => string | Error | undefined;\n}\n\n/** The rejection of a call replaced by a newer one, or cut off by an unmount. */\nexport class FetcherPromiseAborted extends Error {\n\toverride readonly name = 'FetcherPromiseAborted';\n\treadonly reason: 'superseded' | 'unmounted';\n\tconstructor(reason: 'superseded' | 'unmounted') {\n\t\tsuper(\n\t\t\treason === 'superseded'\n\t\t\t\t? 'useFetcherPromise: a newer call replaced this one before the fetcher answered'\n\t\t\t\t: 'useFetcherPromise: the component unmounted before the fetcher answered',\n\t\t);\n\t\tthis.reason = reason;\n\t}\n}\n\ninterface Waiting<Data> {\n\tresolve(answer: Data): void;\n\treject(error: unknown): void;\n}\n\n/**\n * Ties one submission to its answer. `run(submit)` calls `submit` (usually\n * `() => fetcher.submit(...)`) and returns a Promise settled by the fetcher's\n * next new answer (see `useFetcherAnswer`): it resolves with the answer, or\n * rejects with an `Error` when `options.refusal` returns a message for it. An\n * answer the fetcher already held when `run` was called never settles it.\n *\n * A second `run` while one is pending rejects the older promise with a\n * `FetcherPromiseAborted` (`'superseded'`) and waits for the answer to the\n * newer one. An unmount rejects the pending promise with a\n * `FetcherPromiseAborted` (`'unmounted'`). A `submit` that throws (or whose\n * promise rejects) rejects the promise with that error, and so does a\n * `refusal` that throws. `run` is stable.\n *\n * Catch every promise `run` returns: an abort rejects it, and one nobody\n * handles is an unhandled rejection. Ignore the aborts with\n * `.catch((e) => { if (!(e instanceof FetcherPromiseAborted)) throw e; })`.\n */\nexport function useFetcherPromise<Data>(\n\tfetcher: AnswerSource<Data>,\n\toptions: FetcherPromiseOptions<Data> = {},\n): (submit: () => void | Promise<unknown>) => Promise<Data> {\n\tconst waiting = useRef<Waiting<Data> | null>(null);\n\tconst refusal = useRef(options.refusal);\n\trefusal.current = options.refusal;\n\n\tuseFetcherAnswer(fetcher, (answer) => {\n\t\tconst pending = waiting.current;\n\t\tif (!pending) return;\n\t\twaiting.current = null;\n\t\tlet refused: string | Error | undefined;\n\t\ttry {\n\t\t\trefused = refusal.current?.(answer);\n\t\t} catch (error) {\n\t\t\tpending.reject(error);\n\t\t\treturn;\n\t\t}\n\t\tif (refused === undefined) pending.resolve(answer);\n\t\telse\n\t\t\tpending.reject(\n\t\t\t\ttypeof refused === 'string' ? new Error(refused) : refused,\n\t\t\t);\n\t});\n\n\tuseEffect(\n\t\t() => () => {\n\t\t\tconst pending = waiting.current;\n\t\t\twaiting.current = null;\n\t\t\tpending?.reject(new FetcherPromiseAborted('unmounted'));\n\t\t},\n\t\t[],\n\t);\n\n\treturn useCallback((submit) => {\n\t\twaiting.current?.reject(new FetcherPromiseAborted('superseded'));\n\t\treturn new Promise<Data>((resolve, reject) => {\n\t\t\tconst mine: Waiting<Data> = { resolve, reject };\n\t\t\twaiting.current = mine;\n\t\t\tconst fail = (error: unknown) => {\n\t\t\t\tif (waiting.current === mine) waiting.current = null;\n\t\t\t\treject(error);\n\t\t\t};\n\t\t\ttry {\n\t\t\t\tPromise.resolve(submit()).catch(fail);\n\t\t\t} catch (error) {\n\t\t\t\tfail(error);\n\t\t\t}\n\t\t});\n\t}, []);\n}\n",
7
8
  "import { useMemo } from 'react';\nimport type { FormErrors } from '../errors/form-errors';\n\n/**\n * What a form's fetcher answers: a done step (`ok: true`), or a refusal\n * (`formRefusal`, or `fetcherResult` with its `error`) that may carry the\n * form's field errors.\n */\nexport type FormRefusalResult =\n\t| { ok: true }\n\t| { ok?: false; fieldErrors?: Record<string, string>; error?: string };\n\n/**\n * The refusal of a form's last answer as its `FormErrors`: `undefined` for\n * no answer, a done step, or a refusal with no `fieldErrors` (a toast's\n * message, not the form's). Stable for one answer.\n */\nexport function useFormRefusal(\n\tresult: FormRefusalResult | undefined,\n): FormErrors | undefined {\n\treturn useMemo(\n\t\t() =>\n\t\t\tresult && !result.ok && result.fieldErrors\n\t\t\t\t? {\n\t\t\t\t\t\tfieldErrors: result.fieldErrors,\n\t\t\t\t\t\tformError: result.error || undefined,\n\t\t\t\t\t}\n\t\t\t\t: undefined,\n\t\t[result],\n\t);\n}\n"
8
9
  ],
9
- "mappings": ";AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQA;AA2DO,SAAS,mBAAyB,CACxC,OACA,IACA,UAAyC,CAAC,GACjB;AAAA,EACzB,QAAQ,UAAU,KAAK,YAAY,GAAG,UAAU,MAAM,KAAK,SAAS;AAAA,EACpE,MAAM,UAAU,OAAO,IAAI;AAAA,EAC3B,QAAQ,UAAU;AAAA,EAClB,MAAM,UAAU,WAAiB,QAAQ,YAAY,YAAY,EAAE,IAAI,CAAC;AAAA,EACxE,MAAM,OAAO,OAAO,QAAQ,IAAI;AAAA,EAChC,KAAK,UAAU,QAAQ;AAAA,EACvB,MAAM,OAAO,MAAM,KAAK;AAAA,EACxB,MAAM,MACL,WAAW,KAAK,UAAU,YACvB,OAAO,OAAO,aACb,GAAG,IAAI,IACP,KACD;AAAA,EAEJ,OAAO,QAAQ,aAAa,SAAwB,IAAI;AAAA,EACxD,MAAM,WAAW,OAA0B,SAAS;AAAA,EAQpD,MAAM,MAAM,OAAO,CAAC;AAAA,EACpB,OAAO,SAAS,cAAc,SAAS,CAAC;AAAA,EACxC,MAAM,UAAU,OAAO,CAAC;AAAA,EACxB,gBAAgB,MAAM;AAAA,IACrB,IAAI,QAAQ,UAAU;AAAA,MAAQ,QAAQ,UAAU,IAAI;AAAA,KAClD,CAAC,QAAQ,KAAK,CAAC;AAAA,EAClB,MAAM,MAAM,CAAC,WAAmB;AAAA,IAC/B,MAAM,OAAO,EAAE,IAAI;AAAA,IACnB,UAAU,MAAM;AAAA,IACX,QAAQ,QAAQ,KAAK,QAAQ,MAAM,CAAC,EAAE,KAAK,MAAM;AAAA,MACrD,IAAI,SAAS,IAAI;AAAA,QAAS,gBAAgB,MAAM,WAAW,IAAI,CAAC;AAAA,KAChE;AAAA;AAAA,EAEF,MAAM,SAAS,OAAO,GAAG;AAAA,EACzB,OAAO,UAAU;AAAA,EAEjB,UAAU,MAAM;AAAA,IACf,IAAI,QAAQ,MAAM;AAAA,MACjB,SAAS,UAAU;AAAA,MACnB,UAAU,IAAI;AAAA,MACd;AAAA,IACD;AAAA,IACA,MAAM,QAAQ,WAAW,MAAM;AAAA,MAC9B,OAAO,QAAQ,GAAG;AAAA,OAChB,OAAO;AAAA,IACV,OAAO,MAAM,aAAa,KAAK;AAAA,KAC7B,CAAC,KAAK,MAAM,OAAO,CAAC;AAAA,EAEvB,MAAM,SACL,QAAQ,UAAU,WACjB,QAAQ,YAAY,IAAI,WAAW,YAAY,IAAI;AAAA,EACrD,MAAM,UACL,QAAQ,QAAQ,WAAW,OAAO,UAAU,QAAQ,SAAS;AAAA,EAC9D,MAAM,SAAS,UAAU,QAAQ,OAAO;AAAA,EACxC,UAAU,MAAM;AAAA,IACf,IAAI,WAAW,aAAa,WAAW;AAAA,MAAM;AAAA,IAC7C,IAAI,QAAQ,UAAU,MAAM,KAAK;AAAA,MAAM,SAAS,UAAU;AAAA,KACxD,CAAC,MAAM,CAAC;AAAA,EAEX,MAAM,OAAO,UAAU,SAAS,QAAQ,OAAO,YAAY,SAAS;AAAA,EACpE,IAAI;AAAA,EACJ,IAAI,QAAQ;AAAA,IAAM,QAAQ;AAAA,EAC1B,KAAK,IAAI,WAAW;AAAA,IAAK,QAAQ;AAAA,EACjC;AAAA,IAAK,QAAQ,SAAS,SAAS;AAAA,EAE/B,MAAM,SAAS,OAAO,GAAG;AAAA,EACzB,OAAO,UAAU;AAAA,EACjB,MAAM,QAAQ,YAAY,MAAM;AAAA,IAC/B,MAAM,MAAM,OAAO;AAAA,IACnB,IAAI,QAAQ;AAAA,MAAM;AAAA,IAClB,OAAO,QAAQ,GAAG;AAAA,KAChB,CAAC,CAAC;AAAA,EACL,OAAO,EAAE,MAAM,OAAO,UAAU,UAAU,SAAS,WAAW,OAAO,MAAM;AAAA;;ACnJ5E,sBAAS,sBAAW,qBAAQ;AAiBrB,SAAS,gBAAsB,CACrC,SACA,UACmB;AAAA,EACnB,MAAM,OAAO,QAAO,QAAQ,IAAI;AAAA,EAChC,MAAM,UAAU,QAAO,QAAQ;AAAA,EAC/B,QAAQ,UAAU;AAAA,EAClB,OAAO,QAAQ,aAAa,UAA2B,SAAS;AAAA,EAChE,MAAM,SAAS,QAAQ,UAAU,SAAS,QAAQ,OAAO;AAAA,EACzD,WAAU,MAAM;AAAA,IACf,IAAI,WAAW,aAAa,WAAW,QAAQ,WAAW,KAAK,SAAS;AAAA,MACvE;AAAA,IACD;AAAA,IACA,KAAK,UAAU;AAAA,IACf,UAAU,MAAM;AAAA,IAChB,QAAQ,UAAU,MAAM;AAAA,KACtB,CAAC,MAAM,CAAC;AAAA,EACX,OAAO;AAAA;;AClCR;AAiBO,SAAS,cAAc,CAC7B,QACyB;AAAA,EACzB,OAAO,QACN,MACC,UAAU,CAAC,OAAO,MAAM,OAAO,cAC5B;AAAA,IACA,aAAa,OAAO;AAAA,IACpB,WAAW,OAAO,SAAS;AAAA,EAC5B,IACC,WACJ,CAAC,MAAM,CACR;AAAA;",
10
- "debugId": "42D032646A4DF0F764756E2164756E21",
10
+ "mappings": ";AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQA;AA2DO,SAAS,mBAAyB,CACxC,OACA,IACA,UAAyC,CAAC,GACjB;AAAA,EACzB,QAAQ,UAAU,KAAK,YAAY,GAAG,UAAU,MAAM,KAAK,SAAS;AAAA,EACpE,MAAM,UAAU,OAAO,IAAI;AAAA,EAC3B,QAAQ,UAAU;AAAA,EAClB,MAAM,UAAU,WAAiB,QAAQ,YAAY,YAAY,EAAE,IAAI,CAAC;AAAA,EACxE,MAAM,OAAO,OAAO,QAAQ,IAAI;AAAA,EAChC,KAAK,UAAU,QAAQ;AAAA,EACvB,MAAM,OAAO,MAAM,KAAK;AAAA,EACxB,MAAM,MACL,WAAW,KAAK,UAAU,YACvB,OAAO,OAAO,aACb,GAAG,IAAI,IACP,KACD;AAAA,EAEJ,OAAO,QAAQ,aAAa,SAAwB,IAAI;AAAA,EACxD,MAAM,WAAW,OAA0B,SAAS;AAAA,EAQpD,MAAM,MAAM,OAAO,CAAC;AAAA,EACpB,OAAO,SAAS,cAAc,SAAS,CAAC;AAAA,EACxC,MAAM,UAAU,OAAO,CAAC;AAAA,EACxB,gBAAgB,MAAM;AAAA,IACrB,IAAI,QAAQ,UAAU;AAAA,MAAQ,QAAQ,UAAU,IAAI;AAAA,KAClD,CAAC,QAAQ,KAAK,CAAC;AAAA,EAClB,MAAM,MAAM,CAAC,WAAmB;AAAA,IAC/B,MAAM,OAAO,EAAE,IAAI;AAAA,IACnB,UAAU,MAAM;AAAA,IACX,QAAQ,QAAQ,KAAK,QAAQ,MAAM,CAAC,EAAE,KAAK,MAAM;AAAA,MACrD,IAAI,SAAS,IAAI;AAAA,QAAS,gBAAgB,MAAM,WAAW,IAAI,CAAC;AAAA,KAChE;AAAA;AAAA,EAEF,MAAM,SAAS,OAAO,GAAG;AAAA,EACzB,OAAO,UAAU;AAAA,EAEjB,UAAU,MAAM;AAAA,IACf,IAAI,QAAQ,MAAM;AAAA,MACjB,SAAS,UAAU;AAAA,MACnB,UAAU,IAAI;AAAA,MACd;AAAA,IACD;AAAA,IACA,MAAM,QAAQ,WAAW,MAAM;AAAA,MAC9B,OAAO,QAAQ,GAAG;AAAA,OAChB,OAAO;AAAA,IACV,OAAO,MAAM,aAAa,KAAK;AAAA,KAC7B,CAAC,KAAK,MAAM,OAAO,CAAC;AAAA,EAEvB,MAAM,SACL,QAAQ,UAAU,WACjB,QAAQ,YAAY,IAAI,WAAW,YAAY,IAAI;AAAA,EACrD,MAAM,UACL,QAAQ,QAAQ,WAAW,OAAO,UAAU,QAAQ,SAAS;AAAA,EAC9D,MAAM,SAAS,UAAU,QAAQ,OAAO;AAAA,EACxC,UAAU,MAAM;AAAA,IACf,IAAI,WAAW,aAAa,WAAW;AAAA,MAAM;AAAA,IAC7C,IAAI,QAAQ,UAAU,MAAM,KAAK;AAAA,MAAM,SAAS,UAAU;AAAA,KACxD,CAAC,MAAM,CAAC;AAAA,EAEX,MAAM,OAAO,UAAU,SAAS,QAAQ,OAAO,YAAY,SAAS;AAAA,EACpE,IAAI;AAAA,EACJ,IAAI,QAAQ;AAAA,IAAM,QAAQ;AAAA,EAC1B,KAAK,IAAI,WAAW;AAAA,IAAK,QAAQ;AAAA,EACjC;AAAA,IAAK,QAAQ,SAAS,SAAS;AAAA,EAE/B,MAAM,SAAS,OAAO,GAAG;AAAA,EACzB,OAAO,UAAU;AAAA,EACjB,MAAM,QAAQ,YAAY,MAAM;AAAA,IAC/B,MAAM,MAAM,OAAO;AAAA,IACnB,IAAI,QAAQ;AAAA,MAAM;AAAA,IAClB,OAAO,QAAQ,GAAG;AAAA,KAChB,CAAC,CAAC;AAAA,EACL,OAAO,EAAE,MAAM,OAAO,UAAU,UAAU,SAAS,WAAW,OAAO,MAAM;AAAA;;ACnJ5E,sBAAS,sBAAW,qBAAQ;AAiBrB,SAAS,gBAAsB,CACrC,SACA,UACmB;AAAA,EACnB,MAAM,OAAO,QAAO,QAAQ,IAAI;AAAA,EAChC,MAAM,UAAU,QAAO,QAAQ;AAAA,EAC/B,QAAQ,UAAU;AAAA,EAClB,OAAO,QAAQ,aAAa,UAA2B,SAAS;AAAA,EAChE,MAAM,SAAS,QAAQ,UAAU,SAAS,QAAQ,OAAO;AAAA,EACzD,WAAU,MAAM;AAAA,IACf,IAAI,WAAW,aAAa,WAAW,QAAQ,WAAW,KAAK,SAAS;AAAA,MACvE;AAAA,IACD;AAAA,IACA,KAAK,UAAU;AAAA,IACf,UAAU,MAAM;AAAA,IAChB,QAAQ,UAAU,MAAM;AAAA,KACtB,CAAC,MAAM,CAAC;AAAA,EACX,OAAO;AAAA;;AClCR,wBAAS,2BAAa,sBAAW;AAe1B,MAAM,8BAA8B,MAAM;AAAA,EAC9B,OAAO;AAAA,EAChB;AAAA,EACT,WAAW,CAAC,QAAoC;AAAA,IAC/C,MACC,WAAW,eACR,kFACA,wEACJ;AAAA,IACA,KAAK,SAAS;AAAA;AAEhB;AAyBO,SAAS,iBAAuB,CACtC,SACA,UAAuC,CAAC,GACmB;AAAA,EAC3D,MAAM,UAAU,QAA6B,IAAI;AAAA,EACjD,MAAM,UAAU,QAAO,QAAQ,OAAO;AAAA,EACtC,QAAQ,UAAU,QAAQ;AAAA,EAE1B,iBAAiB,SAAS,CAAC,WAAW;AAAA,IACrC,MAAM,UAAU,QAAQ;AAAA,IACxB,IAAI,CAAC;AAAA,MAAS;AAAA,IACd,QAAQ,UAAU;AAAA,IAClB,IAAI;AAAA,IACJ,IAAI;AAAA,MACH,UAAU,QAAQ,UAAU,MAAM;AAAA,MACjC,OAAO,OAAO;AAAA,MACf,QAAQ,OAAO,KAAK;AAAA,MACpB;AAAA;AAAA,IAED,IAAI,YAAY;AAAA,MAAW,QAAQ,QAAQ,MAAM;AAAA,IACjD;AAAA,MACC,QAAQ,OACP,OAAO,YAAY,WAAW,IAAI,MAAM,OAAO,IAAI,OACpD;AAAA,GACD;AAAA,EAED,WACC,MAAM,MAAM;AAAA,IACX,MAAM,UAAU,QAAQ;AAAA,IACxB,QAAQ,UAAU;AAAA,IAClB,SAAS,OAAO,IAAI,sBAAsB,WAAW,CAAC;AAAA,KAEvD,CAAC,CACF;AAAA,EAEA,OAAO,aAAY,CAAC,WAAW;AAAA,IAC9B,QAAQ,SAAS,OAAO,IAAI,sBAAsB,YAAY,CAAC;AAAA,IAC/D,OAAO,IAAI,QAAc,CAAC,SAAS,WAAW;AAAA,MAC7C,MAAM,OAAsB,EAAE,SAAS,OAAO;AAAA,MAC9C,QAAQ,UAAU;AAAA,MAClB,MAAM,OAAO,CAAC,UAAmB;AAAA,QAChC,IAAI,QAAQ,YAAY;AAAA,UAAM,QAAQ,UAAU;AAAA,QAChD,OAAO,KAAK;AAAA;AAAA,MAEb,IAAI;AAAA,QACH,QAAQ,QAAQ,OAAO,CAAC,EAAE,MAAM,IAAI;AAAA,QACnC,OAAO,OAAO;AAAA,QACf,KAAK,KAAK;AAAA;AAAA,KAEX;AAAA,KACC,CAAC,CAAC;AAAA;;ACrGN;AAiBO,SAAS,cAAc,CAC7B,QACyB;AAAA,EACzB,OAAO,QACN,MACC,UAAU,CAAC,OAAO,MAAM,OAAO,cAC5B;AAAA,IACA,aAAa,OAAO;AAAA,IACpB,WAAW,OAAO,SAAS;AAAA,EAC5B,IACC,WACJ,CAAC,MAAM,CACR;AAAA;",
11
+ "debugId": "6FFF1F15C0AB3E6F64756E2164756E21",
11
12
  "names": []
12
13
  }
@@ -0,0 +1,37 @@
1
+ import { type AnswerSource } from './use-fetcher-answer';
2
+ /** What `useFetcherPromise` accepts. */
3
+ export interface FetcherPromiseOptions<Data> {
4
+ /**
5
+ * Decides whether an answer is a refusal: the message (or the `Error`) the
6
+ * promise rejects with, `undefined` to resolve with the answer. Read from a
7
+ * ref, so a new function each render is fine. Without it every answer
8
+ * resolves.
9
+ */
10
+ refusal?: (answer: Data) => string | Error | undefined;
11
+ }
12
+ /** The rejection of a call replaced by a newer one, or cut off by an unmount. */
13
+ export declare class FetcherPromiseAborted extends Error {
14
+ readonly name = "FetcherPromiseAborted";
15
+ readonly reason: 'superseded' | 'unmounted';
16
+ constructor(reason: 'superseded' | 'unmounted');
17
+ }
18
+ /**
19
+ * Ties one submission to its answer. `run(submit)` calls `submit` (usually
20
+ * `() => fetcher.submit(...)`) and returns a Promise settled by the fetcher's
21
+ * next new answer (see `useFetcherAnswer`): it resolves with the answer, or
22
+ * rejects with an `Error` when `options.refusal` returns a message for it. An
23
+ * answer the fetcher already held when `run` was called never settles it.
24
+ *
25
+ * A second `run` while one is pending rejects the older promise with a
26
+ * `FetcherPromiseAborted` (`'superseded'`) and waits for the answer to the
27
+ * newer one. An unmount rejects the pending promise with a
28
+ * `FetcherPromiseAborted` (`'unmounted'`). A `submit` that throws (or whose
29
+ * promise rejects) rejects the promise with that error, and so does a
30
+ * `refusal` that throws. `run` is stable.
31
+ *
32
+ * Catch every promise `run` returns: an abort rejects it, and one nobody
33
+ * handles is an unhandled rejection. Ignore the aborts with
34
+ * `.catch((e) => { if (!(e instanceof FetcherPromiseAborted)) throw e; })`.
35
+ */
36
+ export declare function useFetcherPromise<Data>(fetcher: AnswerSource<Data>, options?: FetcherPromiseOptions<Data>): (submit: () => void | Promise<unknown>) => Promise<Data>;
37
+ //# sourceMappingURL=use-fetcher-promise.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-fetcher-promise.d.ts","sourceRoot":"","sources":["../../src/fetcher/use-fetcher-promise.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,YAAY,EAAoB,MAAM,sBAAsB,CAAC;AAE3E,wCAAwC;AACxC,MAAM,WAAW,qBAAqB,CAAC,IAAI;IAC1C;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,IAAI,KAAK,MAAM,GAAG,KAAK,GAAG,SAAS,CAAC;CACvD;AAED,iFAAiF;AACjF,qBAAa,qBAAsB,SAAQ,KAAK;IAC/C,SAAkB,IAAI,2BAA2B;IACjD,QAAQ,CAAC,MAAM,EAAE,YAAY,GAAG,WAAW,CAAC;gBAChC,MAAM,EAAE,YAAY,GAAG,WAAW;CAQ9C;AAOD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EACrC,OAAO,EAAE,YAAY,CAAC,IAAI,CAAC,EAC3B,OAAO,GAAE,qBAAqB,CAAC,IAAI,CAAM,GACvC,CAAC,MAAM,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAgD1D"}
package/docs/README.md CHANGED
@@ -21,7 +21,7 @@ installable and usable offline, wording an API's refusals, the BFF session, a Gr
21
21
  | [An API's refusals](guide/errors.md) | wording a GraphQL API's refusals for the pages: recognising an `ApiError` or a REST API's `problemError`, the request id, the page a request is for, the catalogue store, the form checks in the API's words, the provider, and the loaders, the error page and the actions that answer with it, the JSON actions with their CSRF check and fetcher hook, calling the API from a loader (`apiCallFailure`), and keeping the catalogue fresh (`@alxia/react-router/errors`) |
22
22
  | [A BFF session](guide/session.md) | redirecting after sign-in to a page of the site and nowhere else, the sign-in pages' paths, the session's cookies, the sign-in flow (where an answer lands, a refused code, the viewer, sign-out; `@alxia/react-router/session`), the pages a mail's link opens (a magic link, a password reset: `authLinks`), the page's hooks (sign-out, a mail link's parameter, a one-code field; `@alxia/react-router/session/react`), the step-up route's signed-out handling (`onSignedOut`), and the step-up's state and the props it gives the UI (`@alxia/react-router/session/model`) |
23
23
  | [Links through React Router](guide/link.md) | `RouterLink`, React Router's `Link` as the link component of a UI library that hands `href` and spreads anchor props; `@alxia/react-router/link` |
24
- | [A fetcher's fresh answer](guide/fetcher.md) | `useFetcherAnswer`, a handler called once per new answer of a keyed fetcher, not for the data it held at mount; `useDebouncedFetcher`, a resource route loaded for what is typed (pause, minimum length, stale answer kept, retry); `@alxia/react-router/fetcher` |
24
+ | [A fetcher's fresh answer](guide/fetcher.md) | `useFetcherAnswer`, `useFetcherPromise` (one submission's answer as a Promise), a handler called once per new answer of a keyed fetcher, not for the data it held at mount; `useDebouncedFetcher`, a resource route loaded for what is typed (pause, minimum length, stale answer kept, retry); `@alxia/react-router/fetcher` |
25
25
  | [Lists whose state is the URL](guide/lists.md) | a search and filters in the address bar that drop the cursor (`useSearchChange`), a skeleton while the same page reloads (`useSameRouteLoading`), and a load-more button that appends pages by cursor and keeps the rows when one fails (`usePagedRows`); `@alxia/react-router/lists` |
26
26
  | [Subscriptions for a BFF](guide/subscriptions.md) | showing a GraphQL subscription in the browser without giving it the API's token or a way to write a document: a resource route with a map of operation names to the server's documents, the same-site check, the bearer from the session, extra upstream headers, a heartbeat, abort propagation, size limits and a 429 passed through with `retryAfter` (`@alxia/react-router/subscription`) |
27
27
  | [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or a page streams, hydrates or reads its context unlike you expected |
@@ -41,6 +41,50 @@ export function SaveForm() {
41
41
  hands a new object each time. An answer that comes back as the object seen
42
42
  before (A, B, then A again) is a new answer once B was seen.
43
43
 
44
+ ## One submission's answer as a Promise: `useFetcherPromise(fetcher, options?)`
45
+
46
+ A confirm dialog whose `onConfirm` returns a Promise stays open, and shows the
47
+ error, while the Promise rejects. `useFetcherPromise` ties a submission to the
48
+ fetcher's next new answer, on top of `useFetcherAnswer`, so the app keeps no
49
+ ref of resolve and reject.
50
+
51
+ ```tsx
52
+ import { useJsonFetcher } from '@alxia/react-router/errors';
53
+ import { useFetcherPromise } from '@alxia/react-router/fetcher';
54
+
55
+ const { fetcher, post } = useJsonFetcher<typeof action>('delete');
56
+ const run = useFetcherPromise(fetcher, {
57
+ refusal: (result) => (result.ok ? undefined : result.error),
58
+ });
59
+ // a confirm dialog that stays open, and shows the error, while this rejects
60
+ const onConfirm = () => run(() => post({ intent: 'delete', id }));
61
+ ```
62
+
63
+ - `run(submit)` calls `submit` and returns a Promise. It resolves with the
64
+ next new answer, or rejects when `options.refusal(answer)` returns a
65
+ message (an `Error` with that message) or an `Error` (thrown as it is).
66
+ Without `refusal` every answer resolves. `run` is the same function on every
67
+ render.
68
+ - An answer the fetcher already held when `run` was called never settles the
69
+ Promise, nor does the data present at mount; only an answer that arrives
70
+ after the call, once the fetcher is idle, does.
71
+ - **A second `run` while one is pending replaces it**: the older Promise
72
+ rejects with a `FetcherPromiseAborted` (`reason: 'superseded'`) and the newer
73
+ one gets the answer. The fetcher itself still sends both submissions (React
74
+ Router answers the last), so disable the button while the dialog works if
75
+ the first must not be sent.
76
+ - **An unmount rejects the pending Promise** with a `FetcherPromiseAborted`
77
+ (`reason: 'unmounted'`), so nothing waits forever; the caller (the dialog)
78
+ is usually gone and ignores it. Check `error instanceof FetcherPromiseAborted`
79
+ to tell it from a refusal.
80
+ - Catch every Promise `run` returns: an abort rejects it, and one nobody
81
+ handles is an unhandled rejection. Where the caller does not await it:
82
+ `run(...).catch((e) => { if (!(e instanceof FetcherPromiseAborted)) throw e; })`.
83
+ The example assumes `fetcherResult`'s shape, `{ ok: false, error: string }`.
84
+ - A `refusal` that throws rejects the Promise with what it threw.
85
+ - A `submit` that throws, or returns a Promise that rejects, rejects the
86
+ Promise with that error.
87
+
44
88
  ## Typeahead: `useDebouncedFetcher(value, to, options?)`
45
89
 
46
90
  A search box, a suggestion list or a preview loads a resource route for what
package/docs/roadmap.md CHANGED
@@ -88,6 +88,11 @@ Nothing planned.
88
88
 
89
89
  ### Unreleased
90
90
 
91
+ - **`useFetcherPromise` on `/fetcher`.** `run(submit)` returns a Promise
92
+ settled by the fetcher's next new answer, rejected with an `Error` when your
93
+ `refusal(answer)` returns a message. See [One submission's answer as a
94
+ Promise](guide/fetcher.md#one-submissions-answer-as-a-promise-usefetcherpromisefetcher-options).
95
+
91
96
  - **A health probe in `createServer`.** `health: '/health'` or `{ path,
92
97
  check, timeout }` declares `@alxia/core`'s liveness probe, in text, before
93
98
  every middleware. See [A health
@@ -631,6 +631,25 @@ export async function loader({ request }: Route.LoaderArgs) {
631
631
 
632
632
  The rows stay, `error` is `true`, and `loadMore` retries.
633
633
 
634
+ ### `useFetcherPromise: a newer call replaced this one before the fetcher answered` (or `… the component unmounted before the fetcher answered`)
635
+
636
+ **When:** the Promise `run(submit)` returned rejects with a `FetcherPromiseAborted` instead of the action's answer.
637
+
638
+ **Why:** `'superseded'`: `run` was called again while the first call was still waiting (a double click), so only the newer call gets the answer. `'unmounted'`: the component left before the fetcher answered.
639
+
640
+ **Fix:** disable the control while the Promise is pending, and tell the two cases from a refusal by the class:
641
+
642
+ ```ts
643
+ import { FetcherPromiseAborted } from '@alxia/react-router/fetcher';
644
+
645
+ try {
646
+ await run(() => post({ intent: 'delete', id }));
647
+ } catch (error) {
648
+ if (error instanceof FetcherPromiseAborted) return;
649
+ setError((error as Error).message); // the refusal's message
650
+ }
651
+ ```
652
+
634
653
  ### A debounced load lands on the route's `ErrorBoundary`
635
654
 
636
655
  **When:** the resource route behind `useDebouncedFetcher` throws (or throws a `Response`) and the whole page is replaced by its `ErrorBoundary` instead of showing the previous suggestions with a retry.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alxia/react-router",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "alxia as the server of a React Router framework app: server rendering behind the app's hooks, loaders reading its typed context, the client's assets served; the code with no UI an installable app shares (`/pwa`), its hooks (`/pwa/react`), `noCache` for the worker's script (`/pwa/server`), the service worker on Workbox (`/pwa/worker`) and its build (`/pwa/build`), an API's refusals worded for the page (`/errors`), the BFF session: cookies, sign-in, step-up and mail links (`/session`), its page hooks (`/session/react`) and what it shares with the page (`/session/model`), a GraphQL subscription relayed to the browser over SSE (`/subscription`), the page's CSP nonce and inline boot scripts (`/document`) and a streaming `entry.server` (`/entry`)",
5
5
  "license": "MIT",
6
6
  "type": "module",