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.
- 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/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/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/media-gallery.css +1 -1
- package/src/styles-areas/media.css +1 -1
- 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
|
-
|
|
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. 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
|