@alxia/react-router 0.31.1 → 0.32.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 +19 -4
- package/dist/fetcher/index.d.ts +3 -2
- package/dist/fetcher/index.d.ts.map +1 -1
- package/dist/fetcher/index.js +60 -6
- package/dist/fetcher/index.js.map +4 -3
- package/dist/fetcher/use-debounced-fetcher.d.ts +57 -0
- package/dist/fetcher/use-debounced-fetcher.d.ts.map +1 -0
- package/docs/README.md +1 -1
- package/docs/guide/fetcher.md +61 -3
- package/docs/troubleshooting.md +19 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1057,8 +1057,8 @@ import { RouterLink } from '@alxia/react-router/link';
|
|
|
1057
1057
|
## A fetcher's fresh answer
|
|
1058
1058
|
|
|
1059
1059
|
A keyed fetcher outlives its page, so a `useEffect` on its data replays an
|
|
1060
|
-
earlier visit's answer on remount. `@alxia/react-router/fetcher` (`react`
|
|
1061
|
-
its
|
|
1060
|
+
earlier visit's answer on remount. `@alxia/react-router/fetcher` (`react` and
|
|
1061
|
+
`react-router` are its imports) calls your handler once per new answer:
|
|
1062
1062
|
|
|
1063
1063
|
```tsx
|
|
1064
1064
|
import { useJsonFetcher } from '@alxia/react-router/errors';
|
|
@@ -1068,6 +1068,20 @@ const { fetcher } = useJsonFetcher<typeof action>('save');
|
|
|
1068
1068
|
const answer = useFetcherAnswer(fetcher, (result) => toast(result.message));
|
|
1069
1069
|
```
|
|
1070
1070
|
|
|
1071
|
+
A resource route loaded for what is typed (a typeahead, a preview): one load
|
|
1072
|
+
after a pause, from a minimum length, the previous answer kept (`stale`) until
|
|
1073
|
+
the new one lands, and a retry:
|
|
1074
|
+
|
|
1075
|
+
```tsx
|
|
1076
|
+
import { useDebouncedFetcher } from '@alxia/react-router/fetcher';
|
|
1077
|
+
|
|
1078
|
+
const { data, stale, state, retry } = useDebouncedFetcher<Found>(
|
|
1079
|
+
typed,
|
|
1080
|
+
(q) => `/suggest?${new URLSearchParams({ q })}`,
|
|
1081
|
+
{ minLength: 2, delayMs: 250 },
|
|
1082
|
+
);
|
|
1083
|
+
```
|
|
1084
|
+
|
|
1071
1085
|
See the [fetcher guide](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/fetcher.md).
|
|
1072
1086
|
|
|
1073
1087
|
## A list whose state is the URL
|
|
@@ -1359,11 +1373,12 @@ From `@alxia/react-router/link` (`react` and `react-router` are its only imports
|
|
|
1359
1373
|
| --- | --- |
|
|
1360
1374
|
| `RouterLink`, `RouterLinkProps` | React Router's `Link` as a UI library's link component: takes `href` (or `to`), spreads `className`, `children`, `ref` and every anchor prop onto the `<a>`, keeps `Link`'s own props (`replace`, `prefetch`, `reloadDocument`, ...) |
|
|
1361
1375
|
|
|
1362
|
-
From `@alxia/react-router/fetcher` (`react`
|
|
1376
|
+
From `@alxia/react-router/fetcher` (`react` and `react-router` are its imports; it renders on the server):
|
|
1363
1377
|
|
|
1364
1378
|
| export | |
|
|
1365
1379
|
| --- | --- |
|
|
1366
1380
|
| `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 |
|
|
1381
|
+
| `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 |
|
|
1367
1382
|
|
|
1368
1383
|
From `@alxia/react-router/subscription` (no import, no peer):
|
|
1369
1384
|
|
|
@@ -1389,7 +1404,7 @@ The `alxia-react-router` bin, run with `bunx`:
|
|
|
1389
1404
|
- [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.
|
|
1390
1405
|
- [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.
|
|
1391
1406
|
- [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.
|
|
1392
|
-
- [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.
|
|
1407
|
+
- [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.
|
|
1393
1408
|
- [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.
|
|
1394
1409
|
- [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.
|
|
1395
1410
|
- [Troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md): each message, and the traps that print none.
|
package/dist/fetcher/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `@alxia/react-router/fetcher`: the hooks around a React Router fetcher's
|
|
3
|
-
* answers. `react`
|
|
4
|
-
*
|
|
3
|
+
* answers. `react` and `react-router` are its imports, and every hook renders
|
|
4
|
+
* on the server (a load starts in the browser only).
|
|
5
5
|
*/
|
|
6
|
+
export { type DebouncedFetcher, type DebouncedFetcherOptions, type DebouncedFetcherState, useDebouncedFetcher, } from './use-debounced-fetcher';
|
|
6
7
|
export { type AnswerSource, useFetcherAnswer } from './use-fetcher-answer';
|
|
7
8
|
//# 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,EAAE,KAAK,YAAY,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,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"}
|
package/dist/fetcher/index.js
CHANGED
|
@@ -1,12 +1,65 @@
|
|
|
1
|
+
// src/fetcher/use-debounced-fetcher.ts
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useFetcher } from "react-router";
|
|
4
|
+
function useDebouncedFetcher(value, to, options = {}) {
|
|
5
|
+
const { delayMs = 250, minLength = 1, enabled = true, key, keep } = options;
|
|
6
|
+
const keepRef = useRef(keep);
|
|
7
|
+
keepRef.current = keep;
|
|
8
|
+
const fetcher = useFetcher(key === undefined ? undefined : { key });
|
|
9
|
+
const load = useRef(fetcher.load);
|
|
10
|
+
load.current = fetcher.load;
|
|
11
|
+
const text = value.trim();
|
|
12
|
+
const url = enabled && text.length >= minLength ? typeof to === "function" ? to(text) : to : null;
|
|
13
|
+
const [loaded, setLoaded] = useState(null);
|
|
14
|
+
const previous = useRef(undefined);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (url === null) {
|
|
17
|
+
previous.current = undefined;
|
|
18
|
+
setLoaded(null);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const timer = setTimeout(() => {
|
|
22
|
+
setLoaded(url);
|
|
23
|
+
load.current(url);
|
|
24
|
+
}, delayMs);
|
|
25
|
+
return () => clearTimeout(timer);
|
|
26
|
+
}, [url, text, delayMs]);
|
|
27
|
+
const current = url !== null && loaded === url && fetcher.state === "idle" && fetcher.data !== undefined;
|
|
28
|
+
const answer = current ? fetcher.data : undefined;
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (answer === undefined || answer === null)
|
|
31
|
+
return;
|
|
32
|
+
if (keepRef.current?.(answer) ?? true)
|
|
33
|
+
previous.current = answer;
|
|
34
|
+
}, [answer]);
|
|
35
|
+
const data = current ? answer : url === null ? undefined : previous.current;
|
|
36
|
+
let state;
|
|
37
|
+
if (url === null)
|
|
38
|
+
state = "idle";
|
|
39
|
+
else if (loaded !== url)
|
|
40
|
+
state = "waiting";
|
|
41
|
+
else
|
|
42
|
+
state = fetcher.state === "idle" ? "done" : "loading";
|
|
43
|
+
const urlRef = useRef(url);
|
|
44
|
+
urlRef.current = url;
|
|
45
|
+
const retry = useCallback(() => {
|
|
46
|
+
const now = urlRef.current;
|
|
47
|
+
if (now === null)
|
|
48
|
+
return;
|
|
49
|
+
setLoaded(now);
|
|
50
|
+
load.current(now);
|
|
51
|
+
}, []);
|
|
52
|
+
return { data, stale: state !== "done" && data !== undefined, state, retry };
|
|
53
|
+
}
|
|
1
54
|
// src/fetcher/use-fetcher-answer.ts
|
|
2
|
-
import { useEffect, useRef, useState } from "react";
|
|
55
|
+
import { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
|
|
3
56
|
function useFetcherAnswer(fetcher, onAnswer) {
|
|
4
|
-
const seen =
|
|
5
|
-
const handler =
|
|
57
|
+
const seen = useRef2(fetcher.data);
|
|
58
|
+
const handler = useRef2(onAnswer);
|
|
6
59
|
handler.current = onAnswer;
|
|
7
|
-
const [latest, setLatest] =
|
|
60
|
+
const [latest, setLatest] = useState2(undefined);
|
|
8
61
|
const answer = fetcher.state === "idle" ? fetcher.data : undefined;
|
|
9
|
-
|
|
62
|
+
useEffect2(() => {
|
|
10
63
|
if (answer === undefined || answer === null || answer === seen.current) {
|
|
11
64
|
return;
|
|
12
65
|
}
|
|
@@ -17,8 +70,9 @@ function useFetcherAnswer(fetcher, onAnswer) {
|
|
|
17
70
|
return latest;
|
|
18
71
|
}
|
|
19
72
|
export {
|
|
73
|
+
useDebouncedFetcher,
|
|
20
74
|
useFetcherAnswer
|
|
21
75
|
};
|
|
22
76
|
|
|
23
|
-
//# debugId=
|
|
77
|
+
//# debugId=FE85A7EF01A4F72B64756E2164756E21
|
|
24
78
|
//# sourceMappingURL=index.js.map
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../src/fetcher/use-fetcher-answer.ts"],
|
|
3
|
+
"sources": ["../src/fetcher/use-debounced-fetcher.ts", "../src/fetcher/use-fetcher-answer.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
+
"import { useCallback, useEffect, useRef, useState } 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\n\t// biome-ignore lint/correctness/useExhaustiveDependencies: the URL and the value are the trigger; `load` is read from a ref\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\tsetLoaded(url);\n\t\t\tvoid load.current(url);\n\t\t}, delayMs);\n\t\treturn () => clearTimeout(timer);\n\t}, [url, text, delayMs]);\n\n\tconst current =\n\t\turl !== null &&\n\t\tloaded === url &&\n\t\tfetcher.state === 'idle' &&\n\t\tfetcher.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 = fetcher.state === 'idle' ? '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\tsetLoaded(now);\n\t\tvoid load.current(now);\n\t}, []);\n\treturn { data, stale: state !== 'done' && data !== undefined, state, retry };\n}\n",
|
|
5
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"
|
|
6
7
|
],
|
|
7
|
-
"mappings": ";AAAA;
|
|
8
|
-
"debugId": "
|
|
8
|
+
"mappings": ";AAAA;AACA;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,EAGpD,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,UAAU,GAAG;AAAA,MACR,KAAK,QAAQ,GAAG;AAAA,OACnB,OAAO;AAAA,IACV,OAAO,MAAM,aAAa,KAAK;AAAA,KAC7B,CAAC,KAAK,MAAM,OAAO,CAAC;AAAA,EAEvB,MAAM,UACL,QAAQ,QACR,WAAW,OACX,QAAQ,UAAU,UAClB,QAAQ,SAAS;AAAA,EAClB,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,QAAQ,UAAU,SAAS,SAAS;AAAA,EAEjD,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,UAAU,GAAG;AAAA,IACR,KAAK,QAAQ,GAAG;AAAA,KACnB,CAAC,CAAC;AAAA,EACL,OAAO,EAAE,MAAM,OAAO,UAAU,UAAU,SAAS,WAAW,OAAO,MAAM;AAAA;;ACzH5E,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;",
|
|
9
|
+
"debugId": "FE85A7EF01A4F72B64756E2164756E21",
|
|
9
10
|
"names": []
|
|
10
11
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useFetcher } from 'react-router';
|
|
2
|
+
/** What `useFetcher<Data>` hands back as `data` (`Data` may be `typeof loader`). */
|
|
3
|
+
type FetcherData<Data> = ReturnType<typeof useFetcher<Data>>['data'];
|
|
4
|
+
/** What `useDebouncedFetcher` is doing for the value typed. */
|
|
5
|
+
export type DebouncedFetcherState =
|
|
6
|
+
/** Nothing to ask: disabled, or the value is shorter than `minLength`. */
|
|
7
|
+
'idle'
|
|
8
|
+
/** The value changed and the pause after the last change is running. */
|
|
9
|
+
| 'waiting'
|
|
10
|
+
/** The resource route is loading. */
|
|
11
|
+
| 'loading'
|
|
12
|
+
/** The route answered for the value as it stands. */
|
|
13
|
+
| 'done';
|
|
14
|
+
export interface DebouncedFetcherOptions<Data = unknown> {
|
|
15
|
+
/** Pause after the last change before the route is asked. Default 250. */
|
|
16
|
+
readonly delayMs?: number;
|
|
17
|
+
/** The trimmed value must have this many characters. Default 1. */
|
|
18
|
+
readonly minLength?: number;
|
|
19
|
+
/** `false` asks nothing and forgets the previous answer. Default `true`. */
|
|
20
|
+
readonly enabled?: boolean;
|
|
21
|
+
/** The fetcher's key, to share it with another component. */
|
|
22
|
+
readonly key?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Which answers may be kept as the stale `data` while the next value waits
|
|
25
|
+
* or loads. Default: all. An answer it refuses (a failure) is still `data`
|
|
26
|
+
* while it is the current one, but is never shown as the previous one: after a refused answer, the stale
|
|
27
|
+
* data is the last *kept* answer, not the refused one.
|
|
28
|
+
*/
|
|
29
|
+
readonly keep?: (data: NonNullable<FetcherData<Data>>) => boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface DebouncedFetcher<Data> {
|
|
32
|
+
/**
|
|
33
|
+
* The route's answer for the value as it stands, else (`stale`) the last
|
|
34
|
+
* answer for an earlier value, kept until the new one lands. `undefined`
|
|
35
|
+
* before any answer, and again once the hook goes `idle`.
|
|
36
|
+
*/
|
|
37
|
+
readonly data: FetcherData<Data>;
|
|
38
|
+
/** `data` answers an earlier value (or none is current yet). */
|
|
39
|
+
readonly stale: boolean;
|
|
40
|
+
readonly state: DebouncedFetcherState;
|
|
41
|
+
/** Asks the route again for the value as it stands, without waiting. */
|
|
42
|
+
readonly retry: () => void;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A resource route loaded through a fetcher for a value that changes as the
|
|
46
|
+
* user types. After `delayMs` without a change, and from `minLength`
|
|
47
|
+
* characters, it calls `fetcher.load` once (a newer load cancels the
|
|
48
|
+
* previous one). An answer only counts for the value it was asked for: while
|
|
49
|
+
* a newer value waits or loads, `data` is the previous answer with `stale`
|
|
50
|
+
* set, and an answer for an older value is never returned as current.
|
|
51
|
+
* `to` is the route's URL, or a function of the trimmed value that builds it
|
|
52
|
+
* (a string without a function is loaded as it is, the value being only the
|
|
53
|
+
* trigger). A refusal the route answers as data is `data` like any answer.
|
|
54
|
+
*/
|
|
55
|
+
export declare function useDebouncedFetcher<Data>(value: string, to: string | ((value: string) => string), options?: DebouncedFetcherOptions<Data>): DebouncedFetcher<Data>;
|
|
56
|
+
export {};
|
|
57
|
+
//# sourceMappingURL=use-debounced-fetcher.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-debounced-fetcher.d.ts","sourceRoot":"","sources":["../../src/fetcher/use-debounced-fetcher.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE1C,oFAAoF;AACpF,KAAK,WAAW,CAAC,IAAI,IAAI,UAAU,CAAC,OAAO,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;AAErE,+DAA+D;AAC/D,MAAM,MAAM,qBAAqB;AAChC,0EAA0E;AACxE,MAAM;AACR,wEAAwE;GACtE,SAAS;AACX,qCAAqC;GACnC,SAAS;AACX,qDAAqD;GACnD,MAAM,CAAC;AAEV,MAAM,WAAW,uBAAuB,CAAC,IAAI,GAAG,OAAO;IACtD,0EAA0E;IAC1E,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,mEAAmE;IACnE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,4EAA4E;IAC5E,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,6DAA6D;IAC7D,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,KAAK,OAAO,CAAC;CAClE;AAED,MAAM,WAAW,gBAAgB,CAAC,IAAI;IACrC;;;;OAIG;IACH,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC,IAAI,CAAC,CAAC;IACjC,gEAAgE;IAChE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,qBAAqB,CAAC;IACtC,wEAAwE;IACxE,QAAQ,CAAC,KAAK,EAAE,MAAM,IAAI,CAAC;CAC3B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EACvC,KAAK,EAAE,MAAM,EACb,EAAE,EAAE,MAAM,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC,EACxC,OAAO,GAAE,uBAAuB,CAAC,IAAI,CAAM,GACzC,gBAAgB,CAAC,IAAI,CAAC,CA0DxB"}
|
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`, 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; `@alxia/react-router/fetcher` |
|
|
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` |
|
|
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 |
|
package/docs/guide/fetcher.md
CHANGED
|
@@ -4,9 +4,11 @@ A page that posts JSON through a keyed fetcher (`useJsonFetcher('save')`) often
|
|
|
4
4
|
reacts to each answer once: a toast, a form error. A keyed fetcher outlives its
|
|
5
5
|
component, so its data from an earlier visit is still there when the component
|
|
6
6
|
mounts again, and a plain `useEffect` on `fetcher.data` reacts to it as if it
|
|
7
|
-
were new. `@alxia/react-router/fetcher` holds the hook that tells them apart
|
|
8
|
-
`
|
|
9
|
-
|
|
7
|
+
were new. `@alxia/react-router/fetcher` holds the hook that tells them apart
|
|
8
|
+
(`useFetcherAnswer`, which reads the fetcher structurally) and the one that
|
|
9
|
+
loads a resource route for what the user types (`useDebouncedFetcher`, below).
|
|
10
|
+
`react` is its only import, plus `react-router` for `useDebouncedFetcher`, and
|
|
11
|
+
both render on the server.
|
|
10
12
|
|
|
11
13
|
```tsx
|
|
12
14
|
import { useJsonFetcher } from '@alxia/react-router/errors';
|
|
@@ -38,3 +40,59 @@ export function SaveForm() {
|
|
|
38
40
|
two objects are two answers, and so is a reloading `fetcher.load`, which
|
|
39
41
|
hands a new object each time. An answer that comes back as the object seen
|
|
40
42
|
before (A, B, then A again) is a new answer once B was seen.
|
|
43
|
+
|
|
44
|
+
## Typeahead: `useDebouncedFetcher(value, to, options?)`
|
|
45
|
+
|
|
46
|
+
A search box, a suggestion list or a preview loads a resource route for what
|
|
47
|
+
the user types. The pattern is always the same: wait for a pause after the last
|
|
48
|
+
keystroke, ask from a minimum length, never act on an answer for older words,
|
|
49
|
+
keep the previous answer on screen while the next one loads, offer a retry.
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
import { useDebouncedFetcher } from '@alxia/react-router/fetcher';
|
|
53
|
+
|
|
54
|
+
type SuggestAnswer = { items: string[] } | { failed: true };
|
|
55
|
+
|
|
56
|
+
export function Suggestions({ typed }: { typed: string }) {
|
|
57
|
+
const { data, stale, state, retry } = useDebouncedFetcher<SuggestAnswer>(
|
|
58
|
+
typed,
|
|
59
|
+
(q) => `/suggest?${new URLSearchParams({ q })}`,
|
|
60
|
+
{ minLength: 2, delayMs: 250, keep: (answer) => !('failed' in answer) },
|
|
61
|
+
);
|
|
62
|
+
if (state === 'idle') return null;
|
|
63
|
+
return (
|
|
64
|
+
<div aria-busy={state !== 'done'} style={{ opacity: stale ? 0.5 : 1 }}>
|
|
65
|
+
{data && 'items' in data ? <List items={data.items} /> : <Spinner />}
|
|
66
|
+
{data && 'failed' in data ? <button onClick={retry}>Try again</button> : null}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- `value` is the text typed; it is trimmed, and nothing is asked below
|
|
73
|
+
`minLength` (default 1). `to` is the route's URL, or a function of the
|
|
74
|
+
trimmed value that builds it (a plain string is loaded as it is, the value
|
|
75
|
+
only triggering it).
|
|
76
|
+
- After `delayMs` (default 250) without a change, one `fetcher.load` is made;
|
|
77
|
+
a newer load cancels the previous one.
|
|
78
|
+
- `data` is the route's answer for the value as it stands. While a newer value
|
|
79
|
+
waits or loads it is the previous answer and `stale` is `true`; an answer
|
|
80
|
+
for an older value is never returned as current. It is `undefined` before
|
|
81
|
+
the first answer and again once the hook is `idle`.
|
|
82
|
+
- `state` is `'idle'` (disabled or too short), `'waiting'` (the pause is
|
|
83
|
+
running), `'loading'` or `'done'`.
|
|
84
|
+
- `retry()` asks again for the value as it stands, without the pause; it is
|
|
85
|
+
the same function on every render. The route answers `{ failed: true }` on a
|
|
86
|
+
failure (for example with `orFailed` from `/lists`) rather than throwing,
|
|
87
|
+
which is what gives the retry button something to show (see
|
|
88
|
+
[troubleshooting](../troubleshooting.md)).
|
|
89
|
+
- `keep(answer)` (default: keep all) decides which answers may stay as the
|
|
90
|
+
stale `data` while the next value waits or loads; refuse the failed ones so
|
|
91
|
+
a failure is shown once, as the current answer, never dimmed as a previous
|
|
92
|
+
one. After a refused answer, the stale data is the last *kept* answer, not
|
|
93
|
+
the refused one.
|
|
94
|
+
- A refusal the route answers as data (`{ failed: true }`) is `data` like any
|
|
95
|
+
answer. A route that throws goes to the route's error boundary, as with any
|
|
96
|
+
fetcher.
|
|
97
|
+
- `enabled: false` asks nothing and forgets the previous answer (a closed
|
|
98
|
+
dialog); `key` shares the fetcher with another component.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -584,6 +584,25 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|
|
584
584
|
|
|
585
585
|
The rows stay, `error` is `true`, and `loadMore` retries.
|
|
586
586
|
|
|
587
|
+
### A debounced load lands on the route's `ErrorBoundary`
|
|
588
|
+
|
|
589
|
+
**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.
|
|
590
|
+
|
|
591
|
+
**Why:** React Router sends the error of a fetcher's load to the route's boundary. `useDebouncedFetcher` only sees what the fetcher answers, so a failure it can show with `retry` has to be an answer.
|
|
592
|
+
|
|
593
|
+
**Fix:** answer `{ failed: true }` instead of throwing, for example with `orFailed(q, run)` from `@alxia/react-router/lists` (any non-empty value as its first argument makes it answer `{ failed: true }` on an error), and pass `keep` so a failure is never shown as the previous answer:
|
|
594
|
+
|
|
595
|
+
```ts
|
|
596
|
+
export function loader({ request }: Route.LoaderArgs) {
|
|
597
|
+
const q = new URL(request.url).searchParams.get('q');
|
|
598
|
+
return orFailed(q, () => suggest(q));
|
|
599
|
+
}
|
|
600
|
+
// in the component
|
|
601
|
+
const { data, retry } = useDebouncedFetcher<typeof loader>(typed, toUrl, {
|
|
602
|
+
keep: (answer) => !('failed' in answer),
|
|
603
|
+
});
|
|
604
|
+
```
|
|
605
|
+
|
|
587
606
|
### The client's files are served uncompressed
|
|
588
607
|
|
|
589
608
|
A response for a script, a stylesheet or an SVG has no `Content-Encoding`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/react-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.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",
|