@jsenv/navi 0.29.459 → 0.29.461

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.
@@ -58,7 +58,8 @@ start when unsure which export solves a problem.
58
58
 
59
59
  - `navigation.md` — the position of the user belongs in the URL by default:
60
60
  routes and which branch renders, search params, redirections, the back arrow
61
- (`navBack`), where a navigation lands (a `#id` for an element), a
61
+ (`navBack`), where a navigation lands (a `#id` for an element; a page not
62
+ whole yet holds its position with `useHoldScrollRestoration`), a
62
63
  `SlideContainer` read from the URL, what a layer drawn over the screen may say
63
64
  in its address. Before any routing code.
64
65
  - `route_transitions.md` — a transition states a relation between two pages;
package/docs/actions.md CHANGED
@@ -202,8 +202,9 @@ this one cannot start before the component that draws it exists — a render lat
202
202
  at best, a gesture late in a popup. It is for a parameter chosen inside the
203
203
  component and dying with it. Outside a component,
204
204
  `actionRunEffect(action, () => …)` is that same run declared once — it runs on
205
- the first truthy params, reruns when they change, aborts when they go false —
206
- and the machinery `routeAction` is built on.
205
+ the first truthy params, reruns when they change (unless the action declared
206
+ `keep` and still holds the answer for the new ones), aborts when they go
207
+ false — and the machinery `routeAction` is built on.
207
208
 
208
209
  **Which of the two it is, is decided by the parameter, not by what draws it.**
209
210
  If the parameter is in the address — a path param, a search param bound to a
@@ -222,6 +223,21 @@ decided to write, whatever its parameter, and `{ run: true }` says it — a toke
222
223
  a share button must already hold when pressed, for instance, since the OS share
223
224
  sheet only opens inside the gesture, never after an `await`.
224
225
 
226
+ **A head start is `{ prerun: true }`.** When the params are known before the
227
+ component owning the read exists — a card already says which chapter a book
228
+ has, the book page draws its pages once the book is there — a component
229
+ drawing nothing can send the read ahead, on the instance the owner will read:
230
+
231
+ ```jsx
232
+ // draws nothing: the pages leave with the book instead of after it
233
+ useAsyncData(chapterPagesAction, { prerun: true });
234
+ ```
235
+
236
+ It needs no `<Loading>`, and its failure reaches no boundary: nothing on screen
237
+ asked for a prerun, so the owner's `{ run: true }` asks again, once, when it
238
+ reads it. A failure of the owner's own run is shown, and asked again by a
239
+ `rerun()`, never by a render. A head start still running is the owner's wait.
240
+
225
241
  `{ onLoad }` is what the screen does with the data **once, when it becomes
226
242
  known** — seed the fields someone is about to edit, focus something, remember
227
243
  where a list was:
@@ -68,6 +68,10 @@ Everything the data layer decides then holds for the code:
68
68
  `activeRouteReadsSignal`, which leaves the code out: rerun, it would take a
69
69
  page reading it delegated off the screen for a frame.
