@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 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`: see [troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md). See the
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
 
@@ -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,EAAE,KAAK,mBAAmB,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC"}
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"}
@@ -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
- }, { replace: true, preventScrollReset: true });
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=D936887B843025CD64756E2164756E21
153
+ //# debugId=CCC4EB19C4BC264164756E2164756E21
126
154
  //# sourceMappingURL=index.js.map
@@ -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`: sets the parameters it is given a value for,\n * deletes the ones given `null` or `undefined` (an empty string is a value,\n * so it is set), and deletes `resetKeys` in\n * the same step. It replaces the history entry and keeps the scroll position.\n */\nexport function useSearchChange(\n\toptions: SearchChangeOptions = {},\n): (changes: Record<string, string | null | undefined>) => 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\tsetSearchParams(\n\t\t\t\t(current) => {\n\t\t\t\t\tconst next = new URLSearchParams(current);\n\t\t\t\t\tfor (const [key, value] of Object.entries(changes)) {\n\t\t\t\t\t\tif (value === null || value === undefined) next.delete(key);\n\t\t\t\t\t\telse next.set(key, value);\n\t\t\t\t\t}\n\t\t\t\t\tfor (const key of joined === '' ? [] : joined.split('\\u0000'))\n\t\t\t\t\t\tnext.delete(key);\n\t\t\t\t\treturn next;\n\t\t\t\t},\n\t\t\t\t{ replace: true, preventScrollReset: true },\n\t\t\t);\n\t\t},\n\t\t[setSearchParams, joined],\n\t);\n}\n"
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": "D936887B843025CD64756E2164756E21",
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`: sets the parameters it is given a value for,
10
- * deletes the ones given `null` or `undefined` (an empty string is a value,
11
- * so it is set), and deletes `resetKeys` in
12
- * the same step. It replaces the history entry and keeps the scroll position.
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: Record<string, string | null | undefined>) => void;
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;;;;;GAKG;AACH,wBAAgB,eAAe,CAC9B,OAAO,GAAE,mBAAwB,GAC/B,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,KAAK,IAAI,CAsB9D"}
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"}
@@ -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, catch in the loader for a request that has the cursor and
111
- answer a value that is not a page.
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.
@@ -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:** catch in the loader for a request that has the cursor and answer a value that is not a page:
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.23.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",