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.
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +19 -1
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/media/HoverScrubVideoThumb.d.ts +7 -1
- package/dist/react/media/HoverScrubVideoThumb.d.ts.map +1 -1
- package/dist/react/media/HoverScrubVideoThumb.js +18 -3
- package/dist/react/media/HoverScrubVideoThumb.js.map +1 -1
- package/dist/react/media/previewGate.d.ts +36 -0
- package/dist/react/media/previewGate.d.ts.map +1 -0
- package/dist/react/media/previewGate.js +161 -0
- package/dist/react/media/previewGate.js.map +1 -0
- package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
- package/dist/react/media-gallery/GalleryTable.js +15 -3
- package/dist/react/media-gallery/GalleryTable.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +3 -1
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.d.ts +32 -1
- package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.js +64 -9
- package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +8 -1
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/styles-areas/file-tree.css +1 -1
- package/dist/styles-areas/media-gallery.css +1 -1
- package/dist/styles-areas/media.css +1 -1
- package/package.json +3 -1
- package/src/declaredImports.spec.ts +66 -0
- package/src/publicSurface.spec.ts +12 -4
- package/src/react/file-tree/FileTree.tsx +19 -1
- package/src/react/file-tree/fileTreeThumbs.spec.tsx +90 -0
- package/src/react/media/HoverScrubVideoThumb.spec.tsx +4 -3
- package/src/react/media/HoverScrubVideoThumb.tsx +25 -3
- package/src/react/media/previewGate.spec.tsx +144 -0
- package/src/react/media/previewGate.ts +160 -0
- package/src/react/media-gallery/GalleryTable.tsx +14 -1
- package/src/react/media-gallery/MediaGallery.spec.tsx +52 -3
- package/src/react/media-gallery/MediaGallery.tsx +9 -1
- package/src/react/media-gallery/galleryItemMedia.spec.tsx +143 -0
- package/src/react/media-gallery/galleryItemMedia.tsx +80 -9
- package/src/react/media-gallery/types.ts +8 -1
- package/src/styles-areas/file-tree.css +1 -1
- package/src/styles-areas/media-gallery.css +1 -1
- package/src/styles-areas/media.css +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|