cursedbelt 4.5.0 → 4.6.0

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.
Files changed (38) hide show
  1. package/dist/react/media/HoverScrubVideoThumb.d.ts +7 -1
  2. package/dist/react/media/HoverScrubVideoThumb.d.ts.map +1 -1
  3. package/dist/react/media/HoverScrubVideoThumb.js +18 -3
  4. package/dist/react/media/HoverScrubVideoThumb.js.map +1 -1
  5. package/dist/react/media/previewGate.d.ts +36 -0
  6. package/dist/react/media/previewGate.d.ts.map +1 -0
  7. package/dist/react/media/previewGate.js +161 -0
  8. package/dist/react/media/previewGate.js.map +1 -0
  9. package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
  10. package/dist/react/media-gallery/GalleryTable.js +15 -3
  11. package/dist/react/media-gallery/GalleryTable.js.map +1 -1
  12. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  13. package/dist/react/media-gallery/MediaGallery.js +3 -1
  14. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  15. package/dist/react/media-gallery/galleryItemMedia.d.ts +32 -1
  16. package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
  17. package/dist/react/media-gallery/galleryItemMedia.js +64 -9
  18. package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
  19. package/dist/react/media-gallery/types.d.ts +8 -1
  20. package/dist/react/media-gallery/types.d.ts.map +1 -1
  21. package/dist/styles-areas/media-gallery.css +1 -1
  22. package/dist/styles-areas/media.css +1 -1
  23. package/package.json +3 -1
  24. package/src/declaredImports.spec.ts +66 -0
  25. package/src/publicSurface.spec.ts +12 -4
  26. package/src/react/media/HoverScrubVideoThumb.spec.tsx +4 -3
  27. package/src/react/media/HoverScrubVideoThumb.tsx +25 -3
  28. package/src/react/media/previewGate.spec.tsx +144 -0
  29. package/src/react/media/previewGate.ts +160 -0
  30. package/src/react/media-gallery/GalleryTable.tsx +14 -1
  31. package/src/react/media-gallery/MediaGallery.spec.tsx +52 -3
  32. package/src/react/media-gallery/MediaGallery.tsx +9 -1
  33. package/src/react/media-gallery/galleryItemMedia.spec.tsx +143 -0
  34. package/src/react/media-gallery/galleryItemMedia.tsx +80 -9
  35. package/src/react/media-gallery/types.ts +8 -1
  36. package/src/styles-areas/media-gallery.css +1 -1
  37. package/src/styles-areas/media.css +1 -1
  38. package/scripts/publicSurface.ts +0 -458