70
70
  - **Fetched ahead**, on intent ([below](#ahead-of-the-render-intent)).
71
+ - **A reload lands where the reader was.** The scroll position waits for the
72
+ code and the render that draws the page, rather than being written into the
73
+ pending screen and clamped to its height
74
+ ([navigation.md](./navigation.md#a-page-that-is-not-whole-yet)).
71
75
 
72
76
  ## A component inside a page that stays: who asks for its code
73
77
 
@@ -17,6 +17,7 @@ changes, measure again before trusting a number here.
17
17
  - [The browser decides about 8px in, and does not wait](#the-browser-decides-about-8px-in-and-does-not-wait)
18
18
  - [touch-action stops at the first scroller](#touch-action-stops-at-the-first-scroller)
19
19
  - [Only the start is contested](#only-the-start-is-contested)
20
+ - [A rounded clip makes the scroll wait for the main thread](#a-rounded-clip-makes-the-scroll-wait-for-the-main-thread)
20
21
  - [The click is the browser's call as much as ours](#the-click-is-the-browsers-call-as-much-as-ours)
21
22
  - [A tap dropped after a touch drag](#a-tap-dropped-after-a-touch-drag)
22
23
  - [A finger is captured to what it touched, inside shadow trees too](#a-finger-is-captured-to-what-it-touched-inside-shadow-trees-too)
@@ -122,6 +123,48 @@ it back:
122
123
  A gesture that has won the start keeps the touch. What loses gestures is the
123
124
  first ~8px.
124
125
 
126
+ ## A rounded clip makes the scroll wait for the main thread
127
+
128
+ Before it scrolls under a finger, Chrome's compositor finds the scroller under
129
+ it. Where it cannot tell on its own, it asks the main thread, and the scroll
130
+ starts when the main thread answers — after whatever task is running there.
131
+ Meanwhile the finger keeps moving: the content holds still, then catches up in
132
+ one step. A small scroll is mostly its start, so that is where it is felt. The
133
+ wheel asks the same way on a desktop, where the main thread is rarely busy at
134
+ that moment.
135
+
136
+ Two things make Chrome ask (Chrome 155, mobile emulation; not measured in
137
+ Safari):
138
+
139
+ - **A rounded clip on the scroller or around it**: `overflow` other than
140
+ `visible` with a `border-radius`, or a rounded `clip-path`. Every scroll
141
+ start inside it is asked, wherever the finger lands. A popup that scrolls is
142
+ one: a rounded `overflow: auto` box.
143
+ - **Anything painted over the scroller**, even with `pointer-events: none` and
144
+ square corners (an overlay drawing an edge above the items, say), but only
145
+ where it covers it: a 6px band along the top leaves the rest decided on the
146
+ compositor.
147
+
148
+ The wait costs whatever runs at the touch: 0.1–2ms when nothing does; with a
149
+ render built on the touch at 6× CPU, 7.5ms median and up to 27ms, and a scroll
150
+ started a frame late one time in three. So navi keeps both away from what
151
+ scrolls. A framed `List` does not clip to its rounded corners: its edge stays
152
+ under the items, and one with a background covers the inside of the curve. A
153
+ picker popup whose list scrolls itself does not clip either. And a finger on
154
+ something that opens builds nothing before it has been held ~80ms. Wrapping a
155
+ `List` in a rounded box of your own with `overflow: hidden` brings the wait
156
+ back.
157
+
158
+ A box that stops clipping needs `min-height: 0` if it is a flex or grid item
159
+ holding the scroller: without a clip its minimum height is its content's, so a
160
+ box given a height grows to all its items and the page scrolls instead. Nothing
161
+ waits for the main thread then, because the wrong thing scrolls — check that
162
+ the scroller's own `scrollTop` moved.
163
+
164
+ Reference: `.navi_list_container` in `src/control/list/list.jsx`, the
165
+ `[data-picker-popup]` rules in `src/control/picker/picker_custom.jsx`,
166
+ `TOUCH_WARM_HOLD_MS` in `src/layout/popup_content_mount.js`.
167
+
125
168
  ## The click is the browser's call as much as ours
126
169
 
127
170
  - **Chrome** clicks after a touch that moved up to 8px and never beyond, whether
@@ -221,6 +264,19 @@ configuration: "mobile" })`, then `Input.dispatchMouseEvent` with
221
264
  through the desktop configuration instead: a 15px slop. Anything deciding at
222
265
  10px wins there, and the race that loses swipes on a phone is invisible.
223
266
 
267
+ Where a scroll's start is decided shows only in a trace, never in the page's
268
+ events: `Tracing.start` with `input`, `cc` and `disabled-by-default-cc.debug`.
269
+ After the last `InputHandlerProxy::HandleTouchStart`, a
270
+ `WidgetInputHandlerManager::FindScrollTargetOnMainThread` is the scroller looked
271
+ up on the main thread, the gap between the two
272
+ `InputHandlerProxy::HandleGestureScrollBegin` is how long that took, and a
273
+ `delta_y` larger than one move on the first
274
+ `InputHandler::ScrollLatchedScroller` says the first step merged two moves.
275
+ Not under Playwright: its injected script puts a non-passive `touchstart`
276
+ listener on `window`, which makes every touch blocking and changes the path
277
+ being measured. Start Chrome by hand (`--headless=new
278
+ --remote-debugging-port`) and drive the page's websocket directly.
279
+
224
280
  **Safari**: the iOS simulator, driven by `safaridriver`, which turns W3C touch
225
281
  actions into real UIKit touches — Safari's own pan recognizers compete for
226
282
  them exactly as on a phone:
@@ -16,6 +16,7 @@ linking to them, and turning them into tabs.
16
16
  - [The back arrow: `navBack`](#the-back-arrow-navback)
17
17
  - [Tabs that travel: `RouteTravel`](#tabs-that-travel-routetravel)
18
18
  - [Where a navigation lands: the scroll](#where-a-navigation-lands-the-scroll)
19
+ - [A page that is not whole yet](#a-page-that-is-not-whole-yet)
19
20
  - [Landing on an element: the fragment](#landing-on-an-element-the-fragment)
20
21
  - [Creating something, then editing it](#creating-something-then-editing-it)
21
22
  - [Tabs that are not routes](#tabs-that-are-not-routes)
@@ -481,7 +482,8 @@ Five cases, and they are not a policy to configure but five different facts:
481
482
  - **Replacing the entry** (`<Link replace>`, `route.redirectTo()`, a param
482
483
  settling) moves nothing: the same place said differently — except in a row of
483
484
  tabs, below.
484
- - **A reload** lands where one was, as it would have without navi.
485
+ - **A reload** lands where one was, as it would have without navi, once the
486
+ page is there ([below](#a-page-that-is-not-whole-yet)).
485
487
 
486
488
  Where the browser exposes its stack (the Navigation API), **a `<Link>` whose
487
489
  destination is the entry right next to the current one becomes a real
@@ -490,6 +492,47 @@ A, B, A, B… growth, and the scroll comes back — and one step forward too. On
490
492
  towards entries of this document, and never when the push carries explicit
491
493
  state. An arrow that must ALWAYS behave as a back is `navBack()`.
492
494
 
495
+ ### A page that is not whole yet
496
+
497
+ A position is put back into whatever the page shows at that moment. When that is
498
+ a stand-in — a pending screen, a `<Loading>` fallback, a list of three skeletons
499
+ — the document is shorter than the page the position was taken on, the offset is
500
+ clamped to it, and the reader lands at the top of a page that arrives a moment
501
+ later.
502
+
503
+ **After a reload, navi waits for the route actions** that the first render had
504
+ nothing to draw for: the page's code arriving on its own
505
+ ([dynamic_import.md](./dynamic_import.md)), a read the previous document did not
506
+ keep ([`keepPageOnScreen`](./resource.md#keeppageonscreen-the-page-on-screen-drawn-again-by-the-next-document) draws a kept one at once, and that one
507
+ is not waited for). The position is put back once they have answered and the
508
+ page has rendered. When one of them fails, nothing is written: the screen saying
509
+ so is not the page the position was taken on, and the position stays for the
510
+ reload that brings it back. What it costs: a slow read that every page asks for
511
+ delays the position of every page, since nothing tells navi which reads set the
512
+ height.
513
+
514
+ **What navi cannot see, the page says**: a read it starts itself
515
+ (`useAsyncData(…, { run: true })`), content whose height arrives late. While
516
+ `useHoldScrollRestoration(held)` is given `true`, the position of a reload or of
517
+ a back/forward waits; it is put back after the render that turns it `false`:
518
+
519
+ ```jsx
520
+ const [games] = useAsyncData(GAMES, { loading: true, run: true });
521
+ useHoldScrollRestoration(games === undefined);
522
+ ```
523
+
524
+ A read that fails leaves it `true`, and nothing is written, for the same reason
525
+ as above. A row of tabs is not held: it gives a tab its position as it travels,
526
+ and measures right after (see [below](#a-row-of-tabs-where-a-replace-is-an-arrival)).
527
+
528
+ **The reader taking over cancels it.** A finger, a click, a wheel or a key
529
+ before the position is written, and it is not written: moved afterwards, they
530
+ would lose whatever they had started on. Going elsewhere cancels it too.
531
+
532
+ A `<List>` that opens on an item is the opposite case, and needs nothing: it
533
+ places the document itself, by measuring the item, and the url's offset is not
534
+ put back over it ([scroll.md](./scroll.md#where-the-list-opens-and-where-it-is)).
535
+
493
536
  ### Landing on an element: the fragment
494
537
 
495
538
  A link meant to bring one element under the reader's eyes — a notification
@@ -564,9 +607,10 @@ the viewport may scroll, and a row inside a scroller of its own leaves that
564
607
  offset alone. An `overflow: hidden` on any ancestor is enough to take the row out
565
608
  of the document's scrollport — it holds an offset even without a scrollbar, where
566
609
  `overflow: clip` holds none — so it is the first thing to look for when a row
567
- does not give positions back. And a page whose height depends on something still
568
- loading is not tall enough when its position is put back: only the page knows
569
- when it is whole. What navi itself builds after the first frame
610
+ does not give positions back. And a tab whose height depends on something still
611
+ loading is not tall enough when its position is put back: the row does not wait
612
+ for it ([A page that is not whole yet](#a-page-that-is-not-whole-yet) is for a
613
+ reload and a back). What navi itself builds after the first frame
570
614
  (`<Box mount="after-paint">`, see
571
615
  [scroll.md](./scroll.md#many-sections-box-mountafter-paint)) is not such a
572
616
  page: the part the position shows is built before it is written.
package/docs/resource.md CHANGED
@@ -613,6 +613,27 @@ is inferred — a relation that really is a sub-route of the parent is modelled
613
613
  with the relationship methods ([above](#relations-and-autorerun)), never
614
614
  declared as a dependency.
615
615
 
616
+ When only some writes change what the reads show — a count of videos moves
617
+ when one is created or deleted, not when a chapter is rated — name them with
618
+ `on`:
619
+
620
+ ```js
621
+ const SUMMARY = resource("summary", {
622
+ dependencies: [
623
+ BOOK, // every write
624
+ { resource: VIDEO, on: ["PATCH_MANY", "DELETE"] },
625
+ { resource: VIDEO_FILE, on: ["POST", "DELETE"] },
626
+ ],
627
+ GET: …,
628
+ });
629
+ ```
630
+
631
+ `on` names callbacks, not verbs: `PATCH` does not cover `PATCH_MANY` the way it
632
+ does in `rerunOn`, so each write keeps its own answer. A name that is not a
633
+ write, or that the resource does not define, throws at declaration — a
634
+ dependency that never fires leaves a count on its old value with nothing to say
635
+ so.
636
+
616
637
  ## A function calling the verb, or the instance
617
638
 
618
639
  Both forms are real, and what separates them is narrower than it looks:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.459",
3
+ "version": "0.29.461",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {