cursedbelt 4.5.0 → 4.7.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 (45) hide show
  1. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  2. package/dist/react/file-tree/FileTree.js +19 -1
  3. package/dist/react/file-tree/FileTree.js.map +1 -1
  4. package/dist/react/media/HoverScrubVideoThumb.d.ts +7 -1
  5. package/dist/react/media/HoverScrubVideoThumb.d.ts.map +1 -1
  6. package/dist/react/media/HoverScrubVideoThumb.js +18 -3
  7. package/dist/react/media/HoverScrubVideoThumb.js.map +1 -1
  8. package/dist/react/media/previewGate.d.ts +36 -0
  9. package/dist/react/media/previewGate.d.ts.map +1 -0
  10. package/dist/react/media/previewGate.js +161 -0
  11. package/dist/react/media/previewGate.js.map +1 -0
  12. package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
  13. package/dist/react/media-gallery/GalleryTable.js +15 -3
  14. package/dist/react/media-gallery/GalleryTable.js.map +1 -1
  15. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  16. package/dist/react/media-gallery/MediaGallery.js +3 -1
  17. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  18. package/dist/react/media-gallery/galleryItemMedia.d.ts +32 -1
  19. package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
  20. package/dist/react/media-gallery/galleryItemMedia.js +64 -9
  21. package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
  22. package/dist/react/media-gallery/types.d.ts +8 -1
  23. package/dist/react/media-gallery/types.d.ts.map +1 -1
  24. package/dist/styles-areas/file-tree.css +1 -1
  25. package/dist/styles-areas/media-gallery.css +1 -1
  26. package/dist/styles-areas/media.css +1 -1
  27. package/package.json +3 -1
  28. package/src/declaredImports.spec.ts +66 -0
  29. package/src/publicSurface.spec.ts +12 -4
  30. package/src/react/file-tree/FileTree.tsx +19 -1
  31. package/src/react/file-tree/fileTreeThumbs.spec.tsx +90 -0
  32. package/src/react/media/HoverScrubVideoThumb.spec.tsx +4 -3
  33. package/src/react/media/HoverScrubVideoThumb.tsx +25 -3
  34. package/src/react/media/previewGate.spec.tsx +144 -0
  35. package/src/react/media/previewGate.ts +160 -0
  36. package/src/react/media-gallery/GalleryTable.tsx +14 -1
  37. package/src/react/media-gallery/MediaGallery.spec.tsx +52 -3
  38. package/src/react/media-gallery/MediaGallery.tsx +9 -1
  39. package/src/react/media-gallery/galleryItemMedia.spec.tsx +143 -0
  40. package/src/react/media-gallery/galleryItemMedia.tsx +80 -9
  41. package/src/react/media-gallery/types.ts +8 -1
  42. package/src/styles-areas/file-tree.css +1 -1
  43. package/src/styles-areas/media-gallery.css +1 -1
  44. package/src/styles-areas/media.css +1 -1
  45. package/scripts/publicSurface.ts +0 -458