@@ -0,0 +1,143 @@
1
+ /**
2
+ * `useItemMedia`'s cancellation (task 107/461): a resolve is aborted when every mount waiting on
3
+ * it is gone, NOT while another still waits, and an abandoned entry is evicted so a remount asks
4
+ * again instead of inheriting the abort — the scrolled-past tile that would otherwise be grey
5
+ * forever.
6
+ */
7
+ import { afterEach, describe, expect, test } from 'bun:test';
8
+ import { act, cleanup, render, waitFor } from '@testing-library/react';
9
+ import { type MediaCache, ThumbImg, useItemMedia } from './galleryItemMedia.js';
10
+ import type { MediaGalleryItem, MediaGalleryItemMedia } from './types.js';
11
+
12
+ afterEach(cleanup);
13
+
14
+ /** A resolver whose answers the test hands out by hand, recording every signal it was given. */
15
+ function deferredResolver() {
16
+ const signals: AbortSignal[] = [];
17
+ const answers: Array<(m: MediaGalleryItemMedia | null) => void> = [];
18
+ const resolveMedia = (signal?: AbortSignal) =>
19
+ new Promise<MediaGalleryItemMedia | null>((resolve, reject) => {
20
+ if (signal) signals.push(signal);
21
+ answers.push(resolve);
22
+ // What a `fetch` does with its signal: reject with an AbortError.
23
+ signal?.addEventListener('abort', () =>
24
+ reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
25
+ );
26
+ });
27
+ return { signals, answers, resolveMedia };
28
+ }
29
+
30
+ function Probe({ item, cache, label }: { item: MediaGalleryItem; cache: MediaCache; label: string }) {
31
+ const { media, resolving } = useItemMedia(item, cache);
32
+ return <span data-testid={label}>{resolving ? 'resolving' : (media.url ?? 'none')}</span>;
33
+ }
34
+
35
+ const newCache = (): MediaCache => ({ current: new Map() });
36
+ /** Past the microtask the abandon check runs in. */
37
+ const settle = () => act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
38
+
39
+ describe('useItemMedia — abort, refcount, evict', () => {
40
+ test('the resolve is handed an AbortSignal, and unmounting aborts it', async () => {
41
+ const r = deferredResolver();
42
+ const cache = newCache();
43
+ const item: MediaGalleryItem = { id: 'a', name: 'a.png', resolveMedia: r.resolveMedia };
44
+ const view = render(<Probe item={item} cache={cache} label='a' />);
45
+ expect(r.signals).toHaveLength(1);
46
+ expect(r.signals[0]?.aborted).toBe(false);
47
+ view.unmount();
48
+ await waitFor(() => expect(r.signals[0]?.aborted).toBe(true));
49
+ // 🔴 and EVICTED — the cache must not keep a promise that rejects with an abort.
50
+ expect(cache.current.has('a')).toBe(false);
51
+ });
52
+
53
+ test('🔴 two mounts on one entry: one unmounting does NOT abort the other', async () => {
54
+ const r = deferredResolver();
55
+ const cache = newCache();
56
+ const item: MediaGalleryItem = { id: 'b', name: 'b.png', resolveMedia: r.resolveMedia };
57
+ const view = render(
58
+ <>
59
+ <Probe item={item} cache={cache} label='grid' />
60
+ <Probe item={item} cache={cache} label='table' />
61
+ </>,
62
+ );
63
+ expect(r.signals).toHaveLength(1);
64
+ view.rerender(<Probe item={item} cache={cache} label='table' />);
65
+ await settle();
66
+ expect(r.signals[0]?.aborted).toBe(false);
67
+ expect(cache.current.has('b')).toBe(true);
68
+ act(() => r.answers[0]?.({ url: '/bytes/b' }));
69
+ await waitFor(() => expect(view.getByTestId('table').textContent).toBe('/bytes/b'));
70
+ });
71
+
72
+ test('🔴 an abandoned entry is evicted, and a remount re-asks and gets the bytes', async () => {
73
+ const r = deferredResolver();
74
+ const cache = newCache();
75
+ const item: MediaGalleryItem = { id: 'c', name: 'c.png', resolveMedia: r.resolveMedia };
76
+ render(<Probe item={item} cache={cache} label='first' />).unmount();
77
+ await waitFor(() => expect(r.signals[0]?.aborted).toBe(true));
78
+
79
+ const again = render(<Probe item={item} cache={cache} label='again' />);
80
+ expect(r.signals).toHaveLength(2);
81
+ expect(r.signals[1]?.aborted).toBe(false);
82
+ act(() => r.answers[1]?.({ url: '/bytes/c' }));
83
+ // Not "none": an abort is never read as "this item has no bytes".
84
+ await waitFor(() => expect(again.getByTestId('again').textContent).toBe('/bytes/c'));
85
+ });
86
+
87
+ test('a re-render with a NEW resolveMedia closure does not abort the resolve in flight', async () => {
88
+ // Apps build items inline, so `resolveMedia` is a fresh function every render and the
89
+ // effect re-runs (cleanup, then setup) each time. The abandon check must not fire on that.
90
+ const r = deferredResolver();
91
+ const cache = newCache();
92
+ const make = (): MediaGalleryItem => ({
93
+ id: 'd',
94
+ name: 'd.png',
95
+ resolveMedia: (signal) => r.resolveMedia(signal),
96
+ });
97
+ const view = render(<Probe item={make()} cache={cache} label='d' />);
98
+ view.rerender(<Probe item={make()} cache={cache} label='d' />);
99
+ view.rerender(<Probe item={make()} cache={cache} label='d' />);
100
+ await settle();
101
+ expect(r.signals).toHaveLength(1);
102
+ expect(r.signals[0]?.aborted).toBe(false);
103
+ act(() => r.answers[0]?.({ url: '/bytes/d' }));
104
+ await waitFor(() => expect(view.getByTestId('d').textContent).toBe('/bytes/d'));
105
+ });
106
+
107
+ test('a SETTLED answer is kept after unmount — the cache still saves the re-sign', async () => {
108
+ const r = deferredResolver();
109
+ const cache = newCache();
110
+ const item: MediaGalleryItem = { id: 'e', name: 'e.png', resolveMedia: r.resolveMedia };
111
+ const view = render(<Probe item={item} cache={cache} label='e' />);
112
+ act(() => r.answers[0]?.({ url: '/bytes/e' }));
113
+ await waitFor(() => expect(view.getByTestId('e').textContent).toBe('/bytes/e'));
114
+ view.unmount();
115
+ await settle();
116
+ expect(r.signals[0]?.aborted).toBe(false);
117
+ const again = render(<Probe item={item} cache={cache} label='e2' />);
118
+ await waitFor(() => expect(again.getByTestId('e2').textContent).toBe('/bytes/e'));
119
+ expect(r.signals).toHaveLength(1);
120
+ });
121
+
122
+ test('a genuine rejection is still "no bytes" for a live mount', async () => {
123
+ const cache = newCache();
124
+ const item: MediaGalleryItem = {
125
+ id: 'f',
126
+ name: 'f.png',
127
+ resolveMedia: async () => {
128
+ throw new Error('gone');
129
+ },
130
+ };
131
+ const view = render(<Probe item={item} cache={cache} label='f' />);
132
+ await waitFor(() => expect(view.getByTestId('f').textContent).toBe('none'));
133
+ });
134
+ });
135
+
136
+ describe('ThumbImg — the tile picture is lazy and decodes off the main thread', () => {
137
+ test('🔴 loading=lazy and decoding=async', () => {
138
+ const { container } = render(<ThumbImg src='/t.jpg' alt='' />);
139
+ const img = container.querySelector('img') as HTMLImageElement;
140
+ expect(img.getAttribute('loading')).toBe('lazy');
141
+ expect(img.getAttribute('decoding')).toBe('async');
142
+ });
143
+ });
@@ -12,13 +12,67 @@ import { type MutableRefObject, useCallback, useEffect, useRef, useState } from
12
12
  import { staticItemMedia } from './mediaGalleryModel.js';
