@jsenv/navi 0.29.392 → 0.29.394

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.
@@ -98,8 +98,10 @@ to start when unsure which export solves a problem.
98
98
  action from a component, and before putting a delay on params.
99
99
  - `resource.md` — REST state: `resource()`, the callback contract of each
100
100
  relationship method (not guessable), `GET_RANGE`, a search as the same
101
- `GET_MANY`, `withParams()` scopes and `dependencies`. Read before writing a
102
- resource; never encode a sub-route as an `op` discriminator.
101
+ `GET_MANY`, `withParams()` scopes and `dependencies`, `persist` (the last
102
+ `GET` answer drawn again after a reload). Read before writing a resource, and
103
+ before caching a response in localStorage; never encode a sub-route as an
104
+ `op` discriminator.
103
105
  - `data_states.md` — `data`, `loading` and `error` are three questions; the
104
106
  four combinations of the first two; `loading: true` never suspends; a skeleton
105
107
  is told whether it is loading. Read before drawing a skeleton on `!data` or
@@ -82,7 +82,10 @@ whose params name a row the store already holds — read earlier by a `GET_MANY`
82
82
  a relation, a list — starts on the second line, not the first: the row is drawn
83
83
  and the request goes out behind it. The response replaces it, and a failure
84
84
  lands beside it like any refresh failure. A `GET` of a row the store lacks
