@jsenv/navi 0.29.416 → 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.
@@ -19,6 +19,7 @@ changes, measure again before trusting a number here.
19
19
  - [Only the start is contested](#only-the-start-is-contested)
20
20
  - [The click is the browser's call as much as ours](#the-click-is-the-browsers-call-as-much-as-ours)
21
21
  - [A tap dropped after a touch drag](#a-tap-dropped-after-a-touch-drag)
22
+ - [A finger is captured to what it touched, inside shadow trees too](#a-finger-is-captured-to-what-it-touched-inside-shadow-trees-too)
22
23
  - [A finger never makes a wheel event](#a-finger-never-makes-a-wheel-event)
23
24
  - [Verifying without a device](#verifying-without-a-device)
24
25
 
@@ -160,6 +161,40 @@ Chromium's `gesture_provider.cc` (`ignore_single_tap_`, reset on the next down)
160
161
  and `tap_suppression_controller.cc` (the tap that stops a fling, 180ms) for the
161
162
  two mechanisms that do NOT explain it.
162
163
 
164
+ ## A finger is captured to what it touched, inside shadow trees too
165
+
166
+ A touch pointer is captured the moment it lands, by the browser, to the element
167
+ it landed on — nobody calls `setPointerCapture`. A mouse and a pen are not.
168
+ That capture is announced like any other, with a `gotpointercapture` just
169
+ before the first pointer event that follows, and a finger resting still sends
170
+ one soon: its contact changes, so `pointermove`s with unchanged coordinates
171
+ come within the first 100ms or so. Nearly every touch press therefore sees a
172
+ `gotpointercapture` it never asked for. Code that reads it as "another gesture
173
+ took this press" gives up on almost every finger.
174
+
175
+ Recognising that capture is where Chrome misleads: **it goes to the deepest
176
+ element under the finger, shadow trees included.** On an `<input>` or a
177
+ `<textarea>` that is the inner editor, inside the field's own shadow tree; in a
178
+ web component, whatever its shadow root holds there. `hasPointerCapture` on
179
+ what the page sees — the field, the host — answers `false` at `pointerdown`,
180
+ after `gotpointercapture`, and for the whole press. Yet the `gotpointercapture`
181
+ reaches the page retargeted to that same element. An open shadow root shows
182
+ both halves: the host answers `false`, the element inside it `true`.
183
+
184
+ So the browser's capture is recognised by where it lands, never asked for: on
185
+ a touch press, a capture announced on the press's own target is the browser's,
186
+ whenever it arrives. Compare both events from the same tree: each is
187
+ retargeted to the shadow host from outside. A gesture that takes the press onto
188
+ that same element cannot be told apart from it, so it has to say so another way.
189
+
190
+ Measured in Chromium 153 through `Input.dispatchTouchEvent`. The capture does
191
+ not depend on the slop, so the desktop path shows it; see
192
+ [Verifying without a device](#verifying-without-a-device). The same `false` on
193
+ a field shows on an Android phone (Chrome 153). Not measured in Safari.
194
+
195
+ Reference: `implicitCaptureHolder` and `takePress` in `@jsenv/dom`'s
196
+ `press_held.js`.
197
+
163
198
  ## A finger never makes a wheel event
164
199
 
165
200
  No mobile browser turns a touch into `wheel` events: those come from a mouse
package/docs/resource.md CHANGED
@@ -300,6 +300,19 @@ store are written, and re-renders when its row is — `useArray` to the whole
300
300
  store. An entry of `useAllByIds` given by a unique key rather than by id can
301
301
  only be found by reading the whole store, and that read follows it too.
302
302
 
303
+ A row counts as written only when something in it changed:
304
+
305
+ - **An answer** — what a callback returns — is compared to the row held in
306
+ depth. Parsed JSON makes every object nested in a row a new one; a
307
+ revalidation returning the same rows keeps every one of them, and their
308
+ readers stay still.
309
+ - **A relation** changes its row when the row points at other children. What
310
+ happens to the children themselves is followed through the relation:
311
+ `game.place.label` read in a render re-renders it when the place is written,
312
+ and the game row stays the one it was.
313
+ - **A value written with `store.upsert()`** is compared by reference: a new
314
+ object is a change, whatever it holds.
315
+
303
316
  ## Relations: pick one of the four methods
304
317
 
305
318
  A backend sub-route (`/games/:id/candidates`, `/games/:id/candidates/:userId/seen`)
@@ -552,9 +552,11 @@ Measured on that page, first frame of the movement: 63 → 45 ms at full speed,
552
552
  What that means for a page and for the application around it:
553
553
 
554
554
  - **It is still mounted for the length of the movement.** Its effects keep
555
- running, and it still reads the address: whatever re-renders when the URL
556
- changes re-renders in it too, hidden (see
557
- [What a transition costs](#what-a-transition-costs)).
555
+ running, and it still reads the address: a component reading the URL, a
556
+ route or a signal directly re-renders in it too, hidden (see
557
+ [What a transition costs](#what-a-transition-costs)). What it reads through
558
+ `useAsyncData` does not: navi leaves a page alone as soon as its container
559
+ would no longer show it.
558
560
  - **It is taken down after the page arriving is up.** A cleanup that puts back
559
561
  what it found — a document title, a class on `<body>`, a value in a shared
560
562
  store — puts a stale value back over the new page's. Such a registration is
@@ -646,10 +648,36 @@ elements):
646
648
  thirty cards: 90 re-renders gone, first frame 58 → 41 ms). A row read with
647
649
  `RESOURCE.useById` follows that row only, so the page arriving loading rows
648
650
  of its own into the same store leaves the cards of the page being left as
649
- they were. What reacts is then the page's own reading of its route's action
650
- — its params, its data and its state all move as the route stops matching —
651
- and that re-renders the page with everything in it (on the list of thirty
652
- cards: about 1,150 component renders, the largest share of the callback).
651
+ they were. What moves last is the page's own reading of its route's action
652
+ — its params, its data and its state all change as the route stops
653
+ matching. `useAsyncData` gives no render to a page its container no longer
654
+ shows: the page is about to be taken down, or hidden until the movement is
655
+ over (on the list of thirty cards: about 450 component renders gone from the
656
+ callback, first frame 10–20 ms sooner at 4× CPU, nothing measurable at full
657
+ speed). A component reading the action's signals itself
658
+ (`action.dataSignal.value`) does not get that, and re-renders in the hidden
659
+ page.
660
+ - **Restyling the page arriving more than once.** Built inside the update
661
+ callback, it is styled as it lands; anything written afterwards that its
662
+ elements inherit, or that re-selects them, restyles all of them again. On the
663
+ list of thirty cards (~7,000 elements) three did, 16–19 ms each at full
664
+ speed: the height of a list group's sticky label, written on the group once
665
+ measured (navi keeps it from being inherited, which took the movement 17 ms
666
+ sooner); the room a fixed bar takes (`--navi-fixed-bar-space-*`, feeding the
667
+ inherited `--navi-safe-area-inset-*`), which changes on the root when a bar
668
+ arrives or leaves with the page; and an app's `:has()` rule on the list
669
+ container over its rows (`.navi_list_container:has(.card) …`), evaluated
670
+ again as the rows land. An attribute the app sets on the container says the
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.
653
681
  - **Re-rendering what did not change, on the press itself.** A component that
654
682
  only wants a yes or a no reads a computed flag, not the signal it is derived
655
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.416",
3
+ "version": "0.29.418",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {
@@ -34,7 +34,7 @@
34
34
  "preact": "^11.0.0-rc.2"
35
35
  },
36
36
  "dependencies": {
37
- "@jsenv/dom": "0.17.83",
37
+ "@jsenv/dom": "0.17.84",
38
38
  "@jsenv/humanize": "1.8.6",
39
39
  "@jsenv/validity": "0.4.5"
40
40
  },