@alxia/react-router 0.19.0 → 0.20.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 +62 -1
- package/dist/lists/index.d.ts +9 -0
- package/dist/lists/index.d.ts.map +1 -0
- package/dist/lists/index.js +126 -0
- package/dist/lists/index.js.map +12 -0
- package/dist/lists/use-paged-rows.d.ts +40 -0
- package/dist/lists/use-paged-rows.d.ts.map +1 -0
- package/dist/lists/use-same-route-loading.d.ts +8 -0
- package/dist/lists/use-same-route-loading.d.ts.map +1 -0
- package/dist/lists/use-search-change.d.ts +15 -0
- package/dist/lists/use-search-change.d.ts.map +1 -0
- package/docs/README.md +1 -0
- package/docs/guide/lists.md +111 -0
- package/docs/roadmap.md +7 -0
- package/docs/troubleshooting.md +22 -0
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -92,7 +92,7 @@ Vite's preview server, as `bun run start` would: the pages, `/api`, the
|
|
|
92
92
|
client's files and the app's middlewares. So does React Router's prerendering.
|
|
93
93
|
|
|
94
94
|
`@alxia/core` and `react-router` 8 are peers, and `vite` 7 or 8 is an
|
|
95
|
-
optional peer, for `/vite`; `react` 19 is an optional peer, for `/pwa/react`. The six `workbox-*`
|
|
95
|
+
optional peer, for `/vite`; `react` 19 is an optional peer, for `/pwa/react` and `/lists`. The six `workbox-*`
|
|
96
96
|
packages (`workbox-core`, `workbox-expiration`, `workbox-navigation-preload`,
|
|
97
97
|
`workbox-precaching`, `workbox-routing`, `workbox-strategies`) are optional
|
|
98
98
|
peers too, for `/pwa/worker` (the [PWA section](#installable-and-offline-pwa) has the install line). `/errors` imports `react-router`, a required peer that is already installed. The package declares no dependency.
|
|
@@ -1019,6 +1019,58 @@ export const action = events.action; // POST
|
|
|
1019
1019
|
See the [subscription guide](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/subscriptions.md)
|
|
1020
1020
|
for the rules, the options and the errors.
|
|
1021
1021
|
|
|
1022
|
+
## A list whose state is the URL
|
|
1023
|
+
|
|
1024
|
+
`@alxia/react-router/lists` (`react` and `react-router` are its only imports; every hook renders on the server)
|
|
1025
|
+
holds the three hooks a search, filters and a "load more" button need.
|
|
1026
|
+
|
|
1027
|
+
```tsx
|
|
1028
|
+
// app/routes/users.tsx
|
|
1029
|
+
import { usePagedRows, useSameRouteLoading, useSearchChange } from '@alxia/react-router/lists';
|
|
1030
|
+
import type { Route } from './+types/users';
|
|
1031
|
+
|
|
1032
|
+
const USERS = Array.from({ length: 45 }, (_, i) => `user ${i + 1}`);
|
|
1033
|
+
|
|
1034
|
+
// ?q=<search>&after=<cursor>: 20 rows a page, `next` is null on the last one
|
|
1035
|
+
export function loader({ request }: Route.LoaderArgs) {
|
|
1036
|
+
const url = new URL(request.url);
|
|
1037
|
+
const q = url.searchParams.get('q') ?? '';
|
|
1038
|
+
const start = Number(url.searchParams.get('after') ?? 0);
|
|
1039
|
+
const found = USERS.filter((name) => name.includes(q));
|
|
1040
|
+
const rows = found.slice(start, start + 20);
|
|
1041
|
+
return { rows, next: start + 20 < found.length ? String(start + 20) : null };
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
export default function Users({ loaderData }: Route.ComponentProps) {
|
|
1045
|
+
const change = useSearchChange(); // drops ?after on every change
|
|
1046
|
+
const reloading = useSameRouteLoading(); // another search of this page: show the skeleton
|
|
1047
|
+
const { rows, next, loading, error, loadMore } = usePagedRows(loaderData, (page) => page.rows);
|
|
1048
|
+
return (
|
|
1049
|
+
<main>
|
|
1050
|
+
<input type="search" placeholder="Search" onChange={(event) => change({ q: event.target.value || null })} />
|
|
1051
|
+
{reloading ? (
|
|
1052
|
+
<p>Loading…</p>
|
|
1053
|
+
) : (
|
|
1054
|
+
<ul>
|
|
1055
|
+
{rows.map((name) => (
|
|
1056
|
+
<li key={name}>{name}</li>
|
|
1057
|
+
))}
|
|
1058
|
+
</ul>
|
|
1059
|
+
)}
|
|
1060
|
+
{error ? <p role="alert">The next rows could not be loaded.</p> : null}
|
|
1061
|
+
{next ? (
|
|
1062
|
+
<button type="button" disabled={loading} onClick={loadMore}>
|
|
1063
|
+
{error ? 'Retry' : 'Load more'}
|
|
1064
|
+
</button>
|
|
1065
|
+
) : null}
|
|
1066
|
+
</main>
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
```
|
|
1070
|
+
|
|
1071
|
+
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
|
|
1072
|
+
[lists guide](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/guide/lists.md).
|
|
1073
|
+
|
|
1022
1074
|
## Traps
|
|
1023
1075
|
|
|
1024
1076
|
- **A context key made in `app/` reaches the loaders only through the
|
|
@@ -1223,6 +1275,14 @@ From `@alxia/react-router/entry`:
|
|
|
1223
1275
|
| `createEntry({ streamTimeout?, nonce?, isBot?, onError? })`, `EntryOptions`, `Entry` | `/entry`: `{ handleRequest, streamTimeout }` (`Entry`), `entry.server.tsx` for Bun on the web stream renderer |
|
|
1224
1276
|
| `handleRequest`, `streamTimeout`, `HandleRequest` | `/entry`: the default entry (`createEntry()`), its `5_000` and the type of `handleRequest` (assignable to React Router's `ServerEntryModule['default']`) |
|
|
1225
1277
|
|
|
1278
|
+
From `@alxia/react-router/lists` (`react` and `react-router` are its only imports; every hook renders on the server):
|
|
1279
|
+
|
|
1280
|
+
| export | |
|
|
1281
|
+
| --- | --- |
|
|
1282
|
+
| `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 |
|
|
1283
|
+
| `useSameRouteLoading()` | `true` while a navigation is loading and its pathname is the current one (another search of the list), `false` for any other page |
|
|
1284
|
+
| `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 |
|
|
1285
|
+
|
|
1226
1286
|
From `@alxia/react-router/subscription` (no import, no peer):
|
|
1227
1287
|
|
|
1228
1288
|
| export | |
|
|
@@ -1247,6 +1307,7 @@ The `alxia-react-router` bin, run with `bunx`:
|
|
|
1247
1307
|
- [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.
|
|
1248
1308
|
- [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.
|
|
1249
1309
|
- [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.
|
|
1310
|
+
- [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.
|
|
1250
1311
|
- [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.
|
|
1251
1312
|
- [Troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/troubleshooting.md): each message, and the traps that print none.
|
|
1252
1313
|
- [Roadmap](https://github.com/softistx/alxia/blob/develop/packages/react-router/docs/roadmap.md): what is coming, and what is not planned.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@alxia/react-router/lists`: the hooks of a list screen whose state is the
|
|
3
|
+
* URL (a search, filters, a cursor). `react` and `react-router` are its only
|
|
4
|
+
* imports, and every hook renders on the server.
|
|
5
|
+
*/
|
|
6
|
+
export { DEFAULT_CURSOR_PARAM, type PagedRows, type PagedRowsOptions, usePagedRows, } from './use-paged-rows';
|
|
7
|
+
export { useSameRouteLoading } from './use-same-route-loading';
|
|
8
|
+
export { type SearchChangeOptions, useSearchChange } from './use-search-change';
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// src/lists/use-paged-rows.ts
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useFetcher, useLocation } from "react-router";
|
|
4
|
+
var DEFAULT_CURSOR_PARAM = "after";
|
|
5
|
+
function restart(first, nextOf) {
|
|
6
|
+
return { first, extra: [], next: nextOf(first) ?? null, error: false };
|
|
7
|
+
}
|
|
8
|
+
function readPage(value, rowsOf, nextOf) {
|
|
9
|
+
if (typeof value !== "object" || value === null)
|
|
10
|
+
return;
|
|
11
|
+
try {
|
|
12
|
+
const rows = rowsOf(value);
|
|
13
|
+
const next = nextOf(value);
|
|
14
|
+
if (!Array.isArray(rows))
|
|
15
|
+
return;
|
|
16
|
+
if (next !== null && typeof next !== "string")
|
|
17
|
+
return;
|
|
18
|
+
return { rows, next };
|
|
19
|
+
} catch {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function settle(previous, first, more, nextOf) {
|
|
24
|
+
const base = previous.first === first ? previous : restart(first, nextOf);
|
|
25
|
+
if (more === undefined)
|
|
26
|
+
return { ...base, error: true };
|
|
27
|
+
return {
|
|
28
|
+
...base,
|
|
29
|
+
extra: [...base.extra, ...more.rows],
|
|
30
|
+
next: more.next,
|
|
31
|
+
error: false
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function usePagedRows(firstPage, rowsOf, options = {}) {
|
|
35
|
+
const cursorParam = options.cursorParam ?? DEFAULT_CURSOR_PARAM;
|
|
36
|
+
const nextOf = options.nextOf ?? ((page) => page.next);
|
|
37
|
+
const fetcher = useFetcher();
|
|
38
|
+
const { pathname, search } = useLocation();
|
|
39
|
+
const [loaded, setLoaded] = useState(() => restart(firstPage, nextOf));
|
|
40
|
+
const asked = useRef(null);
|
|
41
|
+
const current = loaded.first === firstPage ? loaded : restart(firstPage, nextOf);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const request = asked.current;
|
|
44
|
+
if (request === null)
|
|
45
|
+
return;
|
|
46
|
+
if (fetcher.state !== "idle") {
|
|
47
|
+
request.started = true;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (!request.started && fetcher.data === request.seen)
|
|
51
|
+
return;
|
|
52
|
+
asked.current = null;
|
|
53
|
+
if (request.first !== firstPage)
|
|
54
|
+
return;
|
|
55
|
+
const more = fetcher.data === request.seen ? undefined : readPage(fetcher.data, rowsOf, nextOf);
|
|
56
|
+
setLoaded((previous) => settle(previous, firstPage, more, nextOf));
|
|
57
|
+
}, [fetcher.state, fetcher.data, firstPage]);
|
|
58
|
+
const next = current.next;
|
|
59
|
+
const busy = fetcher.state !== "idle";
|
|
60
|
+
const { load } = fetcher;
|
|
61
|
+
const loadMore = useCallback(() => {
|
|
62
|
+
if (next === null || busy)
|
|
63
|
+
return;
|
|
64
|
+
const params = new URLSearchParams(search);
|
|
65
|
+
params.set(cursorParam, next);
|
|
66
|
+
asked.current = { first: firstPage, seen: fetcher.data, started: false };
|
|
67
|
+
setLoaded({ ...current, error: false });
|
|
68
|
+
load(`${pathname}?${params}`);
|
|
69
|
+
}, [
|
|
70
|
+
next,
|
|
71
|
+
busy,
|
|
72
|
+
search,
|
|
73
|
+
cursorParam,
|
|
74
|
+
firstPage,
|
|
75
|
+
fetcher.data,
|
|
76
|
+
current,
|
|
77
|
+
pathname,
|
|
78
|
+
load
|
|
79
|
+
]);
|
|
80
|
+
return {
|
|
81
|
+
rows: [...rowsOf(firstPage), ...current.extra],
|
|
82
|
+
next,
|
|
83
|
+
loading: busy,
|
|
84
|
+
error: current.error,
|
|
85
|
+
loadMore
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// src/lists/use-same-route-loading.ts
|
|
89
|
+
import { useLocation as useLocation2, useNavigation } from "react-router";
|
|
90
|
+
function useSameRouteLoading() {
|
|
91
|
+
const navigation = useNavigation();
|
|
92
|
+
const { pathname } = useLocation2();
|
|
93
|
+
return navigation.state === "loading" && navigation.location.pathname === pathname;
|
|
94
|
+
}
|
|
95
|
+
// src/lists/use-search-change.ts
|
|
96
|
+
import { useCallback as useCallback2 } from "react";
|
|
97
|
+
import { useSearchParams } from "react-router";
|
|
98
|
+
var RESET_KEYS = [DEFAULT_CURSOR_PARAM];
|
|
99
|
+
function useSearchChange(options = {}) {
|
|
100
|
+
const [, setSearchParams] = useSearchParams();
|
|
101
|
+
const resetKeys = options.resetKeys ?? RESET_KEYS;
|
|
102
|
+
const joined = resetKeys.join("\x00");
|
|
103
|
+
return useCallback2((changes) => {
|
|
104
|
+
setSearchParams((current) => {
|
|
105
|
+
const next = new URLSearchParams(current);
|
|
106
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
107
|
+
if (value === null || value === undefined)
|
|
108
|
+
next.delete(key);
|
|
109
|
+
else
|
|
110
|
+
next.set(key, value);
|
|
111
|
+
}
|
|
112
|
+
for (const key of joined === "" ? [] : joined.split("\x00"))
|
|
113
|
+
next.delete(key);
|
|
114
|
+
return next;
|
|
115
|
+
}, { replace: true, preventScrollReset: true });
|
|
116
|
+
}, [setSearchParams, joined]);
|
|
117
|
+
}
|
|
118
|
+
export {
|
|
119
|
+
DEFAULT_CURSOR_PARAM,
|
|
120
|
+
usePagedRows,
|
|
121
|
+
useSameRouteLoading,
|
|
122
|
+
useSearchChange
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
//# debugId=D936887B843025CD64756E2164756E21
|
|
126
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/lists/use-paged-rows.ts", "../src/lists/use-same-route-loading.ts", "../src/lists/use-search-change.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"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
|
+
"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
|
+
],
|
|
9
|
+
"mappings": "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",
|
|
10
|
+
"debugId": "D936887B843025CD64756E2164756E21",
|
|
11
|
+
"names": []
|
|
12
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface PagedRowsOptions<Page> {
|
|
2
|
+
/** The search parameter that carries the cursor (default `after`). */
|
|
3
|
+
readonly cursorParam?: string;
|
|
4
|
+
/** The cursor of the page after this one, `null` at the end (default `page.next`). */
|
|
5
|
+
readonly nextOf?: (page: Page) => string | null;
|
|
6
|
+
}
|
|
7
|
+
export interface PagedRows<Row> {
|
|
8
|
+
/** The first page's rows, then the rows of each page loaded after it. */
|
|
9
|
+
readonly rows: readonly Row[];
|
|
10
|
+
/** The cursor of the next page, `null` when there is none. */
|
|
11
|
+
readonly next: string | null;
|
|
12
|
+
/** A page is being fetched. */
|
|
13
|
+
readonly loading: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The last `loadMore` ended with nothing usable (the route answered something
|
|
16
|
+
* that is not a page). The rows stay; call `loadMore` again to retry. The
|
|
17
|
+
* next `loadMore` clears it.
|
|
18
|
+
*/
|
|
19
|
+
readonly error: boolean;
|
|
20
|
+
/** Fetches the page after the last one; nothing when there is none or one is on its way. */
|
|
21
|
+
readonly loadMore: () => void;
|
|
22
|
+
}
|
|
23
|
+
/** The default cursor parameter, shared with `useSearchChange`'s default `resetKeys`. */
|
|
24
|
+
export declare const DEFAULT_CURSOR_PARAM = "after";
|
|
25
|
+
/**
|
|
26
|
+
* Appends the pages a "load more" asks of the route the page is on. The
|
|
27
|
+
* route's loader answers `?<cursorParam>=<next>` with the next page, through
|
|
28
|
+
* `useFetcher`, so the address bar and the page's own data stay as they are.
|
|
29
|
+
*
|
|
30
|
+
* - It starts over whenever `firstPage` is another object (a new search, a
|
|
31
|
+
* revalidation): the rows are the new first page's alone, in that very
|
|
32
|
+
* render. A page still on its way for the old one is dropped.
|
|
33
|
+
* - A failed load keeps the rows and sets `error`. A thrown loader error goes
|
|
34
|
+
* to the route's `ErrorBoundary` as React Router always does; `error` covers
|
|
35
|
+
* an answer that is not a page, such as `data({ message }, { status: 503 })`
|
|
36
|
+
* or `null`.
|
|
37
|
+
* - Data of the fetcher that is not a page (no rows, or no cursor) is ignored.
|
|
38
|
+
*/
|
|
39
|
+
export declare function usePagedRows<Page, Row>(firstPage: Page, rowsOf: (page: Page) => readonly Row[], options?: PagedRowsOptions<Page>): PagedRows<Row>;
|
|
40
|
+
//# sourceMappingURL=use-paged-rows.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-paged-rows.d.ts","sourceRoot":"","sources":["../../src/lists/use-paged-rows.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,gBAAgB,CAAC,IAAI;IACrC,sEAAsE;IACtE,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,sFAAsF;IACtF,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,MAAM,GAAG,IAAI,CAAC;CAChD;AAED,MAAM,WAAW,SAAS,CAAC,GAAG;IAC7B,yEAAyE;IACzE,QAAQ,CAAC,IAAI,EAAE,SAAS,GAAG,EAAE,CAAC;IAC9B,8DAA8D;IAC9D,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,+BAA+B;IAC/B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,4FAA4F;IAC5F,QAAQ,CAAC,QAAQ,EAAE,MAAM,IAAI,CAAC;CAC9B;AAED,yFAAyF;AACzF,eAAO,MAAM,oBAAoB,UAAU,CAAC;AA0D5C;;;;;;;;;;;;;GAaG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,GAAG,EACrC,SAAS,EAAE,IAAI,EACf,MAAM,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,SAAS,GAAG,EAAE,EACtC,OAAO,GAAE,gBAAgB,CAAC,IAAI,CAAM,GAClC,SAAS,CAAC,GAAG,CAAC,CAoEhB"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `true` while a navigation is loading and goes to the pathname the page is
|
|
3
|
+
* on: another search of this list, not another page. It stays `false` for a
|
|
4
|
+
* navigation elsewhere (and when idle), so a list can show its skeleton
|
|
5
|
+
* without flashing on a click that leaves it.
|
|
6
|
+
*/
|
|
7
|
+
export declare function useSameRouteLoading(): boolean;
|
|
8
|
+
//# sourceMappingURL=use-same-route-loading.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-same-route-loading.d.ts","sourceRoot":"","sources":["../../src/lists/use-same-route-loading.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,wBAAgB,mBAAmB,IAAI,OAAO,CAM7C"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface SearchChangeOptions {
|
|
2
|
+
/**
|
|
3
|
+
* The parameters dropped by every change, because they only mean something
|
|
4
|
+
* for the search they were read from (default `['after']`, a cursor).
|
|
5
|
+
*/
|
|
6
|
+
readonly resetKeys?: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
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.
|
|
13
|
+
*/
|
|
14
|
+
export declare function useSearchChange(options?: SearchChangeOptions): (changes: Record<string, string | null | undefined>) => void;
|
|
15
|
+
//# sourceMappingURL=use-search-change.d.ts.map
|
|
@@ -0,0 +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"}
|
package/docs/README.md
CHANGED
|
@@ -20,6 +20,7 @@ installable and usable offline, wording an API's refusals, the BFF session, a Gr
|
|
|
20
20
|
| [Installable and offline](guide/pwa.md) | making the app installable and usable offline: the subpaths of `@alxia/react-router/pwa`, the service worker (`startWorker`, `/pwa/worker`), its build by `alxia()` or `buildWorker` (`/pwa/build`), its contract and cache names, which requests the worker caches and which responses it stores, the precache list read from a build, or clearing the caches at sign-out |
|
|
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`), and the step-up's state and the props it gives the UI (`@alxia/react-router/session/model`) |
|
|
23
|
+
| [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` |
|
|
23
24
|
| [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`) |
|
|
24
25
|
| [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or a page streams, hydrates or reads its context unlike you expected |
|
|
25
26
|
| [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Lists whose state is the URL
|
|
2
|
+
|
|
3
|
+
A list screen of a BFF keeps its state in the address bar: `?q=ana&status=open`
|
|
4
|
+
for a search and filters, `?after=<cursor>` for the page. The loader reads it;
|
|
5
|
+
the screen changes it. `@alxia/react-router/lists` holds the three hooks every
|
|
6
|
+
such screen needs. `react` and `react-router` are its only imports, so it adds
|
|
7
|
+
no peer beyond those, and every hook renders on the server.
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
import {
|
|
11
|
+
usePagedRows,
|
|
12
|
+
useSameRouteLoading,
|
|
13
|
+
useSearchChange,
|
|
14
|
+
} from '@alxia/react-router/lists';
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## The loader
|
|
18
|
+
|
|
19
|
+
One loader serves the first page and the next ones. It reads the cursor and
|
|
20
|
+
answers `{ items, next }`, `next` being `null` on the last page:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
export async function loader({ request }: Route.LoaderArgs) {
|
|
24
|
+
const url = new URL(request.url);
|
|
25
|
+
return {
|
|
26
|
+
page: await listUsers({
|
|
27
|
+
q: url.searchParams.get('q') ?? '',
|
|
28
|
+
after: url.searchParams.get('after'),
|
|
29
|
+
}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## `useSearchChange`
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
const change = useSearchChange(); // resetKeys: ['after']
|
|
38
|
+
|
|
39
|
+
<input onChange={(event) => change({ q: event.target.value || null })} />;
|
|
40
|
+
<Select onChange={(status) => change({ status })} />;
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`change(changes)` sets each parameter given a string, deletes each given
|
|
44
|
+
`null` or `undefined` (an empty string is a value, so it is kept as `?q=`),
|
|
45
|
+
and deletes `resetKeys` in the same step: a new search makes the old cursor
|
|
46
|
+
meaningless. It navigates with `replace: true` and `preventScrollReset: true`,
|
|
47
|
+
so typing does not fill the history nor jump the page. Pass
|
|
48
|
+
`{ resetKeys: ['after', 'page'] }` to drop more.
|
|
49
|
+
|
|
50
|
+
## `useSameRouteLoading`
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
const reloading = useSameRouteLoading();
|
|
54
|
+
return reloading ? <Skeleton /> : <Table rows={rows} />;
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`true` while a navigation is loading **and** its pathname is the page's own: a
|
|
58
|
+
new search of this list. A click on a link to another page stays `false`, so
|
|
59
|
+
the list does not flash a skeleton on the way out.
|
|
60
|
+
|
|
61
|
+
## `usePagedRows`
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
const { rows, next, loading, error, loadMore } = usePagedRows(
|
|
65
|
+
loaderData.page,
|
|
66
|
+
(page) => page.items,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
<Button disabled={!next || loading} onClick={loadMore}>
|
|
70
|
+
{error ? 'Retry' : 'Load more'}
|
|
71
|
+
</Button>;
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
| option | default | |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| `cursorParam` | `'after'` | the search parameter carrying the cursor |
|
|
77
|
+
| `nextOf` | `page.next` | the cursor after a page, `null` at the end |
|
|
78
|
+
|
|
79
|
+
- `loadMore()` asks the **same route** for `?<cursorParam>=<next>`, keeping the
|
|
80
|
+
rest of the search, through `useFetcher`: the address bar and the page's
|
|
81
|
+
data do not change. It does nothing at the end or while a page is on its way.
|
|
82
|
+
- `rows` is the first page's rows followed by every page loaded after it.
|
|
83
|
+
- **It starts over** when `firstPage` is another object, which is what the
|
|
84
|
+
loader gives after a new search or a revalidation. The new first page's rows
|
|
85
|
+
are the ones rendered, with no frame of stale rows, and a page still on its
|
|
86
|
+
way for the old search is dropped.
|
|
87
|
+
- **A failure keeps the rows.** When the route answers something that is not a
|
|
88
|
+
page (`null`, or `data({ message }, { status: 503 })`), `error` is `true` and
|
|
89
|
+
`next` is unchanged, so the same button retries. The next `loadMore` clears
|
|
90
|
+
it. Fetcher data that is not a page (no rows, or a `next` that is neither a string nor `null`) sets `error`, like a failed page.
|
|
91
|
+
|
|
92
|
+
### Another cursor parameter
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// the loader answers { items, cursor } for ?q=&page=<cursor>
|
|
96
|
+
const { rows, next, loadMore } = usePagedRows(
|
|
97
|
+
loaderData.page,
|
|
98
|
+
(page) => page.items,
|
|
99
|
+
{ cursorParam: 'page', nextOf: (page) => page.cursor },
|
|
100
|
+
);
|
|
101
|
+
const change = useSearchChange({ resetKeys: ['page'] });
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`cursorParam` and `useSearchChange`'s `resetKeys` must name the same
|
|
105
|
+
parameter: with the defaults both are `after` (`DEFAULT_CURSOR_PARAM`), and
|
|
106
|
+
with another one a search that keeps the old cursor shows the wrong page.
|
|
107
|
+
|
|
108
|
+
A loader that **throws** on a cursor request is not an `error` here: React
|
|
109
|
+
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.
|
package/docs/roadmap.md
CHANGED
|
@@ -22,6 +22,13 @@ number on it. Every release, with each change it made, is in
|
|
|
22
22
|
upstream with the client and passes a 429 on with `retryAfter`. See the
|
|
23
23
|
[subscriptions guide](guide/subscriptions.md).
|
|
24
24
|
|
|
25
|
+
- **`@alxia/react-router/lists`: hooks for lists whose state is the URL.**
|
|
26
|
+
`useSearchChange` (set or delete search parameters and drop the cursor),
|
|
27
|
+
`useSameRouteLoading` (another search of the same page is loading) and
|
|
28
|
+
`usePagedRows` (append the pages a load-more asks of the same loader, start
|
|
29
|
+
over on a new first page, keep the rows when a page fails). See the
|
|
30
|
+
[lists guide](guide/lists.md).
|
|
31
|
+
|
|
25
32
|
## Next
|
|
26
33
|
|
|
27
34
|
Nothing planned.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -533,6 +533,28 @@ them.
|
|
|
533
533
|
inside a folder is served by that folder's `static` route, whatever its
|
|
534
534
|
name.
|
|
535
535
|
|
|
536
|
+
### A "load more" lands on the route's `ErrorBoundary`
|
|
537
|
+
|
|
538
|
+
**When:** the loader throws (or throws a `Response`) for a request that has the cursor, and the page used `usePagedRows`: the whole route is replaced by its `ErrorBoundary` instead of showing the rows with a retry button.
|
|
539
|
+
|
|
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
|
+
|
|
542
|
+
**Fix:** catch in the loader for a request that has the cursor and answer a value that is not a page:
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
export async function loader({ request }: Route.LoaderArgs) {
|
|
546
|
+
const url = new URL(request.url);
|
|
547
|
+
try {
|
|
548
|
+
return await listUsers(url.searchParams);
|
|
549
|
+
} catch (error) {
|
|
550
|
+
if (url.searchParams.has('after')) return { message: 'unavailable' };
|
|
551
|
+
throw error;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
The rows stay, `error` is `true`, and `loadMore` retries.
|
|
557
|
+
|
|
536
558
|
### `Refused to execute inline script because it violates the following Content Security Policy directive: "default-src 'none'"`
|
|
537
559
|
|
|
538
560
|
The browser's console prints it; the page renders on the server, then
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/react-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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",
|
|
@@ -82,6 +82,11 @@
|
|
|
82
82
|
"import": "./dist/session/model/index.js",
|
|
83
83
|
"default": "./dist/session/model/index.js"
|
|
84
84
|
},
|
|
85
|
+
"./lists": {
|
|
86
|
+
"types": "./dist/lists/index.d.ts",
|
|
87
|
+
"import": "./dist/lists/index.js",
|
|
88
|
+
"default": "./dist/lists/index.js"
|
|
89
|
+
},
|
|
85
90
|
"./subscription": {
|
|
86
91
|
"types": "./dist/subscription/index.d.ts",
|
|
87
92
|
"import": "./dist/subscription/index.js",
|
|
@@ -115,6 +120,7 @@
|
|
|
115
120
|
"src/session/index.ts",
|
|
116
121
|
"src/session/react/index.ts",
|
|
117
122
|
"src/subscription/index.ts",
|
|
123
|
+
"src/lists/index.ts",
|
|
118
124
|
"src/document/index.ts",
|
|
119
125
|
"src/entry/index.ts",
|
|
120
126
|
"src/pwa/worker/index.ts",
|