@buoy-gg/images 7.0.29 → 7.0.30

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/src/preset.tsx DELETED
@@ -1,35 +0,0 @@
1
- /**
2
- * Pre-configured Images tool for FloatingDevTools.
3
- *
4
- * @example
5
- * ```tsx
6
- * import { imagesToolPreset } from '@buoy-gg/images';
7
- *
8
- * <FloatingDevTools apps={[imagesToolPreset]} />
9
- * ```
10
- */
11
-
12
- import { ImagesIcon, IMAGES_ICON_COLOR } from "./components/ImagesIcon";
13
- import { ImagesModal } from "./components/ImagesModal";
14
-
15
- export const imagesToolPreset = {
16
- id: "images",
17
- name: "IMAGES",
18
- description: "Image loads, caching, failures, and oversize checks",
19
- slot: "both" as const,
20
- icon: ({ size, color }: { size: number; color?: string }) => (
21
- <ImagesIcon size={size} color={color || IMAGES_ICON_COLOR} />
22
- ),
23
- component: ImagesModal,
24
- };
25
-
26
- /** Create a custom Images tool configuration. */
27
- export function createImagesTool(
28
- options: { name?: string; description?: string } = {}
29
- ) {
30
- return {
31
- ...imagesToolPreset,
32
- name: options.name ?? imagesToolPreset.name,
33
- description: options.description ?? imagesToolPreset.description,
34
- };
35
- }
package/src/register.ts DELETED
@@ -1,17 +0,0 @@
1
- /**
2
- * Side-effect entry: installs image capture on import.
3
- *
4
- * Import this FIRST in your app entry file so the RN-core <Image> decorator
5
- * lands before React Native's Image module is evaluated:
6
- *
7
- * ```ts
8
- * import "@buoy-gg/images/register";
9
- * ```
10
- *
11
- * This module intentionally imports nothing that touches react-native's Image
12
- * (directly or transitively) before installing.
13
- */
14
-
15
- import { installImageCapture } from "./capture/install";
16
-
17
- installImageCapture();
package/src/remote.ts DELETED
@@ -1,42 +0,0 @@
1
- /**
2
- * Remote-dispatch seam for dashboard mirrors (Buoy Desktop).
3
- *
4
- * On device this stays null and every action in capture/ runs locally. A
5
- * dashboard sets a handler that forwards the action name + params over the
6
- * external-sync protocol to the SAME adapter actions the MCP uses
7
- * (sync/imagesSyncAdapter.ts), so one action vocabulary serves phone UI,
8
- * desktop UI, and LLM automation.
9
- */
10
-
11
- import { imagesState } from "./state";
12
- import type { ActionResult } from "./capture/actions";
13
-
14
- export type ImagesRemoteDispatch = (
15
- action: string,
16
- params?: Record<string, unknown>
17
- ) => Promise<unknown>;
18
-
19
- export function setRemoteImagesHandler(
20
- handler: ImagesRemoteDispatch | null
21
- ): void {
22
- imagesState().remoteDispatch = handler;
23
- }
24
-
25
- export function remoteImagesDispatch(): ImagesRemoteDispatch | null {
26
- return imagesState().remoteDispatch;
27
- }
28
-
29
- /** Coerce an adapter action's `{ ok, message }` reply into an ActionResult. */
30
- export async function remoteActionResult(
31
- call: Promise<unknown>
32
- ): Promise<ActionResult> {
33
- try {
34
- const result = (await call) as { ok?: unknown; message?: unknown } | null;
35
- return {
36
- ok: !!result?.ok,
37
- message: String(result?.message ?? ""),
38
- };
39
- } catch (error) {
40
- return { ok: false, message: String((error as Error)?.message ?? error) };
41
- }
42
- }
package/src/state.ts DELETED
@@ -1,103 +0,0 @@
1
- /**
2
- * Global singleton state for the Images tool.
3
- *
4
- * The package can get evaluated twice as two separate module instances (e.g.
5
- * the `/register` entry shim resolving to one copy while the package root
6
- * import resolves to another — lib vs src). If capture state lived in module
7
- * scope, the early copy would install the RN decorator and collect records
8
- * while the UI copy read an empty store and a false "decorator never ran"
9
- * flag. Keying all mutable state on globalThis under a well-known symbol makes
10
- * every copy operate on the same data.
11
- */
12
-
13
- import type { CaptureStatus, ImageRecord } from "./types";
14
-
15
- export interface ImagesGlobalState {
16
- // store
17
- records: ImageRecord[];
18
- byId: Map<number, ImageRecord>;
19
- nextId: number;
20
- listeners: Set<() => void>;
21
- snapshot: ImageRecord[];
22
- emitScheduled: boolean;
23
- // install flags
24
- captureInstalled: boolean;
25
- rnInstalled: boolean;
26
- expoInstalled: boolean;
27
- // rn decorator status
28
- decoratorRegistered: boolean;
29
- decoratorInvoked: boolean;
30
- attachSeen: number;
31
- // expo patch status
32
- expoAvailable: boolean;
33
- expoPatched: boolean;
34
- // actions
35
- /** recordId → pending reload request for the RN wrapper to consume. */
36
- reloadRequests: Map<number, { nonce: number; bust: boolean }>;
37
- /** Notified when a reload request is issued (wrappers re-check their nonce). */
38
- actionListeners: Set<() => void>;
39
- /** recordId → live expo-image class instance (has reloadAsync etc.). */
40
- expoInstances: Map<number, unknown>;
41
- // simulation overrides
42
- /** recordId → active override (source swap / forced error / style merge). */
43
- overrides: Map<number, unknown>;
44
- /** recordId → epoch-ms until which the locate-flash border is shown. */
45
- flashUntil: Map<number, number>;
46
- /** Per-record render version (bumped by any action touching that record). */
47
- recordVersions: Map<number, number>;
48
- /** Render version for global-mode changes (affects every image). */
49
- globalVersion: number;
50
- /** Monotonic counter feeding both version maps. */
51
- versionCounter: number;
52
- /** App-wide simulation modes. */
53
- globalModes: { network: "normal" | "offline" | "cold"; blank: boolean };
54
- // mirror mode (desktop dashboard)
55
- /** True on dashboards: local capture must never install (mirror only). */
56
- captureDisabled: boolean;
57
- /** Forwards actions to the synced device instead of running them locally. */
58
- remoteDispatch:
59
- | ((action: string, params?: Record<string, unknown>) => Promise<unknown>)
60
- | null;
61
- /** Device PixelRatio derived from the synced snapshot (layout → neededPx). */
62
- mirrorPixelRatio: number | null;
63
- /** Device-reported capture status (overrides the local probe on mirrors). */
64
- mirrorCaptureStatus: CaptureStatus | null;
65
- }
66
-
67
- const KEY = Symbol.for("buoy.images.globalState.v1");
68
-
69
- export function imagesState(): ImagesGlobalState {
70
- const g = globalThis as Record<symbol, ImagesGlobalState | undefined>;
71
- if (!g[KEY]) {
72
- g[KEY] = {
73
- records: [],
74
- byId: new Map(),
75
- nextId: 1,
76
- listeners: new Set(),
77
- snapshot: [],
78
- emitScheduled: false,
79
- captureInstalled: false,
80
- rnInstalled: false,
81
- expoInstalled: false,
82
- decoratorRegistered: false,
83
- decoratorInvoked: false,
84
- attachSeen: 0,
85
- expoAvailable: false,
86
- expoPatched: false,
87
- reloadRequests: new Map(),
88
- actionListeners: new Set(),
89
- expoInstances: new Map(),
90
- overrides: new Map(),
91
- flashUntil: new Map(),
92
- recordVersions: new Map(),
93
- globalVersion: 0,
94
- versionCounter: 0,
95
- globalModes: { network: "normal", blank: false },
96
- captureDisabled: false,
97
- remoteDispatch: null,
98
- mirrorPixelRatio: null,
99
- mirrorCaptureStatus: null,
100
- };
101
- }
102
- return g[KEY];
103
- }
@@ -1,236 +0,0 @@
1
- /**
2
- * imageEventStore
3
- *
4
- * Bounded buffer of ImageRecords fed by the capture layer (installRnImage /
5
- * installExpoImage). Follows the console tool's store shape — Set of
6
- * listeners, frame-coalesced emit so bursty image grids don't turn every
7
- * event into its own React commit — but ALL mutable state lives on the
8
- * globalThis singleton (see ../state.ts) so a dual module instance of this
9
- * package still reads/writes one shared store.
10
- */
11
-
12
- import { PixelRatio } from "react-native";
13
- import { remoteImagesDispatch } from "../remote";
14
- import { imagesState } from "../state";
15
- import type { ImageRecord, ImageStats, ImagesSnapshotPayload } from "../types";
16
-
17
- /** Max records retained before the oldest are dropped. */
18
- const MAX_RECORDS = 500;
19
-
20
- function emit(): void {
21
- const s = imagesState();
22
- if (s.emitScheduled) return;
23
- s.emitScheduled = true;
24
- requestAnimationFrame(() => {
25
- s.emitScheduled = false;
26
- for (const listener of s.listeners) listener();
27
- });
28
- }
29
-
30
- /** Snapshot identity only changes on emit, so useSyncExternalStore stays cheap. */
31
- function invalidate(): void {
32
- const s = imagesState();
33
- s.snapshot = [...s.records];
34
- emit();
35
- }
36
-
37
- export function createRecord(
38
- partial: Omit<ImageRecord, "id" | "createdAt" | "loadCount" | "mounted" | "status">
39
- ): ImageRecord {
40
- const s = imagesState();
41
- const record: ImageRecord = {
42
- ...partial,
43
- id: s.nextId++,
44
- createdAt: Date.now(),
45
- status: "pending",
46
- mounted: true,
47
- loadCount: 0,
48
- };
49
- s.records.push(record);
50
- s.byId.set(record.id, record);
51
- if (s.records.length > MAX_RECORDS) {
52
- const dropped = s.records.splice(0, s.records.length - MAX_RECORDS);
53
- for (const d of dropped) s.byId.delete(d.id);
54
- }
55
- invalidate();
56
- return record;
57
- }
58
-
59
- /** Mutate a record in place (records are owned by the store) and notify. */
60
- export function updateRecord(
61
- id: number,
62
- patch: Partial<ImageRecord>
63
- ): ImageRecord | undefined {
64
- const s = imagesState();
65
- const record = s.byId.get(id);
66
- if (!record) return undefined;
67
- Object.assign(record, patch);
68
- invalidate();
69
- return record;
70
- }
71
-
72
- export function getRecord(id: number): ImageRecord | undefined {
73
- return imagesState().byId.get(id);
74
- }
75
-
76
- export function getSnapshot(): ImageRecord[] {
77
- return imagesState().snapshot;
78
- }
79
-
80
- export function subscribe(listener: () => void): () => void {
81
- const s = imagesState();
82
- s.listeners.add(listener);
83
- return () => imagesState().listeners.delete(listener);
84
- }
85
-
86
- export function clearRecords(): void {
87
- // Mirror mode: clear the device's registry too, or the next snapshot would
88
- // refill this store straight away.
89
- void remoteImagesDispatch()?.("clearRecords");
90
- const s = imagesState();
91
- // Keep records still actively loading so in-flight events don't orphan.
92
- s.records = s.records.filter(
93
- (r) => r.mounted && r.status !== "loaded" && r.status !== "error"
94
- );
95
- s.byId = new Map(s.records.map((r) => [r.id, r]));
96
- invalidate();
97
- }
98
-
99
- // ---------------------------------------------------------------------------
100
- // Mirror mode (desktop dashboard)
101
- // ---------------------------------------------------------------------------
102
-
103
- /**
104
- * Dashboards never instrument their own images — the store is purely a mirror
105
- * of the synced device. Makes installImageCapture() a permanent no-op.
106
- */
107
- export function disableImagesCapture(): void {
108
- imagesState().captureDisabled = true;
109
- }
110
-
111
- /**
112
- * Replace the whole store from a synced adapter snapshot (wire records are
113
- * newest-first; the store keeps oldest-first and useImageRecords reverses).
114
- * Also adopts the device's global modes / capture status and derives the
115
- * DEVICE's PixelRatio so oversize math doesn't use the dashboard display's.
116
- */
117
- export function replaceImagesFromSnapshot(payload: ImagesSnapshotPayload): void {
118
- const s = imagesState();
119
- const wire = payload.records ?? [];
120
-
121
- // neededPx was computed on device as round(layout * PixelRatio.get()), so
122
- // the widest record recovers the device scale with the least rounding error.
123
- let bestWidth = 0;
124
- for (const w of wire) {
125
- if (w.layout && w.neededPx && w.layout.width > bestWidth) {
126
- bestWidth = w.layout.width;
127
- s.mirrorPixelRatio = w.neededPx.width / w.layout.width;
128
- }
129
- }
130
-
131
- const now = Date.now();
132
- s.records = [...wire].reverse().map((w) => ({
133
- id: w.id,
134
- lib: w.lib,
135
- uri: w.uri,
136
- sourceKind: w.kind,
137
- status: w.status,
138
- mounted: w.mounted,
139
- createdAt: now - w.ageMs,
140
- durationMs: w.ms ?? undefined,
141
- intrinsic: w.intrinsic ?? undefined,
142
- layout: w.layout ?? undefined,
143
- cacheVerdict: w.cache ?? undefined,
144
- cacheVerdictSource: w.cacheSource ?? undefined,
145
- progressSeen: w.progressSeen,
146
- // The wire carries only the total; a settled download loaded all of it.
147
- bytesLoaded: w.status === "loaded" ? (w.bytesTotal ?? undefined) : undefined,
148
- bytesTotal: w.bytesTotal ?? undefined,
149
- error: w.error ?? undefined,
150
- errorCode: w.errorCode ?? undefined,
151
- loadCount: w.loadCount,
152
- overrideLabel: w.overrideLabel ?? undefined,
153
- hasAltText: w.hasAltText ?? undefined,
154
- layoutShifts: w.layoutShifts,
155
- }));
156
- s.byId = new Map(s.records.map((r) => [r.id, r]));
157
- if (payload.globalModes) s.globalModes = { ...payload.globalModes };
158
- if (payload.captureStatus) s.mirrorCaptureStatus = payload.captureStatus;
159
- invalidate();
160
- // Mode toggles render through the actions bus, not the record store.
161
- for (const l of s.actionListeners) l();
162
- }
163
-
164
- // ---------------------------------------------------------------------------
165
- // Derived math (shared by rows, detail view, and stats)
166
- // ---------------------------------------------------------------------------
167
-
168
- /** Device PixelRatio on device; the SYNCED device's ratio on a mirror. */
169
- export function effectivePixelRatio(): number {
170
- return imagesState().mirrorPixelRatio ?? PixelRatio.get();
171
- }
172
-
173
- /** Physical pixels actually needed to fill the laid-out view 1:1. */
174
- export function neededPixels(record: ImageRecord): { width: number; height: number } | null {
175
- if (!record.layout || record.layout.width <= 0 || record.layout.height <= 0) return null;
176
- const scale = effectivePixelRatio();
177
- return {
178
- width: Math.round(record.layout.width * scale),
179
- height: Math.round(record.layout.height * scale),
180
- };
181
- }
182
-
183
- /**
184
- * Linear oversize factor: how many times larger (per axis, area-normalized)
185
- * the decoded bitmap is vs what the view needs. 1 = perfect, 2 = 4× the pixels.
186
- */
187
- export function oversizeFactor(record: ImageRecord): number | null {
188
- const needed = neededPixels(record);
189
- if (!needed || !record.intrinsic) return null;
190
- const neededArea = needed.width * needed.height;
191
- const intrinsicArea = record.intrinsic.width * record.intrinsic.height;
192
- if (neededArea <= 0 || intrinsicArea <= 0) return null;
193
- return Math.sqrt(intrinsicArea / neededArea);
194
- }
195
-
196
- /** Estimated decoded-bitmap memory for a loaded record (RGBA8888). */
197
- export function estDecodedBytes(record: ImageRecord): number {
198
- if (!record.intrinsic) return 0;
199
- return record.intrinsic.width * record.intrinsic.height * 4;
200
- }
201
-
202
- /** Decoded bytes beyond what the laid-out size needs (0 if not oversized). */
203
- export function estWastedBytes(record: ImageRecord): number {
204
- const needed = neededPixels(record);
205
- if (!needed || !record.intrinsic) return 0;
206
- const wasted =
207
- (record.intrinsic.width * record.intrinsic.height -
208
- needed.width * needed.height) *
209
- 4;
210
- return Math.max(0, wasted);
211
- }
212
-
213
- export function computeStats(list: ImageRecord[]): ImageStats {
214
- const stats: ImageStats = {
215
- total: list.length,
216
- loading: 0,
217
- loaded: 0,
218
- errors: 0,
219
- networkLoads: 0,
220
- estDecodedBytes: 0,
221
- estWastedBytes: 0,
222
- };
223
- for (const r of list) {
224
- // Unmounted in-flight records are stale (the instance died mid-load) —
225
- // they must not count as live in-flight work.
226
- if ((r.status === "loading" || r.status === "pending") && r.mounted) stats.loading++;
227
- if (r.status === "loaded") stats.loaded++;
228
- if (r.status === "error") stats.errors++;
229
- if (r.progressSeen || r.cacheVerdict === "none") stats.networkLoads++;
230
- if (r.mounted && r.status === "loaded") {
231
- stats.estDecodedBytes += estDecodedBytes(r);
232
- stats.estWastedBytes += estWastedBytes(r);
233
- }
234
- }
235
- return stats;
236
- }
@@ -1,66 +0,0 @@
1
- /**
2
- * Cross-record insights — findings computed purely from data the registry
3
- * already holds (no extra instrumentation): duplicate simultaneous loads,
4
- * retry storms, iOS image-queue saturation, missing alt text, and
5
- * layout-shifting images.
6
- */
7
-
8
- import type { ImageInsights, ImageRecord } from "../types";
9
-
10
- /** loadCount at/above this flags render-thrash reload storms. */
11
- const RETRY_STORM_THRESHOLD = 5;
12
- /** RCTImageLoader allows 4 concurrent loading tasks on iOS. */
13
- const IOS_CONCURRENT_LOAD_LIMIT = 4;
14
-
15
- export function computeInsights(records: ImageRecord[]): ImageInsights {
16
- const byUri = new Map<string, number>();
17
- const retryStorms: ImageInsights["retryStorms"] = [];
18
- const layoutShifters: ImageInsights["layoutShifters"] = [];
19
- let missingAlt = 0;
20
- let rnLoading = 0;
21
-
22
- for (const r of records) {
23
- if (r.mounted && r.uri) {
24
- byUri.set(r.uri, (byUri.get(r.uri) ?? 0) + 1);
25
- }
26
- if (r.loadCount >= RETRY_STORM_THRESHOLD) {
27
- retryStorms.push({ id: r.id, uri: r.uri, loadCount: r.loadCount });
28
- }
29
- if ((r.layoutShifts ?? 0) > 0) {
30
- layoutShifters.push({ id: r.id, uri: r.uri, shifts: r.layoutShifts ?? 0 });
31
- }
32
- if (r.mounted && r.hasAltText === false) missingAlt++;
33
- if (r.lib === "rn" && r.mounted && r.status === "loading") rnLoading++;
34
- }
35
-
36
- const duplicates = [...byUri.entries()]
37
- .filter(([, count]) => count > 1)
38
- .map(([uri, count]) => ({ uri, count }))
39
- .sort((a, b) => b.count - a.count);
40
-
41
- return {
42
- duplicates,
43
- retryStorms: retryStorms.sort((a, b) => b.loadCount - a.loadCount),
44
- queueSaturated: rnLoading > IOS_CONCURRENT_LOAD_LIMIT,
45
- missingAlt,
46
- layoutShifters,
47
- };
48
- }
49
-
50
- /** Compact one-line chips for UI/MCP surfacing. Empty array = all clear. */
51
- export function insightChips(insights: ImageInsights): string[] {
52
- const chips: string[] = [];
53
- if (insights.duplicates.length > 0) {
54
- const extra = insights.duplicates.reduce((n, d) => n + d.count - 1, 0);
55
- chips.push(`${insights.duplicates.length} duplicate URL${insights.duplicates.length > 1 ? "s" : ""} (${extra} redundant loads)`);
56
- }
57
- if (insights.retryStorms.length > 0) {
58
- chips.push(`${insights.retryStorms.length} retry storm${insights.retryStorms.length > 1 ? "s" : ""}`);
59
- }
60
- if (insights.queueSaturated) chips.push("iOS image queue saturated (>4 loading)");
61
- if (insights.missingAlt > 0) chips.push(`${insights.missingAlt} missing alt text`);
62
- if (insights.layoutShifters.length > 0) {
63
- chips.push(`${insights.layoutShifters.length} layout-shifting image${insights.layoutShifters.length > 1 ? "s" : ""}`);
64
- }
65
- return chips;
66
- }
@@ -1,114 +0,0 @@
1
- import { afterEach, describe, expect, it } from "vitest";
2
- import { createRecord, updateRecord } from "../../store/imageEventStore";
3
- import { imagesState } from "../../state";
4
- import {
5
- SNAPSHOT_URI_INLINE_LIMIT,
6
- imagesSyncAdapter,
7
- toWireUri,
8
- } from "../imagesSyncAdapter";
9
-
10
- // Node has no rAF; the store coalesces listener notifies onto one frame.
11
- globalThis.requestAnimationFrame = ((cb: (t: number) => void) => {
12
- cb(0);
13
- return 0;
14
- }) as typeof requestAnimationFrame;
15
-
16
- afterEach(() => {
17
- const s = imagesState();
18
- s.records = [];
19
- s.byId = new Map();
20
- s.snapshot = [];
21
- s.nextId = 1;
22
- });
23
-
24
- describe("imagesSyncAdapter", () => {
25
- it("exposes version 1 and the listed actions", () => {
26
- expect(imagesSyncAdapter.version).toBe(1);
27
- expect(Object.keys(imagesSyncAdapter.actions)).toEqual(
28
- expect.arrayContaining([
29
- "list",
30
- "getDetail",
31
- "clearRecords",
32
- "setNetworkMode",
33
- ]),
34
- );
35
- });
36
-
37
- it("keeps http URIs inline and snapshots as JSON", () => {
38
- createRecord({
39
- lib: "rn",
40
- uri: "https://cdn.example.com/hero.png",
41
- sourceKind: "network",
42
- });
43
-
44
- const snapshot = imagesSyncAdapter.getSnapshot();
45
- expect(snapshot.records).toHaveLength(1);
46
- expect(snapshot.records[0]?.uri).toBe("https://cdn.example.com/hero.png");
47
- expect(snapshot.records[0]?.kind).toBe("network");
48
- expect(() => JSON.stringify(snapshot)).not.toThrow();
49
- });
50
-
51
- it("does not drop the images snapshot when a data URI is multi-MB", () => {
52
- const huge = `data:image/png;base64,${"A".repeat(3 * 1024 * 1024)}`;
53
- createRecord({ lib: "rn", uri: huge, sourceKind: "data" });
54
-
55
- const snapshot = imagesSyncAdapter.getSnapshot();
56
- expect(snapshot.records).toHaveLength(1);
57
- const wired = snapshot.records[0];
58
- expect(wired?.kind).toBe("data");
59
- expect(wired?.uri).toBe(toWireUri(huge));
60
- expect(wired?.uri.length).toBeLessThan(SNAPSHOT_URI_INLINE_LIMIT);
61
- expect(wired?.uri.startsWith("data:image/png;base64,")).toBe(true);
62
- expect(() => JSON.stringify(snapshot)).not.toThrow();
63
- expect(JSON.stringify(snapshot).length).toBeLessThan(2 * 1024 * 1024);
64
- });
65
-
66
- it("stubs data URIs in insights as well as records", () => {
67
- const huge = `data:image/png;base64,${"B".repeat(80 * 1024)}`;
68
- createRecord({ lib: "rn", uri: huge, sourceKind: "data" });
69
- createRecord({ lib: "rn", uri: huge, sourceKind: "data" });
70
-
71
- const snapshot = imagesSyncAdapter.getSnapshot();
72
- expect(snapshot.insights.duplicates).toHaveLength(1);
73
- expect(snapshot.insights.duplicates[0]?.count).toBe(2);
74
- expect(snapshot.insights.duplicates[0]?.uri).toBe(toWireUri(huge));
75
- expect(snapshot.insights.duplicates[0]?.uri.length).toBeLessThan(
76
- SNAPSHOT_URI_INLINE_LIMIT,
77
- );
78
- expect(JSON.stringify(snapshot).length).toBeLessThan(2 * 1024 * 1024);
79
- });
80
-
81
- it("caps native error messages that embed a data URI", () => {
82
- const huge = `data:image/png;base64,${"C".repeat(3 * 1024 * 1024)}`;
83
- const record = createRecord({
84
- lib: "rn",
85
- uri: "https://cdn.example.com/hero.png",
86
- sourceKind: "network",
87
- });
88
- updateRecord(record.id, {
89
- status: "error",
90
- error: `Failed to load ${huge}`,
91
- });
92
-
93
- const snapshot = imagesSyncAdapter.getSnapshot();
94
- const wired = snapshot.records[0];
95
- expect(wired?.error).toBeTruthy();
96
- expect(wired?.error?.length).toBeLessThan(SNAPSHOT_URI_INLINE_LIMIT + 80);
97
- expect(wired?.error?.startsWith("Failed to load data:image/png;base64,")).toBe(
98
- true,
99
- );
100
- expect(imagesState().byId.get(record.id)?.error?.length).toBeGreaterThan(
101
- 2 * 1024 * 1024,
102
- );
103
- expect(() => JSON.stringify(snapshot)).not.toThrow();
104
- expect(JSON.stringify(snapshot).length).toBeLessThan(2 * 1024 * 1024);
105
- });
106
-
107
- it("does not hang on empty action params", () => {
108
- expect(imagesSyncAdapter.actions.list()).toEqual(
109
- expect.objectContaining({ total: 0, records: [] }),
110
- );
111
- expect(() => imagesSyncAdapter.actions.getDetail()).toThrow(/id/);
112
- expect(() => imagesSyncAdapter.actions.getDetail({})).toThrow(/id/);
113
- });
114
- });