@@ -0,0 +1,160 @@
1
+ /**
2
+ * When a tile is allowed to start a VIDEO load — the gallery's ceiling on byte loads.
3
+ *
4
+ * ── 🔴 Why this exists, from `collections`' 2026-09-22 measurement ──────────────────────
5
+ * One wheel-scroll top to bottom of a 240-row gallery had **176 same-origin requests open at
6
+ * once** (`apps/collections/e2e/mintCeiling.browser.test.ts`), and none of them were `fetch`
7
+ * calls an app-side gate could see: they were the tiles' own `<img>` and `<video>` loads. The
8
+ * `<img>` half is `loading='lazy'` + `decoding='async'` on every tile picture; this file is the
9
+ * `<video>` half. With "Preview all" on, every tile the virtualizer mounted — the visible rows
10
+ * AND its overscan — attached an autoplaying `<video>` the moment it rendered, so a fast
11
+ * scroll opened a stream per row it passed and then abandoned it.
12
+ *
13
+ * So an automatic preview now waits for two things: the tile is actually on screen
14
+ * ({@link useInView}), and it has STAYED there for {@link PREVIEW_SETTLE_MS}
15
+ * ({@link useSettled}). A tile scrolled past faster than that never starts its load at all,
16
+ * and one that leaves before settling cancels the pending start. A deliberate hover is not
17
+ * gated — the pointer being on the tile is the intent the settle exists to infer.
18
+ *
19
+ * {@link releaseVideo} is the other end: a `<video>` React removes keeps downloading until it
20
+ * is garbage-collected, so a tile that stops previewing empties the element's source, which is
21
+ * what actually closes the connection.
22
+ */
23
+ import { useEffect, useState } from 'react';
24
+
25
+ /**
26
+ * How long a tile must stay on screen before an automatic preview starts. Long enough that a
27
+ * wheel flick past a row is well under it (a row crosses the viewport in tens of ms at scroll
28
+ * speed), short enough that a grid the viewer stopped on starts moving without a visible wait.
29
+ */
30
+ export const PREVIEW_SETTLE_MS = 200;
31
+
32
+ type Listener = (inView: boolean) => void;
33
+
34
+ /**
35
+ * ONE observer for every tile, not one per tile — a 400-row gallery is 400 elements and a
36
+ * single `IntersectionObserver` handles that in one callback per frame.
37
+ *
38
+ * Keyed by the constructor it was built with so a replaced global (a spec installing its own
39
+ * fake, a polyfill arriving late) is picked up instead of observing through a stale one.
40
+ */
41
+ let shared: { ctor: typeof IntersectionObserver; observer: IntersectionObserver } | null = null;
42
+ const listeners = new Map<Element, Set<Listener>>();
43
+
44
+ /**
45
+ * The environment's `IntersectionObserver`, but only one that can actually REPORT.
46
+ *
47
+ * 🔴 happy-dom 20 ships an inert stub — `observe()` is an empty method and the callback is
48
+ * never called — so "is it defined" is the wrong test: every tile would wait for a
49
+ * notification that cannot arrive and no preview would ever start, in this repo's specs and in
50
+ * every consumer's. The real interface carries `thresholds` as a readonly attribute on its
51
+ * PROTOTYPE (every engine since 2019); the stub does not. An observer without it is treated as
52
+ * absent, which degrades to the eager behavior every gallery had before this gate.
53
+ */
54
+ function reportingObserver(): typeof IntersectionObserver | null {
55
+ const ctor = (globalThis as { IntersectionObserver?: typeof IntersectionObserver })
56
+ .IntersectionObserver;
57
+ if (typeof ctor !== 'function') return null;
58
+ return 'thresholds' in ctor.prototype ? ctor : null;
59
+ }
60
+
61
+ function sharedObserver(): IntersectionObserver | null {
62
+ const ctor = reportingObserver();
63
+ if (!ctor) return null;
64
+ if (shared?.ctor === ctor) return shared.observer;
65
+ shared?.observer.disconnect();
66
+ const observer = new ctor((entries) => {
67
+ for (const entry of entries) {
68
+ const set = listeners.get(entry.target);
69
+ if (!set) continue;
70
+ for (const listener of set) listener(entry.isIntersecting);
71
+ }
72
+ });
73
+ shared = { ctor, observer };
74
+ // Anything already registered moves to the new observer rather than going deaf.
75
+ for (const el of listeners.keys()) observer.observe(el);
76
+ return observer;
77
+ }
78
+
79
+ /**
80
+ * True while the element intersects the viewport (clipped by its scrolling ancestors, which
81
+ * is what an `IntersectionObserver` with no root measures).
82
+ *
83
+ * 🔴 **No `IntersectionObserver` means VISIBLE**, not hidden. jsdom and old browsers have none,
84
+ * happy-dom has one that never reports (see {@link reportingObserver}), and a gallery whose previews silently never start there is a worse failure than
85
+ * one that loads eagerly — the eager load is the behavior every one of them had before this.
86
+ *
87
+ * `enabled: false` observes nothing and reports false, so a gallery that never asked for
88
+ * automatic previews registers no element at all.
89
+ */
90
+ export function useInView(ref: { current: Element | null }, enabled: boolean): boolean {
91
+ const [inView, setInView] = useState(false);
92
+ useEffect(() => {
93
+ if (!enabled) {
94
+ setInView(false);
95
+ return;
96
+ }
97
+ const el = ref.current;
98
+ const observer = sharedObserver();
99
+ if (!el || !observer) {
100
+ setInView(true);
101
+ return;
102
+ }
103
+ const listener: Listener = (next) => setInView(next);
104
+ let set = listeners.get(el);
105
+ if (!set) {
106
+ set = new Set();
107
+ listeners.set(el, set);
108
+ observer.observe(el);
109
+ }
110
+ set.add(listener);
111
+ return () => {
112
+ const current = listeners.get(el);
113
+ if (!current) return;
114
+ current.delete(listener);
115
+ if (current.size === 0) {
116
+ listeners.delete(el);
117
+ shared?.observer.unobserve(el);
118
+ }
119
+ };
120
+ // `ref` is a ref object and is stable by construction.
121
+ }, [enabled, ref]);
122
+ return enabled && inView;
123
+ }
124
+
125
+ /**
126
+ * `value`, but it only turns TRUE after holding true for `ms` — and turns false at once.
127
+ *
128
+ * The asymmetry is the point: starting a load is what costs, stopping one is what saves, so a
129
+ * flicker of `true` (a tile crossing the viewport during a scroll) never reaches the load and
130
+ * a `false` is never delayed.
131
+ */
132
+ export function useSettled(value: boolean, ms: number = PREVIEW_SETTLE_MS): boolean {
133
+ const [settled, setSettled] = useState(false);
134
+ useEffect(() => {
135
+ if (!value) {
136
+ setSettled(false);
137
+ return;
138
+ }
139
+ const timer = setTimeout(() => setSettled(true), ms);
140
+ return () => clearTimeout(timer);
141
+ }, [value, ms]);
142
+ return value && settled;
143
+ }
144
+
145
+ /**
146
+ * Close a `<video>`'s connection. Removing the element from the DOM does not — it keeps
147
+ * buffering until it is collected — so emptying `src` and calling `load()` is the documented
148
+ * way to make the browser drop the request (WHATWG media "emptied" / load algorithm).
149
+ * Tolerates a missing element and a `load` the environment does not implement.
150
+ */
151
+ export function releaseVideo(el: HTMLVideoElement | null): void {
152
+ if (!el) return;
153
+ try {
154
+ el.pause?.();
155
+ el.removeAttribute('src');
156
+ el.load?.();
157
+ } catch {
158
+ // A DOM without media support has no connection to close.
159
+ }
160
+ }
@@ -37,6 +37,7 @@ import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
37
37
  import { CATEGORY_LABEL } from '../components/FileTypeIcon.js';
