@jsenv/navi 0.29.225 → 0.29.227

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.
@@ -115,12 +115,14 @@ to start when unsure which export solves a problem.
115
115
  - `offline.md` — `setNetworkPolicy`: answer from the store, ask nothing, refuse
116
116
  writes politely. Read before caching responses in the app.
117
117
  - `dynamic_import.md` — a screen's code arrives like its data: a page's
118
- import is a `routeAction`, a component reads its own with
119
- `useAsyncData(() => import(…))`, so `<Loading>`, `<ErrorBoundary>` and
120
- `rerun` cover it; links prerun on intent what asks nothing of the address; a
121
- transition photographs the fallback; a chunk's `import.meta.css`; a chunk
122
- that does not come is never a bug; why not preact's `lazy`. Read before an
123
- `import()` in a component or a page, and before `prefetch={false}`.
118
+ import is a `routeAction` read with `loading: true` (a branch, not a
119
+ `<Loading>`), a component's is started by what asks — `actionRunEffect` on a
120
+ state, `useAsyncData(() => import(…))` only as the fallback;
121
+ links prerun on intent what asks nothing of the address; a transition
122
+ photographs the pending screen; a chunk's `import.meta.css`; a chunk that
123
+ does not come is never a bug and never comes twice in one document — offer
124
+ `reload()`; why not preact's `lazy`. Read before an `import()` in a component
125
+ or a page, and before `prefetch={false}`.
124
126
 
125
127
  ### Controls and forms
126
128
 
@@ -7,14 +7,18 @@ the network is gone or a deploy happened in between — and nothing on screen
7
7
  should have to know whether what it waits for is bytes of data or bytes of
8
8
  code.
9
9
 
10
- So navi has nothing lazy-shaped: **the import is an action**, read by the hook
11
- a screen already reads its data with. Two shapes, decided by who asks.
10
+ So navi has nothing lazy-shaped: **the import is an action**, started by what
11
+ asks for it — the address through `routeAction`, a state through
12
+ `actionRunEffect` — and read by the hook a screen already reads its data
13
+ with. Running it from the hook itself is the fallback, as for data.
12
14
 
13
15
  ## A page: its code is a route action
14
16
 
15
17
  The address asks for the page's data through a route action; it asks for the
16
18
  page's code the same way, and the two are in flight together from the url
17
- change:
19
+ change. The page component in the entry reads the code like data — with
20
+ `loading: true` and `error: true`, drawing the page's own pending screen and
21
+ its own unavailable screen where the page will stand:
18
22
 
19
23
  ```jsx
20
24
  const GAME_PAGE_ACTION = routeAction(GAME_ROUTE, GAME.GET, () => ({
@@ -25,75 +29,111 @@ const GAME_PAGE_CODE = routeAction(GAME_ROUTE, () =>
25
29
  );
26
30
 
27
31
  const GamePage = () => {
28
- const [Page] = useAsyncData(GAME_PAGE_CODE);
32
+ const [Page, loading, error] = useAsyncData(GAME_PAGE_CODE, {
33
+ loading: true,
34
+ error: true,
35
+ });
36
+ if (loading) {
37
+ return <GameScreenPending />;
38
+ }
39
+ if (error) {
40
+ return <GameScreenUnavailable />;
41
+ }
29
42
  return <Page />;
30
43
  };
31
44
 
32
- <Route>
33
- <ErrorBoundary fallback={PageError}>
34
- <Loading fallback={<PageSkeleton />}>
35
- <Route route={GAME_ROUTE} element={GamePage} />
36
- </Loading>
37
- </ErrorBoundary>
38
- </Route>;
45
+ <Route route={GAME_ROUTE} element={GamePage} />;
39
46
  ```
40
47
 
