@jsenv/navi 0.29.20 → 0.29.21
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/dist/jsenv_navi.js +669 -32
- package/dist/jsenv_navi.js.map +89 -32
- package/docs/actions.md +117 -250
- package/docs/resource.md +88 -0
- package/docs/scroll.md +1 -1
- package/package.json +1 -1
package/docs/actions.md
CHANGED
|
@@ -1,250 +1,117 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
-
|
|
114
|
-
|
|
115
|
-
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
-
|
|
121
|
-
- ## Usage Patterns
|
|
122
|
-
-
|
|
123
|
-
- ### 🏗️ **Basic Resource Loading**
|
|
124
|
-
- ```js
|
|
125
|
-
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
- const getUserAction = createActionTemplate(async ({ userId }) => {
|
|
129
|
-
- return await api.getUser(userId);
|
|
130
|
-
- });
|
|
131
|
-
-
|
|
132
|
-
- // In component
|
|
133
|
-
- const userAction = getUserAction.instantiate({ userId: 123 });
|
|
134
|
-
- const { pending, data, error } = useActionStatus(userAction);
|
|
135
|
-
-
|
|
136
|
-
- useEffect(() => {
|
|
137
|
-
- userAction.load();
|
|
138
|
-
- }, []);
|
|
139
|
-
- ```
|
|
140
|
-
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
-
|
|
144
|
-
- ### 🔄 **Reactive Data Loading**
|
|
145
|
-
- ```js
|
|
146
|
-
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
- const searchProxy = createActionProxy(searchTemplate, {
|
|
150
|
-
- query: searchSignal,
|
|
151
|
-
- filters: filtersSignal
|
|
152
|
-
- });
|
|
153
|
-
- // Automatically reloads when signals change
|
|
154
|
-
- ```
|
|
155
|
-
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
-
|
|
159
|
-
- ### 📋 **Master-Detail Pattern**
|
|
160
|
-
- ```js
|
|
161
|
-
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
- const usersAction = getUsersTemplate.instantiate();
|
|
165
|
-
- const selectedUser = signal(null);
|
|
166
|
-
-
|
|
167
|
-
- const userDetailsProxy = createActionProxy(getUserTemplate, {
|
|
168
|
-
- userId: computed(() => selectedUser.value?.id)
|
|
169
|
-
- });
|
|
170
|
-
- ```
|
|
171
|
-
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
-
|
|
175
|
-
- ### 🏃 **Progressive Loading**
|
|
176
|
-
- ```js
|
|
177
|
-
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
- // Preload on hover, load on click
|
|
181
|
-
- <button
|
|
182
|
-
- onMouseEnter={() => action.preload()}
|
|
183
|
-
- onClick={() => action.load()}
|
|
184
|
-
- >
|
|
185
|
-
- Load User
|
|
186
|
-
- </button>
|
|
187
|
-
- ```
|
|
188
|
-
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
-
|
|
192
|
-
- ## Advanced Features
|
|
193
|
-
-
|
|
194
|
-
- ### 🎭 **Custom Data Transformation**
|
|
195
|
-
- ```js
|
|
196
|
-
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
- const actionTemplate = createActionTemplate(fetchUser, {
|
|
200
|
-
- computedDataSignal: computed(() => {
|
|
201
|
-
- const rawData = dataSignal.value;
|
|
202
|
-
- return rawData ? transformUser(rawData) : null;
|
|
203
|
-
- })
|
|
204
|
-
- });
|
|
205
|
-
- ```
|
|
206
|
-
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
-
|
|
210
|
-
- ### 🎨 **Async Rendering Support**
|
|
211
|
-
- ```js
|
|
212
|
-
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
- const actionTemplate = createActionTemplate(fetchData, {
|
|
216
|
-
- renderLoadedAsync: async () => {
|
|
217
|
-
- const { UserComponent } = await import('./UserComponent.js');
|
|
218
|
-
- return (user) => <UserComponent user={user} />;
|
|
219
|
-
- }
|
|
220
|
-
- });
|
|
221
|
-
- ```
|
|
222
|
-
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
-
|
|
226
|
-
- ### 🛠️ **Debugging & Observability**
|
|
227
|
-
- Built-in debug mode with detailed logging of state transitions, loading coordination,
|
|
228
|
-
- and memory management. Enable with `debug = true`.
|
|
229
|
-
-
|
|
230
|
-
- ## Integration Points
|
|
231
|
-
-
|
|
232
|
-
- - **Signals**: Built on @preact/signals for reactive state management
|
|
233
|
-
- - **Navigation**: Integrates with navigation systems for route-based loading
|
|
234
|
-
- - **Components**: Use `useActionStatus()` hook for component integration
|
|
235
|
-
- - **Memory Management**: Automatic cleanup with WeakMap-based private properties
|
|
236
|
-
-
|
|
237
|
-
- ## Performance Characteristics
|
|
238
|
-
-
|
|
239
|
-
- - **Memory Efficient**: Weak references prevent memory leaks
|
|
240
|
-
- - **Request Deduplication**: Identical requests are automatically merged
|
|
241
|
-
- - **Minimal Re-renders**: Signal-based updates only trigger when data actually changes
|
|
242
|
-
- - **Lazy Loading**: Actions only created when needed, with intelligent memoization
|
|
243
|
-
-
|
|
244
|
-
- This system is particularly well-suited for:
|
|
245
|
-
- - SPAs with complex data fetching requirements
|
|
246
|
-
- - Applications needing fine-grained loading state control
|
|
247
|
-
- - Systems requiring request coordination and deduplication
|
|
248
|
-
- - Progressive loading and preloading scenarios
|
|
249
|
-
- - Master-detail interfaces with dynamic parameter binding
|
|
250
|
-
\*/
|
|
1
|
+
# Actions
|
|
2
|
+
|
|
3
|
+
An action is an async callback plus the state of its last run, held in signals:
|
|
4
|
+
running or not, the error it failed with, the data it produced. Components read
|
|
5
|
+
that state instead of keeping their own.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { createAction } from "@jsenv/navi";
|
|
9
|
+
|
|
10
|
+
const getUser = createAction(async ({ id }, { signal }) => {
|
|
11
|
+
const response = await fetch(`/users/${id}`, { signal });
|
|
12
|
+
return response.json();
|
|
13
|
+
});
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The callback receives `(params, { reason, event, signal, isPrerun })`. `signal`
|
|
17
|
+
is aborted when the run is called off — pass it to `fetch`.
|
|
18
|
+
|
|
19
|
+
`resource()` creates one action per REST callback rather than having you write
|
|
20
|
+
them by hand — see [resource.md](./resource.md).
|
|
21
|
+
|
|
22
|
+
## Params: `bindParams`, and calling the action
|
|
23
|
+
|
|
24
|
+
`createAction` gives one action for the callback; the params make instances of
|
|
25
|
+
it, each with its own state:
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
const getUser123 = getUser.bindParams({ id: 123 });
|
|
29
|
+
await getUser123.run();
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Two `bindParams` with equal params give **the same instance** (deep equality),
|
|
33
|
+
which is what makes state shared between two components asking for the same
|
|
34
|
+
thing, and what deduplicates their requests.
|
|
35
|
+
|
|
36
|
+
An action is callable, and calling it is the short way to bind and run in one
|
|
37
|
+
go:
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
getUser({ id: 123 }); // getUser.bindParams({ id: 123 }).rerun()
|
|
41
|
+
getUser(); // getUser.rerun()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Use it wherever a run is a **gesture** — a click handler, an event, a step in a
|
|
45
|
+
flow — where the params are known at that moment and the run is the point:
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
const deleteGame = (game) => GAME.DELETE({ id: game.id });
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Use `bindParams` when what you need is the **instance**, not the run: to hand it
|
|
52
|
+
to a component that will run it and read its state
|
|
53
|
+
(`<Button action={GAME.DELETE.bindParams({ id })}>`), or to keep a handle on it.
|
|
54
|
+
Note that calling the action `rerun()`s it — the run happens even if that
|
|
55
|
+
instance already holds data, which is what you want from a gesture and not what
|
|
56
|
+
you want from a component asking for data.
|
|
57
|
+
|
|
58
|
+
Params may be signals, and then the action follows them:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
const userAction = getUser.bindParams({ id: userIdSignal });
|
|
62
|
+
// a new params value reruns it
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Running: `run`, `rerun`, `prerun`, `reset`
|
|
66
|
+
|
|
67
|
+
| Method | Does |
|
|
68
|
+
| ---------- | ------------------------------------------------------------------------ |
|
|
69
|
+
| `run()` | Asks for the data. An action already running or completed has it: no-op. |
|
|
70
|
+
| `rerun()` | Runs again whatever state it is in — a refresh, an explicit "check now". |
|
|
71
|
+
| `prerun()` | Same as `run()`, in the background: nothing asked for it on screen yet. |
|
|
72
|
+
| `reset()` | Aborts what is running and puts the action back to idle, data and all. |
|
|
73
|
+
| `abort()` | Calls off the run in flight, keeping the data it had. |
|
|
74
|
+
|
|
75
|
+
## Reading an action
|
|
76
|
+
|
|
77
|
+
```jsx
|
|
78
|
+
const [user] = useAsyncData(userAction);
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`useAsyncData` suspends until the data is there and throws on failure, leaving
|
|
82
|
+
both to the nearest `<Loading>` and `<ErrorBoundary>`; pass `{ loading: true }`
|
|
83
|
+
or `{ error: true }` to handle either inside the component (stale data stays
|
|
84
|
+
available while a rerun is in flight). `useActionStatus(action)` gives the whole
|
|
85
|
+
state at once — `{ idle, loading, completed, aborted, error, data, params }` —
|
|
86
|
+
for a component that needs to look at it rather than render it.
|
|
87
|
+
|
|
88
|
+
Controls take the action itself and wire the rest: `<Button action>` runs it on
|
|
89
|
+
click, shows its loading state, and puts its error where the user can see it.
|
|
90
|
+
That is the reason to pass an action instance rather than an arrow calling the
|
|
91
|
+
callback:
|
|
92
|
+
|
|
93
|
+
```jsx
|
|
94
|
+
// ✓ loading, error and disabled states come from the action
|
|
95
|
+
<Button action={GAME_CANDIDATES.POST.bindParams({ id: game.id, user_id })}>
|
|
96
|
+
Accept
|
|
97
|
+
</Button>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Reruns
|
|
101
|
+
|
|
102
|
+
Actions do not stay stale on their own: a resource's `POST` reruns the
|
|
103
|
+
`GET_MANY` that lists it, a `DELETE` resets the `GET` that loaded the item, and
|
|
104
|
+
`dependencies`/`rerunOn` declare the rest. What re-runs after a write, and what
|
|
105
|
+
stays on screen while it does, is in [list_refresh.md](./list_refresh.md) and
|
|
106
|
+
[resource_dependencies.md](./resource_dependencies.md).
|
|
107
|
+
|
|
108
|
+
`rerunActions(actions)` / `updateActions(actions)` drive several at once (route
|
|
109
|
+
changes do exactly that).
|
|
110
|
+
|
|
111
|
+
## See also
|
|
112
|
+
|
|
113
|
+
- [resource.md](./resource.md) — actions created from REST callbacks, and the
|
|
114
|
+
store behind them
|
|
115
|
+
- [resource_with_params.md](./resource_with_params.md) — `withParams()` and
|
|
116
|
+
isolated rerun scopes
|
|
117
|
+
- [list_refresh.md](./list_refresh.md) — what a write refreshes
|
package/docs/resource.md
CHANGED
|
@@ -25,10 +25,94 @@ Each callback returns the data to upsert into the store:
|
|
|
25
25
|
| GET / POST / PUT / PATCH | the full item object, `{ id, … }` |
|
|
26
26
|
| DELETE | the id, or `{ id }` |
|
|
27
27
|
| GET_MANY / POST_MANY / … | an array of item objects |
|
|
28
|
+
| GET_PAGE | `{ items, start, count }` (below) |
|
|
28
29
|
|
|
29
30
|
Actions are read in components through the action system (`useAsyncData`,
|
|
30
31
|
`<Button action>`, …) — see [actions.md](./actions.md).
|
|
31
32
|
|
|
33
|
+
## `store.upsert()` is not how data enters the store
|
|
34
|
+
|
|
35
|
+
navi writes the store. Declare the resource and its relations, return the shape
|
|
36
|
+
each callback owes (tables above and below), and the write happens. A hand
|
|
37
|
+
written `store.upsert()` in the path of a normal request of the resource is the
|
|
38
|
+
sign that something is missing: a relation that is not declared, or a callback
|
|
39
|
+
that does not return what it should.
|
|
40
|
+
|
|
41
|
+
`store` is exposed for what is **not** a request of the resource, where writing
|
|
42
|
+
it yourself is the point:
|
|
43
|
+
|
|
44
|
+
- seeding it with data that came from elsewhere — state rendered by the server,
|
|
45
|
+
a local cache, a websocket message;
|
|
46
|
+
- absorbing the parent object a relation route answered with: the relation
|
|
47
|
+
callback only writes the relation, so the parent's own fields have nowhere
|
|
48
|
+
else to land (see [When the backend answers a sub-route with the whole
|
|
49
|
+
parent](#when-the-backend-answers-a-sub-route-with-the-whole-parent)).
|
|
50
|
+
|
|
51
|
+
A list that loads its rows page by page is **not** one of those cases — that is
|
|
52
|
+
`GET_PAGE`, right below.
|
|
53
|
+
|
|
54
|
+
## `GET_PAGE`: feeding a list that loads as it scrolls
|
|
55
|
+
|
|
56
|
+
A `<List.Items>` asks for the rows it is about to draw and keeps what it gets.
|
|
57
|
+
`GET_PAGE` is the resource's answer to that question — one slice at a time:
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
const GAME = resource("game", {
|
|
61
|
+
GET: ({ id }) => fetchJson(`/games/${id}`),
|
|
62
|
+
GET_PAGE: ({ radar, start, limit }) =>
|
|
63
|
+
fetchJson(`/radars/${radar.id}/games?start=${start}&limit=${limit}`),
|
|
64
|
+
// { items: [{ id, … }, …], start: 20, count: 137 }
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```jsx
|
|
69
|
+
<List.Items
|
|
70
|
+
count={radar.match_count}
|
|
71
|
+
itemsAction={GAME.GET_PAGE.bindParams({ radar })}
|
|
72
|
+
renderItem={(game) => <GameCard game={game} />}
|
|
73
|
+
/>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The callback receives the bound params merged with the range the list asks for
|
|
77
|
+
(`start`, `end`, `limit`, `before`, `after`, `around`, `count`), and a `signal`
|
|
78
|
+
as second argument — aborted when the list stops wanting those rows. It returns
|
|
79
|
+
a page the way a `Content-Range` does: **`{ items, start, count }`** — these
|
|
80
|
+
rows, at this place, out of that many. `start` may be omitted when the list
|
|
81
|
+
asked for a positive one; `count` defaults to `start + items.length` (a source
|
|
82
|
+
that does not know its total). The items are upserted on their way in, so the
|
|
83
|
+
list draws store items — never copies of the JSON — and a request sent from a
|
|
84
|
+
row is read back on that row.
|
|
85
|
+
|
|
86
|
+
A row that must follow its **own fields** through a write reads them from the
|
|
87
|
+
store rather than from the object it was handed:
|
|
88
|
+
|
|
89
|
+
```jsx
|
|
90
|
+
const GameCard = ({ id }) => {
|
|
91
|
+
const game = GAME.useById(id); // the item as it is now
|
|
92
|
+
…
|
|
93
|
+
};
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
An update replaces the item object (the store holds values, and that is what
|
|
97
|
+
makes a change detectable), so the one the list is holding is the one it was
|
|
98
|
+
given. Relations are not concerned: they are keyed by owner, and a row reading
|
|
99
|
+
`game.candidates` reads the shared collection whatever object carries it.
|
|
100
|
+
|
|
101
|
+
`GET_PAGE` is a **reader, not an action**. It keeps no value and takes no place
|
|
102
|
+
in the rerun graph, which is what makes it usable per slice:
|
|
103
|
+
|
|
104
|
+
- the list already holds the pages it received and glues them back together —
|
|
105
|
+
a second memory holding one of them would fight it;
|
|
106
|
+
- a `POST` invalidating "the collection" would otherwise send every page ever
|
|
107
|
+
loaded back to the network at once.
|
|
108
|
+
|
|
109
|
+
What it does not give is membership: an item that leaves the collection stays on
|
|
110
|
+
screen until the rows are asked for again. Give the screen its own way to ask
|
|
111
|
+
(a refresh gesture, a `key` on the run).
|
|
112
|
+
|
|
113
|
+
It reads a collection, so it lives on the resource itself (or on a
|
|
114
|
+
`withParams()` of it), not on a relation.
|
|
115
|
+
|
|
32
116
|
## Relations: pick one of the four methods
|
|
33
117
|
|
|
34
118
|
A backend sub-route (`/games/:id/candidates`, `/games/:id/candidates/:userId/seen`)
|
|
@@ -258,6 +342,10 @@ The arrow works, but nothing tracks it: no per-row spinner, no error surfaced on
|
|
|
258
342
|
the button that caused it, no deduplication of concurrent runs, no autorerun of
|
|
259
343
|
the actions this mutation should invalidate. See [actions.md](./actions.md).
|
|
260
344
|
|
|
345
|
+
Away from a component, where the run is a gesture and not something to render,
|
|
346
|
+
an action is callable: `GAME.DELETE({ id })` is
|
|
347
|
+
`GAME.DELETE.bindParams({ id }).rerun()`.
|
|
348
|
+
|
|
261
349
|
## See also
|
|
262
350
|
|
|
263
351
|
- [resource_with_params.md](./resource_with_params.md) — `withParams()` and
|
package/docs/scroll.md
CHANGED
|
@@ -229,7 +229,7 @@ is a trade, not a leak.
|
|
|
229
229
|
own body) but never grows on its own. Growing is the caller's decision —
|
|
230
230
|
`expandY`.
|
|
231
231
|
|
|
232
|
-
Pass `
|
|
232
|
+
Pass `travelByKeyboard={false}` when the arrow keys belong to the content (a list
|
|
233
233
|
one walks through, a picker whose slides are steps): otherwise the right arrow
|
|
234
234
|
changes screen mid-reading.
|
|
235
235
|
|