38
38
  import { useResizeObserver } from '../hooks/useResizeObserver.js';
39
39
  import { useCanHover, useScrubPreview } from '../media/HoverScrubVideoThumb.js';
40
+ import { releaseVideo, useInView, useSettled } from '../media/previewGate.js';
40
41
  import { createdLabel, type MediaCache, ThumbImg, useItemMedia } from './galleryItemMedia.js';
41
42
  import { galleryItemCategory } from './mediaGalleryModel.js';
42
43
  import type { MediaGalleryItem, MediaGalleryOrder } from './types.js';
@@ -411,12 +412,19 @@ function TableVideoPreview({
411
412
  }) {
412
413
  const ref = useRef<HTMLVideoElement | null>(null);
413
414
  useScrubPreview(ref, { active, durationSeconds: item.durationSeconds });
415
+ // Removing the element does not close its connection; emptying its source does.
416
+ useEffect(() => {
417
+ const el = ref.current;
418
+ return () => releaseVideo(el);
419
+ }, []);
414
420
  return (
415
421
  // biome-ignore lint/a11y/useMediaCaption: a muted 56px preview of an arbitrary user file
416
422
  <video
417
423
  ref={ref}
418
424
  src={src}
419
425
  poster={poster}
426
+ // Mounted only once previewing, so metadata is the most it fetches before it plays.
427
+ preload='metadata'
420
428
  muted
421
429
  loop
422
430
  autoPlay
@@ -477,7 +485,11 @@ function GalleryTableRow({
477
485
  const previewSrc = category === 'video' ? (media.streamUrl ?? media.url) : undefined;
478
486
  // `autoPreview` is suppressed on a coarse pointer exactly as the grid tile suppresses it, so
479
487
  // a phone never opens forty video connections under a finger. A deliberate hover still does.
480
- const previewing = previewSrc != null && ((autoPreview && canHover) || hovering);
488
+ // 🔴 And it waits until the row is on screen and has SETTLED there (`previewGate.ts`), the
489
+ // same gate the grid tile uses — a table scrolled past fast must not open a stream per row.
490
+ const thumbRef = useRef<HTMLButtonElement | null>(null);
491
+ const autoReady = useSettled(useInView(thumbRef, previewSrc != null && autoPreview && canHover));
492
+ const previewing = previewSrc != null && (autoReady || hovering);
481
493
 
482
494
  return (
483
495
  <tr
@@ -523,6 +535,7 @@ function GalleryTableRow({
523
535
  <button
524
536
  // guardrails-ignore no-raw-action-button: the row's own open affordance; see above.
525
537
  type='button'
538
+ ref={thumbRef}
526
539
  className='cbgt-open cbgt-open--thumb'
527
540
  onClick={onOpen}
528
541
  aria-label={`Open ${item.name}`}
@@ -166,6 +166,50 @@ describe('MediaGallery browse grid', () => {
166
166
  );
167
167
  expect(calls).toBe(1);
168
168
  });
169
+
170
+ test('🔴 grid tiles AND table rows draw their pictures lazy + async (the load ceiling)', () => {
171
+ const { getByRole, container } = render(<MediaGallery items={ITEMS} />);
172
+ const gridImgs = [...container.querySelectorAll('.cbgd-photo-tile img')];
173
+ expect(gridImgs.length).toBeGreaterThan(0);
174
+ for (const img of gridImgs) {
175
+ expect(img.getAttribute('loading')).toBe('lazy');
176
+ expect(img.getAttribute('decoding')).toBe('async');
177
+ }
178
+ fireEvent.click(getByRole('button', { name: /Table/ }));
179
+ const rowImgs = [...container.querySelectorAll('.cbgt-root img')];
180
+ expect(rowImgs.length).toBeGreaterThan(0);
181
+ for (const img of rowImgs) {
182
+ expect(img.getAttribute('loading')).toBe('lazy');
183
+ expect(img.getAttribute('decoding')).toBe('async');
184
+ }
185
+ });
186
+
187
+ test('🔴 toggling grid → table mid-resolve neither aborts nor re-asks (one shared entry)', async () => {
188
+ const signals: AbortSignal[] = [];
189
+ let answer: ((m: { url: string; thumbnailUrl: string }) => void) | undefined;
190
+ const lazy: MediaGalleryItem = {
191
+ id: 'lazy2',
192
+ name: 'slow.png',
193
+ resolveMedia: (signal) =>
194
+ new Promise((resolve) => {
195
+ if (signal) signals.push(signal);
196
+ answer = resolve;
197
+ }),
198
+ };
199
+ const { getByRole, container } = render(<MediaGallery items={[lazy]} />);
200
+ expect(signals).toHaveLength(1);
201
+ // Every grid tile unmounts before any table row mounts — the moment a naive refcount aborts.
202
+ fireEvent.click(getByRole('button', { name: /Table/ }));
203
+ await new Promise((resolve) => setTimeout(resolve, 0));
204
+ expect(signals).toHaveLength(1);
205
+ expect(signals[0]?.aborted).toBe(false);
206
+ answer?.({ url: '/resolved/lazy2', thumbnailUrl: '/resolved-thumb/lazy2' });
207
+ await waitFor(() =>
208
+ expect(container.querySelector('.cbgt-root img')?.getAttribute('src')).toBe(
209
+ '/resolved-thumb/lazy2',
210
+ ),
211
+ );
212
+ });
169
213
  });
170
214
 
171
215
  describe('MediaGallery multi-select (opt-in)', () => {
@@ -1723,13 +1767,16 @@ describe('🔴 Preview all reaches the TABLE, not only the grid', () => {
1723
1767
  fireEvent.click(view.getByRole('button', { name: /Table/ }));
1724
1768
  };
1725
1769
 
1726
- test('a video row swaps its still for a live preview when the toggle is on', () => {
1770
+ test('a video row swaps its still for a live preview when the toggle is on', async () => {
1727
1771
  const view = render(<MediaGallery items={ITEMS} />);
1728
1772
  showTable(view);
1729
1773
  // Off: every row is a still, the film's included.
1730
1774
  expect(view.container.querySelector('.cbgt-root video')).toBeNull();
1731
1775
 
1732
1776
  fireEvent.click(view.getByRole('button', { name: /Preview all/ }));
1777
+ // Not at once: an automatic preview waits for the row to SETTLE (`previewGate.ts`).
1778
+ expect(view.container.querySelector('.cbgt-root video')).toBeNull();
1779
+ await waitFor(() => expect(view.container.querySelector('.cbgt-root video')).not.toBeNull());
1733
1780
  const preview = view.container.querySelector('.cbgt-root video') as HTMLVideoElement;
1734
1781
  expect(preview).not.toBeNull();
1735
1782
  expect(preview.getAttribute('src')).toBe('/files/vid1');
@@ -1743,12 +1790,14 @@ describe('🔴 Preview all reaches the TABLE, not only the grid', () => {
1743
1790
  fireEvent.click(view.getByRole('button', { name: /Preview all/ }));
1744
1791
  });
1745
1792
 
1746
- test('a STILL row is untouched by the toggle', () => {
1793
+ test('a STILL row is untouched by the toggle', async () => {
1747
1794
  const view = render(<MediaGallery items={ITEMS} />);
1748
1795
  showTable(view);
1749
1796
  fireEvent.click(view.getByRole('button', { name: /Preview all/ }));
1750
1797
  // beach.jpg and report.pdf keep their images; only the film becomes a `<video>`.
1751
- expect(view.container.querySelectorAll('.cbgt-root video')).toHaveLength(1);
1798
+ await waitFor(() =>
1799
+ expect(view.container.querySelectorAll('.cbgt-root video')).toHaveLength(1),
1800
+ );
1752
1801
  fireEvent.click(view.getByRole('button', { name: /Preview all/ }));
1753
1802
  });
1754
1803
 
@@ -735,7 +735,15 @@ function DocThumb({
735
735
  <>
736
736
  {/* `key={src}`: a new thumbnail URL is a new attempt and must not inherit the
737
737
  previous one's recorded failure. */}
738
- <img key={src} src={src} alt={alt} loading='lazy' onError={() => setFailed(true)} />
738
+ <img
739
+ key={src}
740
+ src={src}
741
+ alt={alt}
742
+ // Lazy + async like every tile picture — see `ThumbImg` for the measurement.
743
+ loading='lazy'
744
+ decoding='async'
745
+ onError={() => setFailed(true)}
746
+ />
739
747
  <span className='cbmg-doc-badge'>
740
748
  <FileTypeIcon category={category} />
741
749
  </span>
@@ -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. 535 candidate(s) scanned.
18
+ * when this file is stale. 574 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. 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