@alxia/react-router 0.23.0 → 0.24.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 +15 -2
- package/dist/lists/index.d.ts +2 -1
- package/dist/lists/index.d.ts.map +1 -1
- package/dist/lists/index.js +30 -2
- package/dist/lists/index.js.map +5 -4
- package/dist/lists/or-failed.d.ts +19 -0
- package/dist/lists/or-failed.d.ts.map +1 -0
- package/dist/lists/use-search-change.d.ts +10 -5
- package/dist/lists/use-search-change.d.ts.map +1 -1
- package/docs/guide/lists.md +31 -2
- package/docs/troubleshooting.md +10 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1077,7 +1077,19 @@ export default function Users({ loaderData }: Route.ComponentProps) {
|
|
|
1077
1077
|
}
|
|
1078
1078
|
```
|
|
1079
1079
|
|
|
1080
|
-
The loader answers `?after=<cursor>` with the next page. A loader that throws on a cursor request lands on the route's `ErrorBoundary
|
|
1080
|
+
The loader answers `?after=<cursor>` with the next page. A loader that throws on a cursor request lands on the route's `ErrorBoundary`; wrap the read in `orFailed(after, run)` to keep the rows and offer a retry instead:
|
|
1081
|
+
|
|
1082
|
+
```ts
|
|
1083
|
+
import { orFailed } from '@alxia/react-router/lists';
|
|
1084
|
+
|
|
1085
|
+
export function loader({ request }: Route.LoaderArgs) {
|
|
1086
|
+
const url = new URL(request.url);
|
|
1087
|
+
// no ?after: a failure throws (ErrorBoundary); with ?after: it answers { failed: true }
|
|
1088
|
+
return orFailed(url.searchParams.get('after'), () => listUsers(url.searchParams));
|
|
1089
|
+
}
|
|
1090
|
+
```
|
|
1091
|
+
|
|
1092
|
+
`usePagedRows` reads `{ failed: true }` as a page without rows, so it sets `error` and keeps the rows. See [troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md). See the
|
|
1081
1093
|
[lists guide](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/lists.md).
|
|
1082
1094
|
|
|
1083
1095
|
## Traps
|
|
@@ -1289,9 +1301,10 @@ From `@alxia/react-router/lists` (`react` and `react-router` are its only import
|
|
|
1289
1301
|
|
|
1290
1302
|
| export | |
|
|
1291
1303
|
| --- | --- |
|
|
1292
|
-
| `useSearchChange({ resetKeys? })`, `SearchChangeOptions` | `(changes) => void`: sets a parameter given a string, deletes one given `null` or `undefined`, and deletes `resetKeys` (default `['after']`); the history entry is replaced and the scroll position kept |
|
|
1304
|
+
| `useSearchChange({ resetKeys? })`, `SearchChange`, `SearchChangeOptions` | `(changes: SearchChange) => void`: a record sets a parameter given a string, deletes one given `null` or `undefined`, and deletes `resetKeys` (default `['after']`); a `URLSearchParams` replaces the whole search, and an updater `(prev) => URLSearchParams` computes it (`resetKeys` does not apply to these two); the history entry is replaced and the scroll position kept |
|
|
1293
1305
|
| `useSameRouteLoading()` | `true` while a navigation is loading and its pathname is the current one (another search of the list), `false` for any other page |
|
|
1294
1306
|
| `usePagedRows(firstPage, rowsOf, { cursorParam?, nextOf? }?)`, `PagedRows`, `PagedRowsOptions` | `{ rows, next, loading, error, loadMore }`: appends the pages the same route answers to `?<cursorParam>=<next>` (default `after`, `nextOf` default `page.next`) through `useFetcher`; starts over when `firstPage` is another object; a failure keeps the rows and sets `error` (an answer that is not a page), and data that is not a page is ignored |
|
|
1307
|
+
| `orFailed(after, run)`, `FAILED`, `isFailedPage(value)`, `FailedPage` | the loader half of `usePagedRows`: `run()`'s answer; an error is rethrown when `after` is empty, and with an `after` it answers `FAILED` (`{ failed: true }`, typed `Promise<T \| FailedPage>`), except a 3xx `Response`, which is rethrown. `isFailedPage` narrows an answer |
|
|
1295
1308
|
|
|
1296
1309
|
From `@alxia/react-router/subscription` (no import, no peer):
|
|
1297
1310
|
|
package/dist/lists/index.d.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* URL (a search, filters, a cursor). `react` and `react-router` are its only
|
|
4
4
|
* imports, and every hook renders on the server.
|
|
5
5
|
*/
|
|
6
|
+
export { FAILED, type FailedPage, isFailedPage, orFailed, } from './or-failed';
|
|
6
7
|
export { DEFAULT_CURSOR_PARAM, type PagedRows, type PagedRowsOptions, usePagedRows, } from './use-paged-rows';
|
|
7
8
|
export { useSameRouteLoading } from './use-same-route-loading';
|
|
8
|
-
export { type SearchChangeOptions, useSearchChange } from './use-search-change';
|
|
9
|
+
export { type SearchChange, type SearchChangeOptions, useSearchChange, } from './use-search-change';
|
|
9
10
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lists/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACN,oBAAoB,EACpB,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,YAAY,GACZ,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAC/D,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lists/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACN,MAAM,EACN,KAAK,UAAU,EACf,YAAY,EACZ,QAAQ,GACR,MAAM,aAAa,CAAC;AACrB,OAAO,EACN,oBAAoB,EACpB,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,YAAY,GACZ,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAC/D,OAAO,EACN,KAAK,YAAY,EACjB,KAAK,mBAAmB,EACxB,eAAe,GACf,MAAM,qBAAqB,CAAC"}
|
package/dist/lists/index.js
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
// src/lists/or-failed.ts
|
|
2
|
+
var FAILED = Object.freeze({ failed: true });
|
|
3
|
+
function isFailedPage(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && value.failed === true;
|
|
5
|
+
}
|
|
6
|
+
async function orFailed(after, run) {
|
|
7
|
+
if (!after)
|
|
8
|
+
return await run();
|
|
9
|
+
try {
|
|
10
|
+
return await run();
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (error instanceof Response && error.status >= 300 && error.status < 400)
|
|
13
|
+
throw error;
|
|
14
|
+
return FAILED;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
1
17
|
// src/lists/use-paged-rows.ts
|
|
2
18
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
19
|
import { useFetcher, useLocation } from "react-router";
|
|
@@ -101,6 +117,15 @@ function useSearchChange(options = {}) {
|
|
|
101
117
|
const resetKeys = options.resetKeys ?? RESET_KEYS;
|
|
102
118
|
const joined = resetKeys.join("\x00");
|
|
103
119
|
return useCallback2((changes) => {
|
|
120
|
+
const navigation = { replace: true, preventScrollReset: true };
|
|
121
|
+
if (typeof changes === "function") {
|
|
122
|
+
setSearchParams((current) => changes(new URLSearchParams(current)), navigation);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (changes instanceof URLSearchParams) {
|
|
126
|
+
setSearchParams(new URLSearchParams(changes), navigation);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
104
129
|
setSearchParams((current) => {
|
|
105
130
|
const next = new URLSearchParams(current);
|
|
106
131
|
for (const [key, value] of Object.entries(changes)) {
|
|
@@ -112,15 +137,18 @@ function useSearchChange(options = {}) {
|
|
|
112
137
|
for (const key of joined === "" ? [] : joined.split("\x00"))
|
|
113
138
|
next.delete(key);
|
|
114
139
|
return next;
|
|
115
|
-
},
|
|
140
|
+
}, navigation);
|
|
116
141
|
}, [setSearchParams, joined]);
|
|
117
142
|
}
|
|
118
143
|
export {
|
|
119
144
|
DEFAULT_CURSOR_PARAM,
|
|
145
|
+
FAILED,
|
|
146
|
+
isFailedPage,
|
|
147
|
+
orFailed,
|
|
120
148
|
usePagedRows,
|
|
121
149
|
useSameRouteLoading,
|
|
122
150
|
useSearchChange
|
|
123
151
|
};
|
|
124
152
|
|
|
125
|
-
//# debugId=
|
|
153
|
+
//# debugId=CCC4EB19C4BC264164756E2164756E21
|
|
126
154
|
//# sourceMappingURL=index.js.map
|
package/dist/lists/index.js.map
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../src/lists/use-paged-rows.ts", "../src/lists/use-same-route-loading.ts", "../src/lists/use-search-change.ts"],
|
|
3
|
+
"sources": ["../src/lists/or-failed.ts", "../src/lists/use-paged-rows.ts", "../src/lists/use-same-route-loading.ts", "../src/lists/use-search-change.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
+
"/** What a loader answers for a failed cursor page: no rows, so `usePagedRows` sets `error`. */\nexport const FAILED = Object.freeze({ failed: true as const });\n\n/** The type of {@link FAILED}. */\nexport type FailedPage = typeof FAILED;\n\n/** Whether a loader answer is {@link FAILED}. */\nexport function isFailedPage(value: unknown): value is FailedPage {\n\treturn (\n\t\ttypeof value === 'object' &&\n\t\tvalue !== null &&\n\t\t(value as { failed?: unknown }).failed === true\n\t);\n}\n\n/**\n * The loader half of `usePagedRows`. Runs `run` and returns its answer.\n *\n * - With no `after` (the first page), an error is rethrown, so the route's\n * `ErrorBoundary` shows.\n * - With an `after` (a \"load more\"), any error becomes {@link FAILED}, so the\n * rows stay on screen with a retry, except a 3xx `Response` (a redirect),\n * which is rethrown.\n */\nexport async function orFailed<T>(\n\tafter: string | null | undefined,\n\trun: () => Promise<T> | T,\n): Promise<T | FailedPage> {\n\tif (!after) return await run();\n\ttry {\n\t\treturn await run();\n\t} catch (error) {\n\t\tif (error instanceof Response && error.status >= 300 && error.status < 400)\n\t\t\tthrow error;\n\t\treturn FAILED;\n\t}\n}\n",
|
|
5
6
|
"import { useCallback, useEffect, useRef, useState } from 'react';\nimport { useFetcher, useLocation } from 'react-router';\n\nexport interface PagedRowsOptions<Page> {\n\t/** The search parameter that carries the cursor (default `after`). */\n\treadonly cursorParam?: string;\n\t/** The cursor of the page after this one, `null` at the end (default `page.next`). */\n\treadonly nextOf?: (page: Page) => string | null;\n}\n\nexport interface PagedRows<Row> {\n\t/** The first page's rows, then the rows of each page loaded after it. */\n\treadonly rows: readonly Row[];\n\t/** The cursor of the next page, `null` when there is none. */\n\treadonly next: string | null;\n\t/** A page is being fetched. */\n\treadonly loading: boolean;\n\t/**\n\t * The last `loadMore` ended with nothing usable (the route answered something\n\t * that is not a page). The rows stay; call `loadMore` again to retry. The\n\t * next `loadMore` clears it.\n\t */\n\treadonly error: boolean;\n\t/** Fetches the page after the last one; nothing when there is none or one is on its way. */\n\treadonly loadMore: () => void;\n}\n\n/** The default cursor parameter, shared with `useSearchChange`'s default `resetKeys`. */\nexport const DEFAULT_CURSOR_PARAM = 'after';\n\ninterface Loaded<Page, Row> {\n\treadonly first: Page;\n\treadonly extra: readonly Row[];\n\treadonly next: string | null;\n\treadonly error: boolean;\n}\n\ntype NextOf<Page> = (page: Page) => string | null;\n\n/** The state for a first page: nothing loaded after it yet. */\nfunction restart<Page, Row>(\n\tfirst: Page,\n\tnextOf: NextOf<Page>,\n): Loaded<Page, Row> {\n\treturn { first, extra: [], next: nextOf(first) ?? null, error: false };\n}\n\n/**\n * The rows and cursor of `value` when it is a page (an object whose rows are\n * an array and whose cursor is a string or `null`), else `undefined`.\n */\nfunction readPage<Page, Row>(\n\tvalue: unknown,\n\trowsOf: (page: Page) => readonly Row[],\n\tnextOf: NextOf<Page>,\n): { rows: readonly Row[]; next: string | null } | undefined {\n\tif (typeof value !== 'object' || value === null) return undefined;\n\ttry {\n\t\tconst rows = rowsOf(value as Page);\n\t\tconst next = nextOf(value as Page);\n\t\tif (!Array.isArray(rows)) return undefined;\n\t\tif (next !== null && typeof next !== 'string') return undefined;\n\t\treturn { rows, next };\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/** The state after an answer: its rows appended, or `error` when it was not a page. */\nfunction settle<Page, Row>(\n\tprevious: Loaded<Page, Row>,\n\tfirst: Page,\n\tmore: { rows: readonly Row[]; next: string | null } | undefined,\n\tnextOf: NextOf<Page>,\n): Loaded<Page, Row> {\n\tconst base =\n\t\tprevious.first === first ? previous : restart<Page, Row>(first, nextOf);\n\tif (more === undefined) return { ...base, error: true };\n\treturn {\n\t\t...base,\n\t\textra: [...base.extra, ...more.rows],\n\t\tnext: more.next,\n\t\terror: false,\n\t};\n}\n\n/**\n * Appends the pages a \"load more\" asks of the route the page is on. The\n * route's loader answers `?<cursorParam>=<next>` with the next page, through\n * `useFetcher`, so the address bar and the page's own data stay as they are.\n *\n * - It starts over whenever `firstPage` is another object (a new search, a\n * revalidation): the rows are the new first page's alone, in that very\n * render. A page still on its way for the old one is dropped.\n * - A failed load keeps the rows and sets `error`. A thrown loader error goes\n * to the route's `ErrorBoundary` as React Router always does; `error` covers\n * an answer that is not a page, such as `data({ message }, { status: 503 })`\n * or `null`.\n * - Data of the fetcher that is not a page (no rows, or no cursor) is ignored.\n */\nexport function usePagedRows<Page, Row>(\n\tfirstPage: Page,\n\trowsOf: (page: Page) => readonly Row[],\n\toptions: PagedRowsOptions<Page> = {},\n): PagedRows<Row> {\n\tconst cursorParam = options.cursorParam ?? DEFAULT_CURSOR_PARAM;\n\tconst nextOf: NextOf<Page> =\n\t\toptions.nextOf ??\n\t\t((page) => (page as { next?: string | null }).next as string | null);\n\tconst fetcher = useFetcher();\n\tconst { pathname, search } = useLocation();\n\tconst [loaded, setLoaded] = useState(() =>\n\t\trestart<Page, Row>(firstPage, nextOf),\n\t);\n\t// What was asked, and for which first page, so an answer that comes after a restart is dropped.\n\tconst asked = useRef<{ first: Page; seen: unknown; started: boolean } | null>(\n\t\tnull,\n\t);\n\tconst current =\n\t\tloaded.first === firstPage ? loaded : restart<Page, Row>(firstPage, nextOf);\n\n\t// Deliberate deps: rowsOf and nextOf are usually inline functions, new on\n\t// every render, and are read at the time of the answer; the effect must run\n\t// on the fetcher's settling, not on each render.\n\tuseEffect(() => {\n\t\tconst request = asked.current;\n\t\tif (request === null) return;\n\t\tif (fetcher.state !== 'idle') {\n\t\t\trequest.started = true;\n\t\t\treturn;\n\t\t}\n\t\t// Idle: the answer is in when the fetcher was seen busy, or when its data\n\t\t// is no longer the one it had at the call (an answer within one batch).\n\t\tif (!request.started && fetcher.data === request.seen) return;\n\t\tasked.current = null;\n\t\tif (request.first !== firstPage) return;\n\t\tconst more =\n\t\t\tfetcher.data === request.seen\n\t\t\t\t? undefined\n\t\t\t\t: readPage(fetcher.data, rowsOf, nextOf);\n\t\tsetLoaded((previous) => settle(previous, firstPage, more, nextOf));\n\t}, [fetcher.state, fetcher.data, firstPage]);\n\n\tconst next = current.next;\n\tconst busy = fetcher.state !== 'idle';\n\tconst { load } = fetcher;\n\tconst loadMore = useCallback(() => {\n\t\tif (next === null || busy) return;\n\t\tconst params = new URLSearchParams(search);\n\t\tparams.set(cursorParam, next);\n\t\tasked.current = { first: firstPage, seen: fetcher.data, started: false };\n\t\tsetLoaded({ ...current, error: false });\n\t\tvoid load(`${pathname}?${params}`);\n\t}, [\n\t\tnext,\n\t\tbusy,\n\t\tsearch,\n\t\tcursorParam,\n\t\tfirstPage,\n\t\tfetcher.data,\n\t\tcurrent,\n\t\tpathname,\n\t\tload,\n\t]);\n\n\treturn {\n\t\trows: [...rowsOf(firstPage), ...current.extra],\n\t\tnext,\n\t\tloading: busy,\n\t\terror: current.error,\n\t\tloadMore,\n\t};\n}\n",
|
|
6
7
|
"import { useLocation, useNavigation } from 'react-router';\n\n/**\n * `true` while a navigation is loading and goes to the pathname the page is\n * on: another search of this list, not another page. It stays `false` for a\n * navigation elsewhere (and when idle), so a list can show its skeleton\n * without flashing on a click that leaves it.\n */\nexport function useSameRouteLoading(): boolean {\n\tconst navigation = useNavigation();\n\tconst { pathname } = useLocation();\n\treturn (\n\t\tnavigation.state === 'loading' && navigation.location.pathname === pathname\n\t);\n}\n",
|
|
7
|
-
"import { useCallback } from 'react';\nimport { useSearchParams } from 'react-router';\nimport { DEFAULT_CURSOR_PARAM } from './use-paged-rows';\n\nexport interface SearchChangeOptions {\n\t/**\n\t * The parameters dropped by every change, because they only mean something\n\t * for the search they were read from (default `['after']`, a cursor).\n\t */\n\treadonly resetKeys?: readonly string[];\n}\n\nconst RESET_KEYS: readonly string[] = [DEFAULT_CURSOR_PARAM];\n\n/**\n * Returns `(changes) => void
|
|
8
|
+
"import { useCallback } from 'react';\nimport { useSearchParams } from 'react-router';\nimport { DEFAULT_CURSOR_PARAM } from './use-paged-rows';\n\nexport interface SearchChangeOptions {\n\t/**\n\t * The parameters dropped by every change, because they only mean something\n\t * for the search they were read from (default `['after']`, a cursor).\n\t */\n\treadonly resetKeys?: readonly string[];\n}\n\nconst RESET_KEYS: readonly string[] = [DEFAULT_CURSOR_PARAM];\n\n/** What `change` takes: a record of changes, a whole search, or an updater. */\nexport type SearchChange =\n\t| Record<string, string | null | undefined>\n\t| URLSearchParams\n\t| ((prev: URLSearchParams) => URLSearchParams);\n\n/**\n * Returns `(changes) => void`. With a record it sets the parameters it is\n * given a value for, deletes the ones given `null` or `undefined` (an empty\n * string is a value, so it is set), and deletes `resetKeys` in the same step.\n * With a `URLSearchParams` it replaces the whole search by it, and with an\n * updater `(prev) => URLSearchParams` by what the updater returns; both are\n * the caller's whole search, so `resetKeys` does not apply. Every form\n * replaces the history entry and keeps the scroll position.\n */\nexport function useSearchChange(\n\toptions: SearchChangeOptions = {},\n): (changes: SearchChange) => void {\n\tconst [, setSearchParams] = useSearchParams();\n\tconst resetKeys = options.resetKeys ?? RESET_KEYS;\n\tconst joined = resetKeys.join('\\u0000');\n\treturn useCallback(\n\t\t(changes) => {\n\t\t\tconst navigation = { replace: true, preventScrollReset: true };\n\t\t\tif (typeof changes === 'function') {\n\t\t\t\tsetSearchParams(\n\t\t\t\t\t(current) => changes(new URLSearchParams(current)),\n\t\t\t\t\tnavigation,\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (changes instanceof URLSearchParams) {\n\t\t\t\tsetSearchParams(new URLSearchParams(changes), navigation);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tsetSearchParams((current) => {\n\t\t\t\tconst next = new URLSearchParams(current);\n\t\t\t\tfor (const [key, value] of Object.entries(changes)) {\n\t\t\t\t\tif (value === null || value === undefined) next.delete(key);\n\t\t\t\t\telse next.set(key, value);\n\t\t\t\t}\n\t\t\t\tfor (const key of joined === '' ? [] : joined.split('\\u0000'))\n\t\t\t\t\tnext.delete(key);\n\t\t\t\treturn next;\n\t\t\t}, navigation);\n\t\t},\n\t\t[setSearchParams, joined],\n\t);\n}\n"
|
|
8
9
|
],
|
|
9
|
-
"mappings": "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
|
|
10
|
-
"debugId": "
|
|
10
|
+
"mappings": "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",
|
|
11
|
+
"debugId": "CCC4EB19C4BC264164756E2164756E21",
|
|
11
12
|
"names": []
|
|
12
13
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** What a loader answers for a failed cursor page: no rows, so `usePagedRows` sets `error`. */
|
|
2
|
+
export declare const FAILED: Readonly<{
|
|
3
|
+
failed: true;
|
|
4
|
+
}>;
|
|
5
|
+
/** The type of {@link FAILED}. */
|
|
6
|
+
export type FailedPage = typeof FAILED;
|
|
7
|
+
/** Whether a loader answer is {@link FAILED}. */
|
|
8
|
+
export declare function isFailedPage(value: unknown): value is FailedPage;
|
|
9
|
+
/**
|
|
10
|
+
* The loader half of `usePagedRows`. Runs `run` and returns its answer.
|
|
11
|
+
*
|
|
12
|
+
* - With no `after` (the first page), an error is rethrown, so the route's
|
|
13
|
+
* `ErrorBoundary` shows.
|
|
14
|
+
* - With an `after` (a "load more"), any error becomes {@link FAILED}, so the
|
|
15
|
+
* rows stay on screen with a retry, except a 3xx `Response` (a redirect),
|
|
16
|
+
* which is rethrown.
|
|
17
|
+
*/
|
|
18
|
+
export declare function orFailed<T>(after: string | null | undefined, run: () => Promise<T> | T): Promise<T | FailedPage>;
|
|
19
|
+
//# sourceMappingURL=or-failed.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"or-failed.d.ts","sourceRoot":"","sources":["../../src/lists/or-failed.ts"],"names":[],"mappings":"AAAA,+FAA+F;AAC/F,eAAO,MAAM,MAAM;;EAA2C,CAAC;AAE/D,kCAAkC;AAClC,MAAM,MAAM,UAAU,GAAG,OAAO,MAAM,CAAC;AAEvC,iDAAiD;AACjD,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,UAAU,CAMhE;AAED;;;;;;;;GAQG;AACH,wBAAsB,QAAQ,CAAC,CAAC,EAC/B,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAChC,GAAG,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,GACvB,OAAO,CAAC,CAAC,GAAG,UAAU,CAAC,CASzB"}
|
|
@@ -5,11 +5,16 @@ export interface SearchChangeOptions {
|
|
|
5
5
|
*/
|
|
6
6
|
readonly resetKeys?: readonly string[];
|
|
7
7
|
}
|
|
8
|
+
/** What `change` takes: a record of changes, a whole search, or an updater. */
|
|
9
|
+
export type SearchChange = Record<string, string | null | undefined> | URLSearchParams | ((prev: URLSearchParams) => URLSearchParams);
|
|
8
10
|
/**
|
|
9
|
-
* Returns `(changes) => void
|
|
10
|
-
* deletes the ones given `null` or `undefined` (an empty
|
|
11
|
-
* so it is set), and deletes `resetKeys` in
|
|
12
|
-
*
|
|
11
|
+
* Returns `(changes) => void`. With a record it sets the parameters it is
|
|
12
|
+
* given a value for, deletes the ones given `null` or `undefined` (an empty
|
|
13
|
+
* string is a value, so it is set), and deletes `resetKeys` in the same step.
|
|
14
|
+
* With a `URLSearchParams` it replaces the whole search by it, and with an
|
|
15
|
+
* updater `(prev) => URLSearchParams` by what the updater returns; both are
|
|
16
|
+
* the caller's whole search, so `resetKeys` does not apply. Every form
|
|
17
|
+
* replaces the history entry and keeps the scroll position.
|
|
13
18
|
*/
|
|
14
|
-
export declare function useSearchChange(options?: SearchChangeOptions): (changes:
|
|
19
|
+
export declare function useSearchChange(options?: SearchChangeOptions): (changes: SearchChange) => void;
|
|
15
20
|
//# sourceMappingURL=use-search-change.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-search-change.d.ts","sourceRoot":"","sources":["../../src/lists/use-search-change.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,mBAAmB;IACnC;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACvC;AAID
|
|
1
|
+
{"version":3,"file":"use-search-change.d.ts","sourceRoot":"","sources":["../../src/lists/use-search-change.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,mBAAmB;IACnC;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACvC;AAID,+EAA+E;AAC/E,MAAM,MAAM,YAAY,GACrB,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,GACzC,eAAe,GACf,CAAC,CAAC,IAAI,EAAE,eAAe,KAAK,eAAe,CAAC,CAAC;AAEhD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAC9B,OAAO,GAAE,mBAAwB,GAC/B,CAAC,OAAO,EAAE,YAAY,KAAK,IAAI,CA+BjC"}
|
package/docs/guide/lists.md
CHANGED
|
@@ -47,6 +47,18 @@ meaningless. It navigates with `replace: true` and `preventScrollReset: true`,
|
|
|
47
47
|
so typing does not fill the history nor jump the page. Pass
|
|
48
48
|
`{ resetKeys: ['after', 'page'] }` to drop more.
|
|
49
49
|
|
|
50
|
+
`change` also takes a whole `URLSearchParams`, which replaces the search, or an
|
|
51
|
+
updater `(prev) => URLSearchParams`, for a filter bar or a server table that
|
|
52
|
+
hands back its next parameters. Both navigate exactly as the record form does
|
|
53
|
+
(`replace: true`, `preventScrollReset: true`), and since they are the whole
|
|
54
|
+
search, `resetKeys` does not apply to them: drop the cursor yourself if the
|
|
55
|
+
new search needs it gone.
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
<Table onParamsChange={(next: URLSearchParams) => change(next)} />;
|
|
59
|
+
change((prev) => new URLSearchParams([...prev].filter(([key]) => key !== 'status')));
|
|
60
|
+
```
|
|
61
|
+
|
|
50
62
|
## `useSameRouteLoading`
|
|
51
63
|
|
|
52
64
|
```tsx
|
|
@@ -107,5 +119,22 @@ with another one a search that keeps the old cursor shows the wrong page.
|
|
|
107
119
|
|
|
108
120
|
A loader that **throws** on a cursor request is not an `error` here: React
|
|
109
121
|
Router sends a fetcher's thrown error to the route's `ErrorBoundary`. To offer
|
|
110
|
-
a retry instead,
|
|
111
|
-
|
|
122
|
+
a retry instead, wrap the read in `orFailed(after, run)`: it answers
|
|
123
|
+
`FAILED` (`{ failed: true }`, a value that is not a page) for a request that
|
|
124
|
+
has the cursor, rethrows when there is none and rethrows a 3xx `Response`.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { orFailed } from '@alxia/react-router/lists';
|
|
128
|
+
|
|
129
|
+
export function loader({ request }: Route.LoaderArgs) {
|
|
130
|
+
const url = new URL(request.url);
|
|
131
|
+
return orFailed(url.searchParams.get('after'), async () => ({
|
|
132
|
+
page: await listUsers({
|
|
133
|
+
q: url.searchParams.get('q') ?? '',
|
|
134
|
+
after: url.searchParams.get('after'),
|
|
135
|
+
}),
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`isFailedPage(loaderData)` narrows the answer when a component reads it.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -539,7 +539,16 @@ name.
|
|
|
539
539
|
|
|
540
540
|
**Why:** React Router sends the error of a fetcher's load to the route's boundary. `usePagedRows` only sees what the fetcher answers, so its `error` is `true` for an answer that is not a page, not for a throw.
|
|
541
541
|
|
|
542
|
-
**Fix:**
|
|
542
|
+
**Fix:** wrap the read in `orFailed(after, run)` from `@alxia/react-router/lists`, which answers `{ failed: true }` for a request that has the cursor and rethrows otherwise (and rethrows a redirect):
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
export function loader({ request }: Route.LoaderArgs) {
|
|
546
|
+
const url = new URL(request.url);
|
|
547
|
+
return orFailed(url.searchParams.get('after'), () => listUsers(url.searchParams));
|
|
548
|
+
}
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
By hand, that is:
|
|
543
552
|
|
|
544
553
|
```ts
|
|
545
554
|
export async function loader({ request }: Route.LoaderArgs) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/react-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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",
|