13
13
  import type { MediaGalleryItem, MediaGalleryItemMedia } from './types.js';
14
14
 
15
- export type MediaCache = MutableRefObject<Map<string, Promise<MediaGalleryItemMedia | null>>>;
15
+ /**
16
+ * One item's in-flight or settled resolve, shared by every view that shows the item.
17
+ *
18
+ * `holders` counts the mounts awaiting it right now — a grid tile and a table row can both be
19
+ * on one entry, and so can the stage. The entry is only ABANDONED (aborted and evicted) when
20
+ * that count reaches zero before the answer arrived; see {@link useItemMedia}.
21
+ */
22
+ export interface MediaCacheEntry {
23
+ promise: Promise<MediaGalleryItemMedia>;
24
+ controller: AbortController;
25
+ holders: number;
26
+ settled: boolean;
27
+ }
28
+ export type MediaCache = MutableRefObject<Map<string, MediaCacheEntry>>;
29
+
30
+ /**
31
+ * Start one resolve for `item` and file it in the cache.
32
+ *
33
+ * The async wrapper is deliberate: a resolver that throws SYNCHRONOUSLY becomes a rejection
34
+ * like any other, instead of escaping the effect and taking the tile down.
35
+ */
36
+ function startResolve(
37
+ id: string,
38
+ resolveMedia: MediaGalleryItem['resolveMedia'],
39
+ cache: MediaCache,
40
+ ): MediaCacheEntry {
41
+ const controller = new AbortController();
42
+ const promise = (async () => (await resolveMedia?.(controller.signal)) ?? {})();
43
+ const entry: MediaCacheEntry = { promise, controller, holders: 0, settled: false };
44
+ const settle = () => {
45
+ entry.settled = true;
46
+ };
47
+ promise.then(settle, settle);
48
+ cache.current.set(id, entry);
49
+ return entry;
50
+ }
51
+
16
52
  /**
17
53
  * The item's resolved media: static URL fields immediately, `resolveMedia` results merged
18
54
  * in when the item needs them (cached per id for the gallery's lifetime).
19
55
  *
20
56
  * `refresh` throws the cached answer away and asks the app again — see its own note; it is
21
57
  * what makes a `403` from a stale signed URL recoverable instead of a sentence on screen.
58
+ *
59
+ * ── 🔴 Cancellation, and the two traps in it (task 107/461, 2026-09-22) ─────────────────
60
+ * Each resolve owns an `AbortController` whose signal is handed to `item.resolveMedia(signal)`.
61
+ * Before this, a tile that unmounted merely stopped listening: the request ran to completion
62
+ * for a tile that was gone, and a fast scroll left one open per row it passed.
63
+ *
64
+ * · **Abort only when NOBODY is waiting.** The grid and the table share this cache (the file
65
+ * header says why), so a tile and a row — or a tile and the stage — can be on one promise.
66
+ * Each mount holds the entry; the abort fires only when the count reaches zero with the
67
+ * answer still outstanding. The check runs a microtask AFTER the cleanup, because the
68
+ * cleanup of one mount and the setup of its successor are not simultaneous: toggling grid →
69
+ * table unmounts every tile before any row mounts, and an app that rebuilds `resolveMedia`
70
+ * on each render re-runs this effect (cleanup, then setup) every render. An immediate check
71
+ * would abort all of those.
72
+ * · **An abandoned entry is EVICTED in the same step.** Otherwise the cache keeps a promise
73
+ * that rejects with an abort, the next mount inherits it, and a tile the viewer scrolled past
74
+ * is a grey box forever when he scrolls back. An abort is also never turned into "no bytes"
75
+ * for a mount still on screen — if one ever sees its entry's signal aborted, it re-asks.
22
76
  */
