@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.158",
3
+ "version": "0.55.159",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -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: with `auto`, a tail
29
- * page failure retries in place every time the sentinel re-enters
30
- * view (the machine's error guard paces it to one attempt per
31
- * emission, no backoff) — hosts that want backoff map the error
32
- * state away from `"idle"` themselves.
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 retries in place", async () => {
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
- // The tail failure is retriable without a full reload.
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(["a", "b", "c"]);
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 exhausted).
87
- * A TAIL-page failure stays retriable in place — the next `loadMore`
88
- * refetches the same page; only a page-one failure needs `reload`. */
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); `reload` is the retry verb.
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
- // A failure from a LATER page is retriable in place (the delivered
200
- // rows stay, the cursor stays); a failure from page one needs a
201
- // full reload (the retry verb for the hard-error state).
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 && errorPhase !== "more") return;
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