85
- still starts on the first line, so what is drawn is never a different row.
85
+ still starts on the first line, so what is drawn is never a different row. A
86
+ row known from the previous page load counts too, when the resource asks for
87
+ it: [`persist`](./resource.md#persist-the-last-answer-drawn-again-after-a-reload)
88
+ puts a reload on the second line as well.
86
89
 
87
90
  **The emptiness test is `data === undefined`, never `loading`.** Reading
88
91
  `loading` as "there is nothing to display" blanks the screen on every refresh —
@@ -431,6 +431,47 @@ param that qualifies a screen is not a place, and one entry per write turns a
431
431
  single back-press into as many as the user moved. A state whose values ARE
432
432
  places says so — see [`history: "push"`](#a-state-whose-values-are-places-history-push).
433
433
 
434
+ #### A value written per frame
435
+
436
+ Every write of the signal reaches the address, synchronously, and browsers
437
+ refuse an address written too often: Safari throws a `SecurityError` past 100
438
+ writes per 10 seconds, and the state then runs ahead of an address that stopped
439
+ following. A value dragged at 60 Hz gets there in under two seconds. Chromium
440
+ drops the writes silently, so a developer working there never sees it; navi
441
+ warns in dev when the budget is reached.
442
+
443
+ **An address is not a recording of a gesture.** What the address records is
444
+ where the value was PUT DOWN, not the path the finger took. So the value one
445
+ drags belongs to the gesture, and the state bound to the url is written when the
446
+ gesture ends:
447
+
448
+ - **A gesture with a release** — a disc turned, a slider dragged: hold the value
449
+ in the component while the pointer is down, and write the signal on release.
450
+ Re-reading the signal meanwhile would pull the control back mid-gesture, so
451
+ the held value is the one drawn.
452
+ - **A movement with no release** — a wheel zooming, a fly-to animation: write
453
+ the signal when the movement has settled (a short idle timer, or the end of
454
+ the animation).
455
+
456
+ When every intermediate value IS a valid address — the centre of a panned map,
457
+ where each frame is a place one could reload into — the state can ask navi to
458
+ debounce the browser's address instead, in milliseconds:
459
+
460
+ ```js
461
+ const eastSignal = stateSignal(0, {
462
+ id: "e",
463
+ type: "number",
464
+ debounceUrl: 200,
465
+ });
466
+ ```
467
+
468
+ The routes, the document url and the renders still follow every write; only the
469
+ browser's copy of the address (`window.location`) waits for the state to stay
470
+ still that long, and is then written with the last value. A push is never
471
+ debounced, and a push or a state written onto the entry flushes what is pending
472
+ first. A back pressed before the write keeps the entry at the last address
473
+ written: the drag in progress, and nothing else.
474
+
434
475
  ## Rendering routes
435
476
 
436
477
  `<Route>` is the only primitive. With `children` it is a container that renders
@@ -195,8 +195,11 @@ one says why is something navi has nothing to decide it with.
195
195
  - **What a screen says on a `NetworkPolicyError`** — "no network" is a fact
196
196
  about the device, "offline mode" a decision, "we are only looking" neither;
197
197
  `error.reason` is there so the screen says the right one.
198
- - **Persisting the store to disk**, so that a reload offline reopens a full
199
- app rather than an empty one.
198
+ - **Which answer is worth keeping across a reload.** A resource declaring
199
+ [`persist`](./resource.md#persist-the-last-answer-drawn-again-after-a-reload)
200
+ has its last `GET` answer back in the store at the first run, and under the
201
+ policy that row answers the `GET`: a reload offline reopens on it. Which
202
+ resource, into which signal, and for which session, is the app's to say.
200
203
  - **A queue of writes to replay** once the network is back. Deliberately not
201
204
  navi's: what a replayed write means (a score entered twice? a seat taken
202
205
  since?) is the app's business.
package/docs/resource.md CHANGED
@@ -34,6 +34,7 @@ Actions are read in components through the action system (`useAsyncData`,
34
34
  looks like ([data_states.md](./data_states.md#data-and-loading-are-independent)).
35
35
 
36
36
  - [`store.upsert()` is not how data enters the store](#storeupsert-is-not-how-data-enters-the-store)
37
+ - [`persist`: the last answer, drawn again after a reload](#persist-the-last-answer-drawn-again-after-a-reload)
37
38
  - [`GET_RANGE`: feeding a list that loads as it scrolls](#get_range-feeding-a-list-that-loads-as-it-scrolls)
38
39
  - [Searching the same collection](#searching-the-same-collection)
39
40
  - [Relations: pick one of the four methods](#relations-pick-one-of-the-four-methods)
@@ -62,7 +63,8 @@ that does not return what it should.
62
63
  it yourself is the point:
63
64
 
64
65
  - seeding it with data that came from elsewhere — state rendered by the server,
65
- a local cache, a websocket message;
66
+ a websocket message (the resource's own last answer is not "elsewhere": that
67
+ is [`persist`](#persist-the-last-answer-drawn-again-after-a-reload), below);
66
68
  - absorbing the parent object a relation route answered with: the relation
67
69
  callback only writes the relation, so the parent's own fields have nowhere
68
70
  else to land (see [When the backend answers a sub-route with the whole
@@ -71,6 +73,98 @@ it yourself is the point:
71
73
  A list that loads its rows a slice at a time is **not** one of those cases — that is
72
74
  `GET_RANGE`, right below.
73
75
 
76
+ ## `persist`: the last answer, drawn again after a reload
77
+
78
+ Some `GET`s are the app. `GET /me` answers who is signed in, their things, what
79
+ the header and the tab bar draw; until it lands, a page that is rendered has
80
+ nothing to draw. On a reload, or when the app is reopened from the home screen,
81
+ that wait is pure loss: the previous answer was on screen a moment ago and is
82
+ almost always still true, and a server waking up makes it last seconds.
83
+
84
+ What is wanted is the second line of the table in
85
+ [data_states.md](./data_states.md#data-and-loading-are-independent) reached on
86
+ a reload: the last known row drawn, the request out behind it, the fresh answer
87
+ replacing it. `persist` names the resource whose answer is worth keeping, and
88
+ the signal that keeps it — a `stateSignal` that persists, the primitive navi
89
+ has for a state that survives a reload:
90
+
91
+ ```js
92
+ const mePersistedSignal = stateSignal(undefined, {
93
+ id: `me@${APP_VERSION}`,
94
+ persists: true,
95
+ type: "object",
96
+ });
97
+ const ME = resource("me", {
98
+ persist: {
99
+ signal: mePersistedSignal,
100
+ when: () => !viewAsSignal.value,
101
+ },
102
+ GET: () => fetchJson(`/me`),
103
+ });
104
+ ME.one("user", USER);
105
+ ME.many("my_games", GAME);
106
+ ```
107
+
108
+ From there navi does the rest:
109
+
110
+ - **the rows `GET` lands with are written into the signal** — one per params
111
+ the `GET` was asked with, so a `GET` without params keeps one row. Where the
112
+ signal keeps its value is the signal's business: `persists: true` is
113
+ localStorage, and a plain `signal()` keeps it for the page's lifetime, which
114
+ is what a test wants;
115
+ - **the first run of the `GET` draws the kept row.** It enters the store and
116
+ becomes the action's provisional value: `data` is set while `loading` is
117
+ `true`, the row is on screen and the request is out behind it. Nothing else
118
+ moves — `runningState` is `running`, the answer replaces the row, an error
119
+ lands in `errorSignal` beside it;
120
+ - **the copy follows the store.** It is written with its relations inline — the
121
+ shape the `GET` callback returns, so it re-enters through the same setters a
122
+ real answer goes through — and rewritten whenever the row or a row it reaches
123
+ through a relation changes: a `PUT` on the item, a list upserting one of its
124
+ children, a game joining `my_games`. A rewrite that changes nothing is
125
+ skipped, so the signal moves only when the copy does;
126
+ - **under a network policy answering reads from the store, the kept row answers
127
+ the `GET`** ([network_policy.md](./network_policy.md)): a reload with no
128
+ network reopens on the last answer rather than on nothing.
129
+
130
+ What stays the app's, because only the app knows:
131
+
132
+ - **the key.** A stored copy is only as good as the code reading it: a deploy
133
+ that changes the shape of the answer must not draw the old one. Put in the
134
+ signal's `id` whatever makes a copy unusable — the deployed version — and a
135
+ new id starts clean.
136
+ - **`when`: is the copy about this session?** A tab viewing as someone else, an
137
+ account being switched — the row in the store is somebody's, and it must not
138
+ be kept for the next person. `when` is read whenever the copy is about to be
139
+ read or written: `false` reads nothing, writes nothing, and empties the
140
+ signal. It is a function so it can read a signal, and it has to: a plain
141
+ variable is only looked at again when the store changes.
142
+ - **the moment, not the state.** Sign-out is not a condition that holds, it is
143
+ something that happens: writing `undefined` into the signal
144
+ (`mePersistedSignal.value = undefined`) forgets the copy at once, and nothing
145
+ is written until a `GET` lands again. The store rows stay — the screen keeps
146
+ what it shows until the app resets the action, as it does today.
147
+ - **what the `GET` feeds beside its return value.** Counts, badges, a feature
148
+ flag set from the response are the app's signals, not the row; they are not
149
+ kept, and the callback does not run on a kept row. Anything the app wants
150
+ back before the answer, it stores itself.
151
+ - **the veil.** A loading screen that waits on the `GET` being `running` stays
152
+ over the drawn row for the whole request. It waits on `data` being set —
153
+ the emptiness test of data_states.md — and the refresh is shown as usual.
154
+
155
+ What the copy is not:
156
+
157
+ - **an answer.** The `GET` goes out every time; the copy is a drawing aid. A
158
+ screen must not read `data` as "the server confirmed": that is `loading`,
159
+ and the two are independent.
160
+ - **a store on disk.** One row per params key, `GET` only — a resource whose
161
+ `GET` is asked for every id would keep every id. It is meant for a singleton
162
+ or a handful of rows.
163
+ - **seeded at declaration.** The row enters the store at the first run of the
164
+ `GET`, once the relations are declared, so that its relation values are
165
+ normalized into their stores as a real answer's are. `ME.store` read before
166
+ that first run is empty.
167
+
74
168
  ## `GET_RANGE`: feeding a list that loads as it scrolls
75
169
 
76
170
  A `<List.Items>` asks for the rows it is about to draw and keeps what it gets.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.392",
3
+ "version": "0.29.394",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {