@celestia-island/hikari 0.55.158 → 0.55.159
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.
package/package.json
CHANGED
|
@@ -25,11 +25,14 @@ import "./HkInfiniteTail.scss";
|
|
|
25
25
|
* - `"idle"` — more exists, nothing in flight. With `auto` (default)
|
|
26
26
|
* the invisible sentinel watches the viewport and emits `loadMore`
|
|
27
27
|
* as it approaches; with `auto={false}` the explicit load-more
|
|
28
|
-
* button (HkLoadMore) renders instead. NOTE:
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* button (HkLoadMore) renders instead. NOTE: a failed page
|
|
29
|
+
* terminalizes the machine's walk — the tail keeps emitting
|
|
30
|
+
* `loadMore` as the sentinel re-enters view, but each emission is a
|
|
31
|
+
* no-op until the host calls `reload()` (the retry verb; the
|
|
32
|
+
* delivered rows stay on screen under the host's stale banner).
|
|
33
|
+
* Auto-retrying from the tail is deliberately not supported: the
|
|
34
|
+
* sentinel would re-fire on a fixed geometry with no backoff, one
|
|
35
|
+
* RPC per round trip, which locks a shared rate budget.
|
|
33
36
|
*
|
|
34
37
|
* The sentinel observes the BROWSER viewport (IntersectionObserver,
|
|
35
38
|
* `rootMargin` lookahead), so it works inside any nested scroll
|
|
@@ -114,7 +114,7 @@ describe("useInfinitePages", () => {
|
|
|
114
114
|
expect(handle.exhausted.value).toBe(true);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
-
it("a failed later page keeps the delivered rows and
|
|
117
|
+
it("a failed later page keeps the delivered rows and terminalizes the walk", async () => {
|
|
118
118
|
const fetcher = vi
|
|
119
119
|
.fn(async (): Promise<HkPageResultLike<Row>> => page([], 0, false))
|
|
120
120
|
.mockImplementationOnce(async () => page([{ id: "a" }], 3, true))
|
|
@@ -127,10 +127,36 @@ describe("useInfinitePages", () => {
|
|
|
127
127
|
await handle.loadMore();
|
|
128
128
|
expect(handle.error.value).toBe("boom");
|
|
129
129
|
expect(handle.items.value.map((r) => r.id)).toEqual(["a"]);
|
|
130
|
-
//
|
|
130
|
+
// A tail failure does NOT auto-retry: the sentinel's idle re-check
|
|
131
|
+
// would re-fire loadMore on a fixed geometry with no backoff — one
|
|
132
|
+
// RPC per round trip forever, locking any shared rate budget. The
|
|
133
|
+
// failure is terminal until `reload`.
|
|
131
134
|
await handle.loadMore();
|
|
135
|
+
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
136
|
+
expect(handle.items.value.map((r) => r.id)).toEqual(["a"]);
|
|
137
|
+
// `reload` is the retry verb: it clears the failure and refetches
|
|
138
|
+
// from page one.
|
|
139
|
+
await handle.reload();
|
|
132
140
|
expect(handle.error.value).toBeNull();
|
|
133
|
-
expect(handle.items.value.map((r) => r.id)).toEqual(["
|
|
141
|
+
expect(handle.items.value.map((r) => r.id)).toEqual(["b", "c"]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("a tail failure cannot storm: repeated loadMore stays a no-op", async () => {
|
|
145
|
+
const fetcher = vi
|
|
146
|
+
.fn(async (): Promise<HkPageResultLike<Row>> => page([], 0, false))
|
|
147
|
+
.mockImplementationOnce(async () => page([{ id: "a" }], 5, true))
|
|
148
|
+
.mockImplementation(async () => {
|
|
149
|
+
throw new Error("tail down");
|
|
150
|
+
});
|
|
151
|
+
const { handle } = withHandle(() => useInfinitePages<Row>({ fetcher, pageSize: 1 }));
|
|
152
|
+
await vi.waitFor(() => expect(handle.loading.value).toBe(false));
|
|
153
|
+
await handle.loadMore();
|
|
154
|
+
expect(handle.error.value).toBe("tail down");
|
|
155
|
+
// Ten idle-entry re-checks (the tail sentinel's emission pattern)
|
|
156
|
+
// spend ZERO further RPCs.
|
|
157
|
+
for (let i = 0; i < 10; i++) await handle.loadMore();
|
|
158
|
+
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
159
|
+
expect(handle.items.value.map((r) => r.id)).toEqual(["a"]);
|
|
134
160
|
});
|
|
135
161
|
|
|
136
162
|
it("a param change triggers a debounced reload from page one", async () => {
|
|
@@ -83,9 +83,9 @@ export interface InfinitePagesHandle<T> {
|
|
|
83
83
|
error: Ref<string | null>;
|
|
84
84
|
/** One-based index of the next page to fetch. */
|
|
85
85
|
nextPage: Ref<number>;
|
|
86
|
-
/** Fetch the next page (guarded: no-op while busy or
|
|
87
|
-
*
|
|
88
|
-
*
|
|
86
|
+
/** Fetch the next page (guarded: no-op while busy, exhausted, or
|
|
87
|
+
* after ANY failure — `reload` is the retry verb for both the
|
|
88
|
+
* first-page and the tail-page error dialects). */
|
|
89
89
|
loadMore: () => Promise<void>;
|
|
90
90
|
/** Reset to page one and fetch (also the error-clearing retry). */
|
|
91
91
|
reload: () => Promise<void>;
|
|
@@ -101,7 +101,11 @@ export interface InfinitePagesHandle<T> {
|
|
|
101
101
|
*
|
|
102
102
|
* Error dialect: a failed page keeps everything already delivered (the
|
|
103
103
|
* consumer renders it as a stale banner — one failed fetch must never
|
|
104
|
-
* hide rows the user was reading)
|
|
104
|
+
* hide rows the user was reading) and terminalizes the walk; `reload`
|
|
105
|
+
* is the retry verb for every failure. Auto-retrying a tail failure is
|
|
106
|
+
* deliberately NOT done: the tail's sentinel would re-fire on a fixed
|
|
107
|
+
* geometry with no backoff, turning one flaky page into an RPC storm
|
|
108
|
+
* that locks a shared rate budget.
|
|
105
109
|
*/
|
|
106
110
|
export function useInfinitePages<T, P extends Record<string, unknown> = Record<string, unknown>>(
|
|
107
111
|
options: InfinitePagesOptions<T, P>,
|
|
@@ -123,9 +127,6 @@ export function useInfinitePages<T, P extends Record<string, unknown> = Record<s
|
|
|
123
127
|
let seq = 0;
|
|
124
128
|
let reloadTimer: ReturnType<typeof setTimeout> | null = null;
|
|
125
129
|
let disposed = false;
|
|
126
|
-
/** Which phase the current `error` came from — a tail-page failure
|
|
127
|
-
* retried by `loadMore` in place, a first-page failure by `reload`. */
|
|
128
|
-
let errorPhase: "load" | "more" = "load";
|
|
129
130
|
|
|
130
131
|
function normalize(result: HkPageResultLike<T>): HkPageResult<T> {
|
|
131
132
|
const loose = result as {
|
|
@@ -196,14 +197,20 @@ export function useInfinitePages<T, P extends Record<string, unknown> = Record<s
|
|
|
196
197
|
}
|
|
197
198
|
|
|
198
199
|
async function loadMore(): Promise<void> {
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
200
|
+
// ANY failure terminalizes the walk until `reload()` — including a
|
|
201
|
+
// tail-page failure. The in-place retry this used to allow read
|
|
202
|
+
// fine in isolation but stormed in the field: a failed tail leaves
|
|
203
|
+
// the state machine "idle", the tail's sentinel stays in view, and
|
|
204
|
+
// its idle-entry re-check fires loadMore again with no geometry
|
|
205
|
+
// change — one RPC per round trip, no backoff, forever. On a shared
|
|
206
|
+
// per-user rate budget that loop locks the bucket and denies every
|
|
207
|
+
// OTHER paged page, so the retry verb is `reload()` (the delivered
|
|
208
|
+
// rows stay on screen under the stale banner; the header refresh is
|
|
209
|
+
// the user's affordance).
|
|
202
210
|
if (loading.value || loadingMore.value || exhausted.value) return;
|
|
203
|
-
if (error.value
|
|
211
|
+
if (error.value) return;
|
|
204
212
|
if (nextPage.value > 1 && items.value.length === 0) return;
|
|
205
213
|
loadingMore.value = true;
|
|
206
|
-
errorPhase = "more";
|
|
207
214
|
const mine = ++seq;
|
|
208
215
|
try {
|
|
209
216
|
await fetchPage(nextPage.value, mine);
|
|
@@ -225,7 +232,6 @@ export function useInfinitePages<T, P extends Record<string, unknown> = Record<s
|
|
|
225
232
|
reloadTimer = null;
|
|
226
233
|
}
|
|
227
234
|
loading.value = true;
|
|
228
|
-
errorPhase = "load";
|
|
229
235
|
error.value = null;
|
|
230
236
|
if (!options.keepDuringReload) {
|
|
231
237
|
// Invalidate the cursor BEFORE the fetch so a straggler from the
|