23
77
  export function useItemMedia(
24
78
  item: MediaGalleryItem,
@@ -48,16 +102,15 @@ export function useItemMedia(
48
102
  return true;
49
103
  }, [item.id, needsResolve, cache]);
50
104
 
105
+ const { id, resolveMedia } = item;
106
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `attempt` is the re-ask — not read in the body, it is what re-runs it (see `refresh`).
51
107
  useEffect(() => {
52
108
  if (!needsResolve) return;
53
109
  let live = true;
54
- let promise = cache.current.get(item.id);
55
- if (!promise) {
56
- promise = (item.resolveMedia?.() ?? Promise.resolve(null)).then((m) => m ?? {});
57
- cache.current.set(item.id, promise);
58
- }
110
+ const entry = cache.current.get(id) ?? startResolve(id, resolveMedia, cache);
111
+ entry.holders += 1;
59
112
  setResolving(true);
60
- promise
113
+ entry.promise
61
114
  .then((m) => {
62
115
  if (!live) return;
63
116
  setResolved(m);
@@ -65,14 +118,27 @@ export function useItemMedia(
65
118
  })
66
119
  .catch(() => {
67
120
  if (!live) return;
121
+ if (entry.controller.signal.aborted) {
122
+ // Our own abandonment reached a mount that is still here — re-ask rather than
123
+ // report an abort as a missing file. The entry is already evicted.
124
+ if (cache.current.get(id) === entry) cache.current.delete(id);
125
+ setAttempt((n) => n + 1);
126
+ return;
127
+ }
68
128
  setResolved({});
69
129
  setResolving(false);
70
130
  });
71
131
  return () => {
72
132
  live = false;
133
+ entry.holders -= 1;
134
+ queueMicrotask(() => {
135
+ if (entry.holders > 0 || entry.settled || entry.controller.signal.aborted) return;
136
+ // Abandoned: nobody is waiting and the answer never arrived.
137
+ if (cache.current.get(id) === entry) cache.current.delete(id);
138
+ entry.controller.abort();
139
+ });
73
140
  };
74
- // `attempt` is the re-ask: it is not read in the body, it is what re-runs it.
75
- }, [item.id, needsResolve, cache, item.resolveMedia, attempt]);
141
+ }, [id, needsResolve, cache, resolveMedia, attempt]);
76
142
 
77
143
  return { media: { ...(resolved ?? {}), ...staticMedia }, resolving, refresh };
78
144
  }
@@ -141,7 +207,12 @@ export function ThumbImg({
141
207
  <img
142
208
  src={failed && canFallBack ? (fallbackSrc as string) : src}
143
209
  alt={alt}
210
+ // 🔴 Both, on every tile picture: `lazy` keeps an off-screen tile from opening a request,
211
+ // `async` keeps a wall of decodes off the main thread during a scroll. The tiles' own
212
+ // `<img>` loads are invisible to any app-side fetch gate (collections measured 176 open
213
+ // at once on one scroll, 2026-09-22), so this is the only place they can be bounded.
144
214
  loading='lazy'
215
+ decoding='async'
145
216
  {...(draggable === undefined ? {} : { draggable })}
146
217
  {...(style && Object.keys(style).length > 0 ? { style } : {})}
147
218
  onError={canFallBack ? () => setFailed(true) : undefined}
@@ -171,6 +171,13 @@ export interface MediaGalleryItem extends MediaGalleryItemMedia {
171
171
  * Called once per item id per gallery mount (cached); return `null` when the bytes
172
172
  * are gone. Fields set directly on the item win over resolved ones. If the resolver
173
173
  * mints object URLs, revoking them stays the app's job.
174
+ *
175
+ * 🔴 `signal` aborts when every tile, row and stage waiting on this answer has unmounted
176
+ * before it arrived — a tile the viewer scrolled past. Hand it to your `fetch` (or drop the
177
+ * ask from your own queue) so an abandoned resolve stops costing origin time. The gallery
178
+ * evicts the abandoned entry, so a remount asks again rather than inheriting the abort; an
179
+ * abort is never read as "this item has no bytes". Ignoring the parameter is valid — the
180
+ * answer is then simply thrown away.
174
181
  */
175
- resolveMedia?: () => Promise<MediaGalleryItemMedia | null>;
182
+ resolveMedia?: (signal?: AbortSignal) => Promise<MediaGalleryItemMedia | null>;
176
183
  }
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 2116 candidate(s) scanned.
18
+ * when this file is stale. 2162 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 2825 candidate(s) scanned.
18
+ * when this file is stale. 2849 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are