41
- Everything the data layer decides then holds for the code, with nothing more
42
- to write:
43
-
44
- - **One wait.** The page suspends into the nearest `<Loading>` on whichever of
45
- the two arrives last; the boundary is told it is loading and draws its
46
- fallback; the routing is busy until both are there.
47
- - **One failure.** A rejected import fails the run: the nearest
48
- `<ErrorBoundary>` shows it, `error.action.rerun()` is the way back, going
49
- somewhere else leaves it behind.
48
+ Why a branch rather than a `<Loading>` around it: suspending takes the
49
+ subtree away and puts the boundary's fallback in its place, then mounts the
50
+ page again — a cost worth paying when a page already on screen re-reads its
51
+ data (see [data_states.md](./data_states.md)), and worth nothing here, where
52
+ the page is not there yet and has nothing to keep. `loading: true` never
53
+ suspends: one component, no wrapper, the pending screen is the page's own
54
+ shape, and the failure sits beside it (what it offers: below, "When the code
55
+ does not come"). What is delegated stays delegated — the page's data, read inside
56
+ `game_page.jsx` with `useAsyncData(GAME_PAGE_ACTION)`, still waits in the
57
+ `<Loading>` written between the routes, as [navigation.md](./navigation.md)
58
+ says.
59
+
60
+ Everything the data layer decides then holds for the code:
61
+
62
+ - **One failure.** A rejected import fails the run like a failed read; drawn
63
+ by the branch above, or by the nearest `<ErrorBoundary>` when the page reads
64
+ its code without `error: true`.
50
65
  - **Fetched once.** A route action left behind is aborted, not reset, and a
51
66
  completed one is not run again: coming back to the page renders
52
67
  synchronously, only the data is asked for per params.
53
68
  - **Fetched ahead.** A route action that asks nothing of the address is prerun
54
69
  on intent (below).
70
+ - **Counted.** The routing is busy until the code is there, like for the
71
+ data.
55
72
 
56
73
  The page module itself is ordinary — it reads its data with
57
74
  `useAsyncData(GAME_PAGE_ACTION)` like any page — and imports the routes module
58
75
  back for it; a dynamic import is not a load-order cycle.
59
76
 
60
- ## A component inside a page that stays: it reads its own code
77
+ ## A component inside a page that stays: who asks for its code
61
78
 
62
79
  A plan drawn on demand inside a page that stays knows where it is drawn and
63
- what stands there — a frame, a line, a button. `useAsyncData` takes a
64
- function: the request this component owns, made into an action once on the
65
- first render and kept for the life of the instance, its answer as the data.
66
- The wait and the failure are then drawn where the frame is, and no boundary is
67
- involved:
68
-
69
- ```jsx
70
- const PlanSection = () => {
71
- const [Plan, loading, error] = useAsyncData(
72
- () => import("./plan.jsx").then((m) => m.Plan),
73
- { loading: true, error: true },
74
- );
75
- return (
76
- <PlanFrame>
77
- {loading ? <Text>…</Text> : null}
78
- {error ? (
79
- <Button action={() => error.action.rerun()}>Réessayer</Button>
80
- ) : null}
81
- {Plan ? <Plan /> : null}
82
- </PlanFrame>
83
- );
84
- };
85
- ```
86
-
87
- Delegating instead (`useAsyncData(fn)` alone, under a `<Loading>` around the
88
- frame) is the same hook with the same rule as for data: **the `<Loading>` goes
89
- where the fallback should land.** A boundary around the whole router takes the
90
- router away while a page loads — top bar, tabs and all.
91
-
92
- This shape starts at the render and is per instance: a component fetched this
93
- way in three places asks three times (the browser fetches the module once),
94
- and coming back to it suspends for the microtask the import takes to answer
95
- from the module map. A page wants the route action shape; a section inside a
96
- page rarely notices.
80
+ what stands there — a frame, a line, a button. The wait and the failure are
81
+ drawn where the frame is, with `loading: true` and `error: true`, the same way
82
+ as for a page. What differs is **who starts the import**, and the order of
83
+ preference is the one `useAsyncData` states for data: **the run belongs to
84
+ what asks, not to the render.**
85
+
86
+ 1. **The address asks** → `routeAction`, above. A section of a page that has
87
+ an address (`/places/:id/plan`) is a page.
88
+ 2. **A state asks** → `actionRunEffect`. A plan drawn once a mode is entered,
89
+ a tab chosen, a popup opened: the signal that says so is what starts the
90
+ import, declared once at module scope — one action for every instance,
91
+ started by the state change rather than by the render that follows it,
92
+ and holding its answer across mounts:
93
+
94
+ ```jsx
95
+ const PLAN_CODE = actionRunEffect(
96
+ () => import("./plan.jsx").then((m) => m.Plan),
97
+ () => planShownSignal.value,
98
+ );
99
+
100
+ const PlanSection = () => {
101
+ const [Plan, loading, error] = useAsyncData(PLAN_CODE, {
102
+ loading: true,
103
+ error: true,
104
+ });
105
+ return (
106
+ <PlanFrame>
107
+ {loading ? <Text>…</Text> : null}
108
+ {error ? <Button action={() => reload()}>Recharger</Button> : null}
109
+ {Plan ? <Plan /> : null}
110
+ </PlanFrame>
111
+ );
112
+ };
113
+ ```
114
+
115
+ 3. **Nothing above the component asks** → `useAsyncData` with a function, the
116
+ fallback: the component's own existence is the only thing that asks, so the
117
+ request is its own, made into an action once on the first render and kept
118
+ for the life of the instance.
119
+
120
+ ```jsx
121
+ const [Plan, loading, error] = useAsyncData(
122
+ () => import("./plan.jsx").then((m) => m.Plan),
123
+ { loading: true, error: true },
124
+ );
125
+ ```
126
+
127
+ It starts one render late, and per instance: a component fetched this way
128
+ in three places asks three times (the browser fetches the module once), and
129
+ coming back to it suspends for the microtask the import takes to answer
130
+ from the module map. Reach for it when there is genuinely no state to read
131
+ the request off — not to save declaring one.
132
+
133
+ Delegating instead (the hook without `loading: true`, under a `<Loading>`
134
+ around the frame) follows the rule for data: **the `<Loading>` goes where the
135
+ fallback should land.** A boundary around the whole router takes the router
136
+ away while a page loads — top bar, tabs and all.
97
137
 
98
138
  Nothing has to be kept out of a subtree loaded this way: the suspension
99
139
  happens before the module's components exist, so nothing inside it is parked
@@ -111,8 +151,9 @@ prefetched: a prefetch has no address, the id may still be chosen.
111
151
  `routeAction(route, action, undefined, { prefetch: false })` keeps a
112
152
  param-less read that is not worth a hover out of it.
113
153
 
114
- A prefetch that fails is forgotten, not reported: nothing on screen asked for
115
- it. The arrival asks again and shows its own failure.
154
+ A prefetch that fails is not reported: nothing on screen asked for it. The
155
+ failure stays on the action, where the arrival asks again — a failed action is
156
+ run, only a running or completed one is left alone — and shows its own.
116
157
 
117
158
  The same from code: `route.preload()` for a route something is about to
118
159
  navigate to, `preloadUrl(url)` for an address. A press that computes its
@@ -120,9 +161,10 @@ destination has nothing to preload — the navigation itself asks.
120
161
 
121
162
  ## What a transition photographs
122
163
 
123
- A route element still waiting is announced as rendered by its boundary, so a
124
- route transition or a `RouteTravel` takes its picture of the **fallback**, code
125
- and data alike. Navi does not hold a navigation for a chunk: the honest answer
164
+ A route element still waiting is what the container rendered — its pending
165
+ screen, or the fallback of the boundary it suspended into — so a route
166
+ transition or a `RouteTravel` takes its picture of **that**, code and data
167
+ alike. Navi does not hold a navigation for a chunk: the honest answer
126
168
  to "a chunk is a hundred milliseconds" is the prefetch above — the code is
127
169
  there before the press — and a skeleton that is the page's own shape is what
128
170
  gets photographed when it is not.
@@ -154,15 +196,22 @@ is a 404; or the network is gone. The browser says both the same way — a
154
196
  `TypeError` with no status — and neither is a bug of the page: there is
155
197
  nothing in the app's code to point at.
156
198
 
157
- The component reading its own code says it where it stands, as above. For a
158
- page, the failure reaches the boundary like a failed read does; a boundary
159
- that shows only what came from the network and rethrows the rest as a bug
160
- recognises an import by its action — `error.action` is the route action the
161
- app declared for the code — rather than by the browser's message. Either way
162
- `error.action.rerun()` retries; a retry failing the same way says the document
163
- is stale, and **reloading it** is what brings the new code. Navi does not do
164
- it on its own — a document reloading itself is a loop waiting to happen — the
165
- screen says it.
199
+ **Asking again never fetches.** The document remembers a module whose fetch
200
+ failed as failed: a second `import()` of the same address is refused without a
201
+ request, for the life of the document. So a retry on an import has nothing to
202
+ do — `error.action.rerun()` fails again at once, on the same failure — and
203
+ the one way out is a fresh document: **`reload()`**, which the screen offers
204
+ in words the reader understands. Navi does not reload on its own: a document
205
+ reloading itself is a loop waiting to happen. It is also why a prefetch that
206
+ failed is not asked again on the arrival in any useful sense: the run is made,
207
+ refused at once, and the screen says it.
208
+
209
+ The page or the component reading its code says it where it stands, as
210
+ above. A failure left to a boundary — a page reading its code without
211
+ `error: true` — reaches it like a failed read does; a boundary that shows only
212
+ what came from the network and rethrows the rest as a bug recognises an import
213
+ by its action — `error.action` is the route action the app declared for the
214
+ code — rather than by the browser's message.
166
215
 
167
216
  ## Preact's `lazy()` is not this
168
217
 
@@ -325,6 +325,13 @@ being sent: everything — the popup is the only place its failure can be read,
325
325
  and `actionStandalone` there is how a save fails behind a closed popup. Something
326
326
  the app watches from somewhere else: nothing — it was never being watched here.
327
327
 
328
+ And when the answer being sent may never arrive at all — a request over a
329
+ network that stopped answering — the hold outlives what it was protecting:
330
+ nothing can close the popup, ever. `actionAbortable` keeps the hold and gives it
331
+ a release, so closing calls the run off rather than being refused. What that
332
+ costs, and where it may be said, is
333
+ [popup_open.md](./popup_open.md#the-popup-owns-its-open-state).
334
+
328
335
  ### On something you draw yourself
329
336
 
330
337
  `selfInteractions` is a `Box` prop too, so an affordance does not have to become
@@ -29,10 +29,10 @@ something a `useState` in the parent cannot give back: **a popup refuses to
29
29
  close while a control inside it is mid-action**.
30
30
 
31
31
  ```js
32
- // what both do on every close request
33
- const busyElement = findBusyElementInside(popupEl);
34
- if (busyElement) {
35
- dispatchRequestInteraction(busyElement, { ... });
32
+ // what both do on every close request (popup_busy.js)
33
+ const controlsHolding = findControlsHoldingPopup(popupEl);
34
+ if (controlsHolding.length) {
35
+ dispatchRequestInteraction(controlsHolding[0].element, { ... });
36
36
  requestCloseEvent.preventDefault();
37
37
  }
38
38
  ```
@@ -58,6 +58,33 @@ It is still busy for itself — the spinner, the second press refused, the error
58
58
  callout — which is the whole point of keeping `action` (see
59
59
  [interactions.md](./interactions.md#the-fourth-question-whose-wait-is-it)).
60
60
 
61
+ Between the two sits a run that IS being waited on and may never end. A request
62
+ over a network that stopped answering settles neither way, and the hold then
63
+ lasts exactly as long as the run: the popup closes for nothing and nobody, and
64
+ the page has to be reloaded. `actionAbortable` gives that hold a release — the
65
+ person waiting decides the answer is not coming, and closing is how they say
66
+ it:
67
+
68
+ ```jsx
69
+ <Form action={saveScore} actionAbortable>
70
+ ```
71
+
72
+ Every close request then goes through and calls the run off on the way out:
73
+ Escape, the backdrop, the cross, the phone's back gesture. No gesture is singled
74
+ out, because the cancel/keep distinction below is about the VALUE a popup holds,
75
+ and there is nothing left to keep or put back once the answer is already on the
76
+ wire — each of those gestures means "I want out of here", which is the only
77
+ thing being answered.
78
+
79
+ What it costs is written down: aborting frees the client and nothing more, so
80
+ the write may have landed anyway (see
81
+ [actions.md](./actions.md#aborting-saves-resources-it-does-not-undo)). Say it
82
+ where the screen can be re-opened on what is actually there, never where the
83
+ popup is the only place the outcome could be read. Everything inside the form
84
+ inherits it along with the wait itself; a popup holding one run that may be
85
+ given up on and one that may not still refuses, since calling off half of them
86
+ would cost an answer and change nothing on screen.
87
+
61
88
  So the question is never "should this popup be controlled?" but "what triggers
62
89
  the opening?" — and, when the answer is the application rather than a gesture,
63
90
  where that state lives:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.225",
3
+ "version": "0.29.227",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {