@jsenv/navi 0.29.417 → 0.29.418

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.
@@ -669,6 +669,15 @@ elements):
669
669
  container over its rows (`.navi_list_container:has(.card) …`), evaluated
670
670
  again as the rows land. An attribute the app sets on the container says the
671
671
  same thing as that `:has()`, for nothing.
672
+ - **Building rows the first picture does not show.** A list coming back
673
+ where it was draws, in the update callback, the rows that were on screen
674
+ then — the `visibleCount` of the position it is handed back (see
675
+ [scroll.md](./scroll.md#where-the-list-opens-and-where-it-is)) — and the
676
+ rest of its window once the movement has started. The movement does not wait
677
+ for them: it plays on the compositor, which keeps presenting frames while the
678
+ main thread builds the rows. On the list of thirty cards, handed its whole
679
+ position back: the movement starts 70–85 ms after the click instead of
680
+ 185–270.
672
681
  - **Re-rendering what did not change, on the press itself.** A component that
673
682
  only wants a yes or a no reads a computed flag, not the signal it is derived
674
683
  from: a list whose rows each read the count of runs in flight re-rendered
package/docs/scroll.md CHANGED
@@ -157,13 +157,25 @@ picked up then. When it is still not the box you mean, say so explicitly with
157
157
  - **`scrolled`** is the controlled form of the same thing — same pair as
158
158
  `open`/`defaultOpen` elsewhere in navi. The list goes back there every time it
159
159
  changes, even after the user scrolled.
160
- - **`onScrolledChange`** gives `{id, index, offset}` as the user scrolls.
160
+ - **`onScrolledChange`** gives `{id, index, offset, visibleCount}` as the user
161
+ scrolls. `visibleCount` is how many rows were on screen from that one on:
162
+ handed back whole (`defaultScrolled={position}`), the position also says what
163
+ to draw first — the list's first commit draws those rows (and the one above,
164
+ when it stood partly in view) and the rest of its window once painted. An app
165
+ keeping only the `id` (in its url, say) keeps the place and drops that: the
166
+ list then draws its whole window before its first picture.
161
167
  - A list with an `id` **comes back where it was** when its screen is left and
162
168
  come back to, the way the page does: the position is kept under the list's
163
169
  `id` and the page's url, for the session, and put back by name the way
164
- `defaultScrolled={{id, offset}}` is. A fresh arrival at the page still opens
165
- at `defaultScrolled`. **`scrollResetOnNavigation`** opts out: the list then
170
+ `defaultScrolled={{id, offset}}` is — `visibleCount` included, and kept even
171
+ when the list was never scrolled. A fresh arrival at the page still opens at
172
+ `defaultScrolled`. **`scrollResetOnNavigation`** opts out: the list then
166
173
  opens the same way every time.
174
+ - A list scrolling the document and opening on a row **places the document
175
+ itself**, on the row: the offset the page kept for its url is not put back
176
+ over it. Those are pixels of the page as it was drawn, and the rows the
177
+ window holds off screen are fillers of an estimated height — the same pixels
178
+ now fall on other rows.
167
179
 
168
180
  ### A search moves the list, and gives it back
169
181
 
@@ -390,10 +402,16 @@ scrolls: a `maxHeight` on a list that IS the scrolling area,
390
402
  ### The first paint of a list that opens in a click
391
403
 
392
404
  A popup's content is built in the click that opens it (see `popup_open.md`),
393
- and rows below the fold cost the same there as rows on screen. `renderBudget`
405
+ and rows below the fold cost the same there as rows on screen. A page coming
406
+ back in a route transition is the same case: it is built in the transition's
407
+ update callback, and every row drawn there delays the movement. `renderBudget`
394
408
  takes `{ initial, after }` for exactly this: `initial` rows in the commit the
395
409
  browser paints first — what a phone screen shows, plus a few — and `after` from
396
- the paint on. The switch waits for the paint itself, not for an effect: preact
410
+ the paint on. They are counted from the row the list opens on: the first
411
+ picture of a list opening on a row shows that row and what is below it, and
412
+ the window is centered on it only from the paint on. A position handed back
413
+ with its `visibleCount` sizes that first window itself, whatever `initial`
414
+ guessed. The switch waits for the paint itself, not for an effect: preact
397
415
  runs a component's pending effects early when that component renders again,
398
416
  and something always re-renders before a popup has painted. Do not rebuild
399
417
  this by hand with a `useEffect` that widens a slice: that effect is one preact
@@ -32,8 +32,6 @@ was missed.
32
32
 
33
33
  ## The callback that only remembers
34
34
 
35
- The same mistake, in four places:
36
-
37
35
  ```jsx
38
36
  // ✗ the app is told where the slides went, and writes it back down
39
37
  <SlideContainer
@@ -45,18 +43,6 @@ The same mistake, in four places:
45
43
  // ✓
46
44
  <SlideContainer signal={stepSignal} />
47
45
 
48
- // ✗ the control is told what it now holds, and the app writes it back down
49
- <Picker value={side} uiAction={(value) => (sideSignal.value = value)} />
50
- // ✓
51
- <Picker signal={sideSignal} />
52
-
53
- // ✗ a button proposing a value to a control, straight into the signal
54
- <Button onClick={() => (durationSignal.value = ninetyMinutes)}>1h30</Button>
55
- // ✓
56
- <Button command="--navi-update" commandFor="duration" value={ninetyMinutes}>
57
- 1h30
58
- </Button>
59
-
60
46
  // ✗ the popup is told it opened, and the app writes it back down
61
47
  <Dialog
62
48
  open={isOpen}
@@ -69,7 +55,8 @@ The same mistake, in four places:
69
55
 
70
56
  In each pair the second line is not a tidier spelling of the first: it is the
71
57
  binding the component already implements, and the first is a partial
72
- re-implementation of it.
58
+ re-implementation of it. The same pair for a control's value, and for a button
59
+ proposing one, is in [control_value.md](./control_value.md).
73
60
 
74
61
  ## Why it is not merely shorter
75
62
 
@@ -77,22 +64,20 @@ A binding is written by **everything that moves the state**, not only by the
77
64
  gesture the caller thought of. A `SlideContainer`'s signal is written by a drag,
78
65
  by an arrow key, by a `--navi-right` command sent from a chevron outside the
79
66
  box, by the address when the signal is a route's own, by a step refusing to be
80
- left. A control's
81
- signal is written by the user typing, by a group placing its children, by a
82
- picker committing on close, by a `--navi-update` from a shortcut button. A
83
- hand-written callback catches the cases it was written for and silently misses
84
- the rest — and the drift only shows on the screen where a second gesture was
85
- added.
67
+ left. A control's signal is written by the user typing, by a group placing its
68
+ children, by a picker filling its popup or putting back what it held on Escape,
69
+ by a `--navi-update` from a shortcut button. A hand-written callback catches the
70
+ cases it was written for and silently misses the rest — and the drift only shows
71
+ on the screen where a second gesture was added.
86
72
 
87
73
  It reads in the other direction too, which no callback gives at all: writing the
88
74
  signal MOVES the component. The wheels roll, the slides travel, the popup opens.
89
75
  That is what replaces the `key` or the `value` prop pushed down to force a
90
76
  component into a state.
91
77
 
92
- And it goes through navi's interaction gate. A `command` on a read-only,
93
- disabled or busy control is refused and says why; an `onClick` doing the same
94
- write is plain DOM, fires on a greyed-out button and rewrites a value nobody was
95
- allowed to change.
78
+ And a `command` goes through navi's interaction gate, which an `onClick` writing
79
+ the same state does not (see
80
+ [control_value.md](./control_value.md#a-button-that-proposes-a-value-is---navi-update)).
96
81
 
97
82
  ## What is left for the callback
98
83
 
@@ -113,13 +98,10 @@ Binding the state does not remove the callback — it removes one job from it:
113
98
  ## The callback also fires for the state's own changes
114
99
 
115
100
  A bound container walks to whatever its signal holds, and tells the callback
116
- about that walk — `cause: "state"`, `event: null`. It is the only piece that
117
- knows the change was not a press, and saying so is its job.
118
-
119
- The trap is on the other side. A callback that writes somewhere whose answer
120
- flows back INTO the signal — a server whose response is upserted into the store,
121
- a `useSignalSync` on the row that re-renders from it — makes every answer a
122
- change, and every change a write:
101
+ about that walk — `cause: "state"`, `event: null`. The trap is a callback that
102
+ writes somewhere whose answer flows back INTO the signal — a server whose
103
+ response is upserted into the store, a `useSignalSync` on the row that
104
+ re-renders from it:
123
105
 
124
106
  ```jsx
125
107
  // ✗ the answer of the write feeds the signal the write came from
@@ -130,49 +112,29 @@ const styleSignal = useSignalSync(user.share_image_style);
130
112
  />;
131
113
  ```
132
114
 
133
- With one write at a time nothing shows: the answer carries the value the signal
134
- already holds, so the model does not change. It takes two writes in flight — two
135
- notches 200ms apart, a 300ms round trip:
136
-
137
- ```
138
- t=0 notch → B signal=B PUT B ──────────────┐
139
- t=200 notch → A signal=A PUT A ────────────┐ │
140
- t=300 B answers, store=B, signal=B, the box │ │
141
- walks to B, onCurrentChange(B, "state"), │ │
142
- save(B) → PUT B ───────────────────────┐ │ │
143
- t=500 A answers … save(A) → PUT A │ │ │
144
- t=600 B answers … save(B) → PUT B │ │ │
145
- …
146
- ```
147
-
148
- Nothing was reordered — it is enough that an answer lands after a newer write
149
- was sent. Every answer is then the answer to a request the user has already
150
- moved past, the container obeys the model, the callback saves what it was just
151
- told, and that write's answer starts the next turn.
115
+ One write at a time shows nothing: the answer carries the value the signal
116
+ already holds. Two in flight — two notches in quick succession — and every
117
+ answer is to a request the user has already moved past: the container walks to
118
+ it, the callback saves it, and that write's answer starts the next turn. The
119
+ loop never closes.
152
120
 
153
121
  Decide what the signal IS:
154
122
 
155
123
  - **The signal is the choice, the server only confirms it.** Seed it once with
156
- `useSignal(model)` and never sync it. A late answer changes the store, not the
157
- picture. The cost: a write that fails is not undone on screen unless the
158
- callback undoes it. Right for a setting saved on the gesture, with no "Save"
159
- button.
124
+ `useSignal(model)` and never sync it: a late answer changes the store, not the
125
+ picture, and a write that fails is not undone on screen unless the callback
126
+ undoes it. Right for a setting saved on the gesture, with no "Save" button.
127
+ That is a `SlideContainer`'s shape; a control says it with
128
+ `defaultValue={model}` and an `action` (see
129
+ [control_value.md](./control_value.md#a-defaultvalue-follows-what-it-was-read-from)).
160
130
  - **The model is the truth, the control only proposes.** Keep `useSignalSync`
161
- and ignore `cause: "state"` in the callback — a change the state asked for is
162
- not a choice to save. The cost: a late answer still pulls the picture back for
163
- one round trip. Right when another client or a background refresh may change
164
- the row while the control is on screen.
131
+ and ignore `cause: "state"` — a change the state asked for is not a choice to
132
+ save. A late answer still pulls the picture back for one round trip. Right
133
+ when another client or a background refresh may change the row while it is on
134
+ screen.
165
135
  - **Never two writes in flight for one field.** Debounce the write, or cancel
166
- the one before it (`createRequestCanceller`), so no answer can be older than
167
- the latest intent. Right when the write is the expensive part.
168
-
169
- Measured on the same screen, the same two notches, a 300ms round trip:
170
-
171
- | what the signal is | writes sent | slide changes |
172
- | ----------------------------------------- | --------------- | ------------- |
173
- | `useSignalSync` + save every change | 39, still going | 39 |
174
- | `useSignal`, seeded once | 2 | 2 |
175
- | `useSignalSync` + ignore `cause: "state"` | 2 | 4 |
136
+ the one before it (`createRequestCanceller`), so no answer is older than the
137
+ latest intent. Right when the write is the expensive part.
176
138
 
177
139
  ## What binds itself
178
140
 
@@ -189,13 +151,10 @@ Measured on the same screen, the same two notches, a 300ms round trip:
189
151
 
190
152
  "Who writes the score" has three answers — a player of the game, the organizer
191
153
  alone, nobody — and only two of them are rows: "nobody" is a switch under the
192
- list. So the state holds a value the list cannot show, and binding the list to
193
- that state leaves it showing nothing whenever the switch is on.
194
-
195
- The reflex is to fall back on `value` + `uiAction` and remember the row by hand.
196
- The finding is elsewhere: **the list is not asking that question**. It asks
197
- "which of these two", the switch asks "anybody at all", and the answer the
198
- screen sends is made of both:
154
+ list. Bound to the state holding all three, the list shows nothing whenever the
155
+ switch is on. The fix is not `value` + `uiAction`: **the list is not asking that
156
+ question**. It asks "which of these two", the switch asks "anybody at all", and
157
+ the answer the screen sends is made of both:
199
158
 
200
159
  ```jsx
201
160
  const scorerSignal = signal("player"); // what the list asks: one of ITS rows
@@ -210,13 +169,10 @@ const whoWritesSignal = computed(() =>
210
169
  ```
211
170
 
212
171
  Each control binds the state it is about, and the value that spans them is
213
- computed from those — never held a second time. It also gives back for free what
214
- the hand-written version had to arrange: the row chosen before the switch went
215
- on is still chosen when it goes off, because nothing ever cleared it.
216
-
217
- The rule underneath: a control shows values, so the state it binds is the one
218
- whose values it can show. A state holding more than that is more than one
219
- question.
172
+ computed from those — never held a second time (the row chosen before the switch
173
+ went on is still chosen when it goes off: nothing cleared it). A control binds
174
+ the state whose values it can show; a state holding more than that is more than
175
+ one question.
220
176
 
221
177
  ## When there is no binding
222
178
 
@@ -235,7 +191,6 @@ the next screen copies, and by then it is the app's pattern.
235
191
 
236
192
  - [control_value.md](./control_value.md) — who holds a control's value, and what
237
193
  a bound signal holds control by control
238
- - [actions.md](./actions.md#action-or-uiaction) — `action` or `uiAction`
239
194
  - [popup_open.md](./popup_open.md) — who owns the fact that a popup is open
240
195
  - [interactions.md](./interactions.md) — a gesture is named, not read by hand:
241
196
  the same rule, one layer down
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.417",
3
+ "version": "0.29.418",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {