@jsenv/navi 0.29.166 → 0.29.167

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.
@@ -69,6 +69,17 @@ consistency across the app, not from any single call site.
69
69
  collection (searching it is that collection's own `GET_MANY`). Companion
70
70
  files: `docs/actions.md`, `docs/resource_with_params.md`,
71
71
  `docs/resource_dependencies.md`.
72
+ - `docs/data_states.md` — what a screen draws while its data is loading,
73
+ missing, stale or failed: the three questions `data`, `loading` and `error`
74
+ answer separately, the four combinations of the first two and what each one
75
+ means, and the invariant that `loading: true` never suspends — so a component
76
+ drawing its own states is never taken away, with the dialog, the scroll
77
+ position and the retry button it was holding. Also why a skeleton is TOLD
78
+ whether it is loading rather than deducing it from emptiness, why an error
79
+ arrives beside the data rather than in place of it, and what `dismissError()`
80
+ does and does not do. Read it before drawing a skeleton on `!data`, before
81
+ hiding content because `error` is set, and before adding a state flag of your
82
+ own next to what the hook already returns.
72
83
  - `docs/list_refresh.md` — what a write sends back to the network and what stays
73
84
  on screen meanwhile: stale data returned by `useAsyncData({ loading: true })`,
74
85
  what updates from a response without any request, `rerunOn` and its defaults,
package/docs/actions.md CHANGED
@@ -130,10 +130,12 @@ const [user] = useAsyncData(userAction);
130
130
 
131
131
  `useAsyncData` suspends until the data is there and throws on failure, leaving
132
132
  both to the nearest `<Loading>` and `<ErrorBoundary>`; pass `{ loading: true }`
133
- or `{ error: true }` to handle either inside the component (stale data stays
134
- available while a rerun is in flight). Where a failure goes when no screen takes
135
- it — and why a run settles with its error rather than rejecting — is
136
- [error_handling.md](./error_handling.md). `useActionStatus(action)` gives the whole
133
+ or `{ error: true }` to receive either as a value instead, the component staying
134
+ mounted throughout and keeping the data it already had. What it then draws for
135
+ each state — the four combinations of `data` and `loading`, why a skeleton is
136
+ told whether it is loading — is [data_states.md](./data_states.md). Where a
137
+ failure goes when no screen takes it — and why a run settles with its error
138
+ rather than rejecting — is [error_handling.md](./error_handling.md). `useActionStatus(action)` gives the whole
137
139
  state at once — `{ idle, loading, completed, aborted, error, data, params }` —
138
140
  for a component that needs to look at it rather than render it. It reads that
139
141
  very instance: to know what moves when a control is handed the action, see
@@ -0,0 +1,147 @@
1
+ # The states of a screen's data
2
+
3
+ A screen reading async data draws one of a handful of states, and there are two
4
+ ways to get it wrong. Both are common, and both come from the same shortcut.
5
+
6
+ - **A wait announced over something that is not coming.** A skeleton drawn on
7
+ "there is no data" alone keeps shimmering after the load failed, and after the
8
+ params turned out to be false. It says "hold on", and there is nothing to hold
9
+ on for.
10
+ - **Content taken away because something behind it went wrong.** A list that
11
+ disappears because its refresh failed; a page replaced by a message while the
12
+ rows it had are still perfectly good, and still true a second ago.
13
+
14
+ The shortcut behind both: collapsing "loading", "empty" and "failed" into one
15
+ state — _not ready_ — and drawing one thing for it. They are three separate
16
+ questions, and a screen that draws honestly answers them separately:
17
+
18
+ - **is there something to show?** → `data`
19
+ - **is anything on its way?** → `loading`
20
+ - **did the last attempt fail?** → `error`
21
+
22
+ navi's part is to answer all three, at every render, and to keep answering them.
23
+ What is drawn for each is the component's, always: navi has no opinion on
24
+ whether a missing list is a skeleton, a sentence or an illustration.
25
+
26
+ ## Asking for a state means receiving it as a value
27
+
28
+ Without options, `useAsyncData` **delegates**: it suspends while the data is not
29
+ there and throws when the action failed, so the states are drawn by the
30
+ `<Loading>` and `<ErrorBoundary>` above rather than by the component.
31
+
32
+ Each option turns one of those from something done _to_ the component into a
33
+ value the component reads:
34
+
35
+ | passed | the wait | the failure |
36
+ | --------------- | ------------------------- | ---------------------------- |
37
+ | nothing | suspends into `<Loading>` | throws to `<ErrorBoundary>` |
38
+ | `loading: true` | `loading`, a boolean | throws |
39
+ | `error: true` | suspends | `error`, plus `dismissError` |
40
+ | both | `loading` | `error` |
41
+
42
+ The invariant that makes this worth relying on: **`loading: true` never
43
+ suspends.** Not while the action runs, not while a debounced binding is
44
+ settling, not while it holds params nobody has started yet, not after a failure
45
+ whose message was dismissed. Every one of those comes back as a value.
46
+
47
+ That is not a detail of the wait — it is what keeps the component alive.
48
+ Suspending does not draw a spinner over a subtree: it takes the subtree away and
49
+ puts the boundary's fallback in its place, and everything under it is remounted
50
+ when it comes back. A dialog open inside it closes, a scroll position is lost,
51
+ half-typed text is gone, and the retry button the component was offering
52
+ disappears along with the component. A component that said it draws its own
53
+ states is never taken away from the person looking at it.
54
+
55
+ `error: true` says the same thing about failure, and it has the same
56
+ consequence: the component stays, so it is still there to offer the way out.
57
+
58
+ A component that delegates nothing needs no `<Loading>` above it. One that
59
+ delegates its wait does, since that is where the suspension lands, and navi says
60
+ so rather than leaving the subtree blank.
61
+
62
+ ## `data` and `loading` are independent
63
+
64
+ They answer different questions, so all four combinations happen and each one
65
+ means something different:
66
+
67
+ | `data` | `loading` | what it is | what a screen draws |
68
+ | ----------- | --------- | ----------------------------- | ------------------------ |
69
+ | `undefined` | `true` | first load | a skeleton, animated |
70
+ | set | `true` | a refresh over a known answer | the content, marked busy |
71
+ | set | `false` | settled | the content |
72
+ | `undefined` | `false` | nothing here, nothing coming | an empty state |
73
+
74
+ Two consequences worth stating out loud.
75
+
76
+ **The emptiness test is `data === undefined`, never `loading`.** Reading
77
+ `loading` as "there is nothing to display" blanks the screen on every refresh —
78
+ for a checkbox ticked on one row of a list, the whole list goes.
79
+
80
+ **A skeleton is told whether it is loading; it does not deduce it.** The last
81
+ row of the table is the one that gets forgotten, and it is reachable: a first
82
+ load that failed — its message on screen or dismissed — and a route action whose
83
+ params getter returns false. A skeleton drawn on emptiness alone shimmers there
84
+ forever, claiming a load nobody started.
85
+
86
+ ```jsx
87
+ const [items, loading] = useAsyncData(ACTION, { loading: true });
88
+ if (items === undefined) {
89
+ return <ItemsSkeleton loading={loading} />;
90
+ }
91
+ return <ItemList items={items} busy={loading} />;
92
+ ```
93
+
94
+ navi's own placeholders take that flag rather than inventing it: `<Text loading>`
95
+ shimmers, `<Text skeleton>` is the same bar held still, and `<Text skeleton
96
+ loading={loading}>` is one that tells the truth either way.
97
+
98
+ **Knowing the count beforehand beats guessing it.** How many skeleton rows to
99
+ draw is a question the response answers too late; a screen that already knows —
100
+ a count carried by the parent resource, a total returned alongside a previous
101
+ page — says so, and `<List loading loadingSkeletonCount={0}>` draws the empty
102
+ state right away instead of three rows that will vanish. Nothing jumps when the
103
+ response lands.
104
+
105
+ ## An error is a message on the screen, not the screen
106
+
107
+ `error: true` hands the failure over **beside** the data the action already had,
108
+ not in place of it. A refresh that failed does not unmake the previous answer,
109
+ so a screen that had something to show keeps it and says the failure over it:
110
+
111
+ ```jsx
112
+ const [items, loading, error, dismissError] = useAsyncData(ACTION, {
113
+ loading: true,
114
+ error: true,
115
+ });
116
+ if (error && !items) {
117
+ return <ErrorScreen error={error} />; // nothing behind it: the message is the screen
118
+ }
119
+ return (
120
+ <>
121
+ {error && <ErrorStrip error={error} onClose={dismissError} />}
122
+ <ItemList items={items} busy={loading} />
123
+ </>
124
+ );
125
+ ```
126
+
127
+ `dismissError()` removes the message and nothing else — nothing is asked again,
128
+ because nothing had to be: what closing it reveals was already there. What comes
129
+ back is the same state minus the error, which is why the two branches above stay
130
+ honest afterwards.
131
+
132
+ Taking the whole subtree away is `<ErrorBoundary>`'s job and it is a different
133
+ decision: reach for it when the failure means the screen genuinely cannot be
134
+ drawn, not as a stronger version of the same thing. Which of the two, and where
135
+ an error goes when neither takes it, is
136
+ [error_handling.md](./error_handling.md).
137
+
138
+ ## Reference
139
+
140
+ - [error_handling.md](./error_handling.md) — the two kinds of error, where each
141
+ is shown, and what happens to one nobody displays.
142
+ - [list_refresh.md](./list_refresh.md) — the same states for a list, plus what a
143
+ write puts back on screen with no request at all.
144
+ - [actions.md](./actions.md#reading-an-action) — reading an action versus
145
+ running one, `{ run: true }`, `{ onLoad }`.
146
+ - `src/state/demos/use_async_data_demo.html` — one card per shape: delegated,
147
+ loading inline, both inline, owning its request.
@@ -140,6 +140,15 @@ const [game, loading, error, dismissError] = useAsyncData(gameAction, {
140
140
  });
141
141
  ```
142
142
 
143
+ `game` and `error` arrive **together**: a refresh that failed does not unmake
144
+ the answer the action already had, so the screen keeps what it has and says the
145
+ failure on it, and `dismissError()` takes that message away without asking
146
+ anything again. That is the whole difference between the two shapes — a boundary
147
+ replaces the subtree by definition, this one writes on it — so reach for the
148
+ boundary when the failure means the screen cannot be drawn at all, and for this
149
+ when it means one thing about it is out of date. How the component draws the
150
+ states it is handed is [data_states.md](./data_states.md).
151
+
143
152
  And one row rather than the whole list: `<List.Item error>` replaces the row's
144
153
  content with the message — what the row stood for did not happen, so drawing it
145
154
  as if it had would be a lie.
@@ -27,13 +27,15 @@ The short answer:
27
27
  | never completed yet | `undefined` | `true` |
28
28
  | running after success | previous | `true` |
29
29
  | completed | fresh | `false` |
30
+ | failed after success | previous | `false` |
31
+ | failed, error closed | `undefined` | `false` |
30
32
 
31
33
  So the emptiness test is `data === undefined`, never `loading`:
32
34
 
33
35
  ```js
34
36
  const [items, loading] = useAsyncData(ACTION, { loading: true });
35
37
  if (items === undefined) {
36
- return null; // first load: there is nothing to show yet
38
+ return <RadarListSkeleton loading={loading} />; // nothing to show yet
37
39
  }
38
40
  return <RadarList radars={items} busy={loading} />; // re-read: stay on screen
39
41
  ```
@@ -48,10 +50,23 @@ if (loading) {
48
50
  Read `loading` as "what you are displaying is from before", not "there is
49
51
  nothing to display".
50
52
 
53
+ The two answer different questions — _is there anything to show_ and _is
54
+ anything on its way_ — so a screen reads both, and a skeleton is given `loading`
55
+ rather than deducing it from emptiness. The four combinations, and what each one
56
+ draws, are in [data_states.md](./data_states.md).
57
+
51
58
  `<List loading>` is the first-load answer, not the refresh one: it replaces the
52
59
  rows with skeletons. Pass it while stale rows exist and they disappear — same
53
60
  mistake as `loading ? null :`, one level down.
54
61
 
62
+ The `failed after success` row is the same rule at the other end: a refresh that
63
+ failed does not unmake the rows either. With `error: true` the failure comes
64
+ back **beside** them, so the list stays and the failure is said over it — a
65
+ strip above the rows, a retry, `dismissError()` to close the strip without
66
+ asking anything again. Taking the rows away is `<ErrorBoundary>`'s job, and only
67
+ when the screen genuinely cannot be drawn — see
68
+ [data_states.md](./data_states.md).
69
+
55
70
  ## What updates without a request
56
71
 
57
72
  An action's `data` is a computation over the resource store, not a snapshot of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.166",
3
+ "version": "0.29.167",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {