@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/package.json CHANGED
@@ -1,14 +1,13 @@
1
1
  {
2
2
  "name": "@buoy-gg/images",
3
- "version": "7.0.29",
3
+ "version": "7.0.30",
4
4
  "description": "Debug every image in your React Native app — live load registry, cache verdicts (memory/disk/network), timings, oversize + wasted-memory checks, and failure diagnosis for RN <Image> and expo-image. Part of Buoy devtools.",
5
- "main": "lib/commonjs/index",
6
- "module": "lib/module/index",
5
+ "main": "lib/commonjs/index.js",
6
+ "module": "lib/module/index.js",
7
7
  "types": "lib/typescript/index.d.ts",
8
- "react-native": "src/index.tsx",
8
+ "react-native": "lib/module/index.js",
9
9
  "source": "src/index.tsx",
10
10
  "files": [
11
- "src",
12
11
  "lib",
13
12
  "register.js",
14
13
  "register.d.ts"
@@ -18,7 +17,7 @@
18
17
  "**/index.*"
19
18
  ],
20
19
  "dependencies": {
21
- "@buoy-gg/shared-ui": "^7.0.29"
20
+ "@buoy-gg/shared-ui": "^7.0.30"
22
21
  },
23
22
  "peerDependencies": {
24
23
  "expo-image": "*",
@@ -1,215 +0,0 @@
1
- /**
2
- * Per-image actions: hard reload (bypass cache) and retry.
3
- *
4
- * RN core path: the decorator wrapper owns every mounted <Image>'s props, so a
5
- * reload request re-renders the wrapper with a bumped `key` (native remount →
6
- * fresh load cycle) and, for cache-busting, `source.cache: 'reload'` injected
7
- * — VERIFIED supported on BOTH platforms (iOS NSURLRequest policy;
8
- * Android ReactImageView.kt reads source."cache" → Fresco full-fetch). The
9
- * override stays applied until the load settles, then reverts lazily on the
10
- * app's next natural re-render (avoids an extra cache round-trip).
11
- *
12
- * expo-image path: the patched class instance exposes the public
13
- * `reloadAsync()`. Cache-busting first evicts the disk entry (cache path +
14
- * expo-file-system delete) and clears expo's memory cache (global — no
15
- * per-entry memory eviction exists), so the reload is a true refetch that
16
- * rewrites both cache tiers.
17
- */
18
-
19
- import { remoteActionResult, remoteImagesDispatch } from "../remote";
20
- import { imagesState } from "../state";
21
- import { touchRecord } from "./overrides";
22
- import type { ImageRecord } from "../types";
23
-
24
- // ---------------------------------------------------------------------------
25
- // RN reload-request bus (consumed by the decorator wrapper)
26
- // ---------------------------------------------------------------------------
27
-
28
- export function subscribeActions(listener: () => void): () => void {
29
- const s = imagesState();
30
- s.actionListeners.add(listener);
31
- return () => imagesState().actionListeners.delete(listener);
32
- }
33
-
34
- export function getReloadRequest(
35
- recordId: number | null
36
- ): { nonce: number; bust: boolean } | undefined {
37
- if (recordId == null) return undefined;
38
- return imagesState().reloadRequests.get(recordId);
39
- }
40
-
41
- /** Reload nonce for a record (0 = never requested). Snapshot for the wrapper. */
42
- export function getReloadNonce(recordId: number | null): number {
43
- return getReloadRequest(recordId)?.nonce ?? 0;
44
- }
45
-
46
- function requestRnReload(recordId: number, bust: boolean): void {
47
- const s = imagesState();
48
- const prev = s.reloadRequests.get(recordId);
49
- s.reloadRequests.set(recordId, { nonce: (prev?.nonce ?? 0) + 1, bust });
50
- // touchRecord bumps the render version (the wrapper's snapshot) and emits.
51
- touchRecord(recordId);
52
- }
53
-
54
- /**
55
- * Mark a request consumed once its load settles (called from the tracker on
56
- * load/error). Silent — the wrapper reverts the prop override on the app's
57
- * next natural re-render instead of forcing an immediate one.
58
- */
59
- export function settleReloadRequest(recordId: number): void {
60
- const s = imagesState();
61
- const req = s.reloadRequests.get(recordId);
62
- if (req?.bust) {
63
- s.reloadRequests.set(recordId, { nonce: req.nonce, bust: false });
64
- }
65
- }
66
-
67
- export function dropActionState(recordId: number): void {
68
- const s = imagesState();
69
- s.reloadRequests.delete(recordId);
70
- s.expoInstances.delete(recordId);
71
- s.overrides.delete(recordId);
72
- s.flashUntil.delete(recordId);
73
- s.recordVersions.delete(recordId);
74
- }
75
-
76
- // ---------------------------------------------------------------------------
77
- // expo instance registry (populated by the render patch)
78
- // ---------------------------------------------------------------------------
79
-
80
- export function registerExpoInstance(recordId: number | null, instance: unknown): void {
81
- if (recordId == null) return;
82
- imagesState().expoInstances.set(recordId, instance);
83
- }
84
-
85
- interface ExpoImageStatics {
86
- getCachePathAsync?: (cacheKey: string) => Promise<string | null>;
87
- clearMemoryCache?: () => Promise<boolean>;
88
- }
89
-
90
- function expoImageStatics(): ExpoImageStatics | null {
91
- try {
92
- return require("expo-image").Image ?? null;
93
- } catch {
94
- return null;
95
- }
96
- }
97
-
98
- interface FileSystemModule {
99
- deleteAsync?: (uri: string, options?: { idempotent?: boolean }) => Promise<void>;
100
- }
101
-
102
- /**
103
- * expo-file-system, legacy path first (v19+/SDK 54+ moved the function API
104
- * there) then the main import — same pattern as shared-ui's persistentStorage.
105
- */
106
- function loadFileSystem(): FileSystemModule | null {
107
- try {
108
- const mod = require("expo-file-system/legacy");
109
- const fs = mod?.default || mod;
110
- if (fs?.deleteAsync) return fs;
111
- } catch {
112
- // try main import
113
- }
114
- try {
115
- const mod = require("expo-file-system");
116
- const fs = mod?.default || mod;
117
- if (fs?.deleteAsync) return fs;
118
- } catch {
119
- // not installed
120
- }
121
- return null;
122
- }
123
-
124
- /**
125
- * Delete an expo-image disk-cache entry (JS-only: cache path + file delete).
126
- * Returns true if an entry existed and was deleted.
127
- */
128
- export async function evictExpoDiskEntry(record: ImageRecord): Promise<boolean> {
129
- const statics = expoImageStatics();
130
- const fileSystem = loadFileSystem();
131
- if (!statics?.getCachePathAsync || !fileSystem?.deleteAsync || !record.uri) return false;
132
- try {
133
- const path = await statics.getCachePathAsync(record.uri);
134
- if (!path) return false;
135
- const uri = path.startsWith("file://") ? path : `file://${path}`;
136
- await fileSystem.deleteAsync(uri, { idempotent: true });
137
- return true;
138
- } catch {
139
- return false;
140
- }
141
- }
142
-
143
- // ---------------------------------------------------------------------------
144
- // High-level dispatchers (used by the detail view)
145
- // ---------------------------------------------------------------------------
146
-
147
- export type ActionResult = { ok: boolean; message: string };
148
-
149
- /** Fresh load attempt for a mounted image (no cache bypass). */
150
- export async function retryRecord(record: ImageRecord): Promise<ActionResult> {
151
- const remote = remoteImagesDispatch();
152
- if (remote) return remoteActionResult(remote("retry", { id: record.id }));
153
- if (!record.mounted) {
154
- return { ok: false, message: "Instance unmounted — cannot reload" };
155
- }
156
- if (record.lib === "expo") {
157
- const instance = imagesState().expoInstances.get(record.id) as
158
- | { reloadAsync?: () => Promise<void> }
159
- | undefined;
160
- if (!instance?.reloadAsync) {
161
- return { ok: false, message: "Live instance not reachable" };
162
- }
163
- await instance.reloadAsync();
164
- return { ok: true, message: "Reload requested" };
165
- }
166
- requestRnReload(record.id, false);
167
- return { ok: true, message: "Remounting image" };
168
- }
169
-
170
- /** Hard-reload every mounted image (mass cache-bust). Returns count started. */
171
- export async function hardReloadAllMounted(): Promise<ActionResult> {
172
- const remote = remoteImagesDispatch();
173
- if (remote) {
174
- return remoteActionResult(remote("massAction", { kind: "reload" }));
175
- }
176
- const { getSnapshot } = require("../store/imageEventStore") as {
177
- getSnapshot: () => ImageRecord[];
178
- };
179
- let count = 0;
180
- for (const record of getSnapshot()) {
181
- if (!record.mounted) continue;
182
- const result = await hardReloadRecord(record);
183
- if (result.ok) count++;
184
- }
185
- return { ok: count > 0, message: `Hard-reloading ${count} mounted image${count === 1 ? "" : "s"}` };
186
- }
187
-
188
- /** Bypass-cache reload: refetches from network and rewrites the caches. */
189
- export async function hardReloadRecord(record: ImageRecord): Promise<ActionResult> {
190
- const remote = remoteImagesDispatch();
191
- if (remote) return remoteActionResult(remote("hardReload", { id: record.id }));
192
- if (!record.mounted) {
193
- return { ok: false, message: "Instance unmounted — cannot reload" };
194
- }
195
- if (record.sourceKind !== "network") {
196
- // Local/asset sources have no HTTP cache to bust — plain remount instead.
197
- return retryRecord(record);
198
- }
199
- if (record.lib === "expo") {
200
- const evicted = await evictExpoDiskEntry(record);
201
- const statics = expoImageStatics();
202
- // No per-entry memory eviction exists — global clear is the honest option.
203
- await statics?.clearMemoryCache?.().catch?.(() => false);
204
- const result = await retryRecord(record);
205
- if (!result.ok) return result;
206
- return {
207
- ok: true,
208
- message: evicted
209
- ? "Disk entry evicted, memory cache cleared, refetching"
210
- : "Memory cache cleared, refetching (no disk entry found)",
211
- };
212
- }
213
- requestRnReload(record.id, true);
214
- return { ok: true, message: "Refetching with cache bypassed" };
215
- }
@@ -1,40 +0,0 @@
1
- /**
2
- * installImageCapture — idempotent entry point for the capture layer.
3
- *
4
- * IMPORTANT (timing): the RN-core decorator only takes effect if this runs
5
- * before anything evaluates the `Image` module from 'react-native'. The
6
- * recommended integration is `import "@buoy-gg/images/register"` as the FIRST
7
- * import of the app entry file. The expo-image patch has no such constraint.
8
- *
9
- * Idempotency is keyed on the globalThis singleton, so even if this package
10
- * gets evaluated as two module instances, capture installs exactly once.
11
- */
12
-
13
- import { imagesState } from "../state";
14
- import { installExpoImageInterceptor, expoCaptureStatus } from "./installExpoImage";
15
- import { installRnImageInterceptor, rnCaptureStatus } from "./installRnImage";
16
- import type { CaptureStatus } from "../types";
17
-
18
- export function installImageCapture(): void {
19
- const s = imagesState();
20
- // Mirror mode (desktop dashboard): never instrument the dashboard's own UI.
21
- if (s.captureDisabled || s.captureInstalled) return;
22
- s.captureInstalled = true;
23
- installRnImageInterceptor();
24
- installExpoImageInterceptor();
25
- }
26
-
27
- export function getCaptureStatus(): CaptureStatus {
28
- // Mirror mode: report the synced DEVICE's capture status, not the local probe.
29
- const mirrored = imagesState().mirrorCaptureStatus;
30
- if (mirrored) return mirrored;
31
- const rn = rnCaptureStatus();
32
- const expo = expoCaptureStatus();
33
- return {
34
- installed: imagesState().captureInstalled,
35
- rnDecoratorActive: rn.decoratorActive,
36
- rnDecoratorTooLate: rn.tooLate,
37
- expoPatched: expo.patched,
38
- expoAvailable: expo.available,
39
- };
40
- }
@@ -1,115 +0,0 @@
1
- /**
2
- * expo-image interception via class-prototype patch.
3
- *
4
- * expo-image's <Image> is a class component (React.PureComponent) whose
5
- * render() reads `this.props`, so wrapping the prototype's render and swapping
6
- * `this.props` for an instrumented copy for the duration of the original
7
- * render is timing-immune (prototype methods are looked up dynamically — no
8
- * import-order race like the RN core decorator). Props are restored in
9
- * `finally`, so PureComponent's shouldComponentUpdate still compares the
10
- * app's real props.
11
- *
12
- * Double-patch safety: install flags live on the globalThis singleton AND a
13
- * marker is stamped on the prototype itself, so even two module instances of
14
- * this package can't wrap render() twice.
15
- *
16
- * expo-image is an optional peer: if it isn't installed, this is a no-op.
17
- */
18
-
19
- import { imagesState } from "../state";
20
- import { registerExpoInstance } from "./actions";
21
- import { applyOverrides } from "./overrides";
22
- import { ImageInstanceTracker, isInternalImage } from "./shared";
23
- import type { OriginalHandlers } from "./shared";
24
-
25
- interface ExpoImageInstance {
26
- props: Record<string, unknown>;
27
- }
28
-
29
- const PATCH_MARKER = "__buoyImagesPatched";
30
-
31
- const trackers = new WeakMap<object, ImageInstanceTracker>();
32
-
33
- function trackerFor(instance: object): ImageInstanceTracker {
34
- let tracker = trackers.get(instance);
35
- if (!tracker) {
36
- tracker = new ImageInstanceTracker("expo");
37
- trackers.set(instance, tracker);
38
- }
39
- return tracker;
40
- }
41
-
42
- export function installExpoImageInterceptor(): void {
43
- const s = imagesState();
44
- if (s.expoInstalled) return;
45
- s.expoInstalled = true;
46
-
47
- let ImageClass:
48
- | {
49
- prototype: ExpoImageInstance & {
50
- render: () => unknown;
51
- componentWillUnmount?: () => void;
52
- [PATCH_MARKER]?: boolean;
53
- };
54
- }
55
- | undefined;
56
- try {
57
- ImageClass = require("expo-image").Image;
58
- } catch {
59
- s.expoAvailable = false;
60
- return; // expo-image not installed in this app
61
- }
62
- if (!ImageClass?.prototype?.render) {
63
- s.expoAvailable = false;
64
- return;
65
- }
66
- s.expoAvailable = true;
67
-
68
- const proto = ImageClass.prototype;
69
- if (proto[PATCH_MARKER]) {
70
- s.expoPatched = true;
71
- return; // already patched by another module instance of this package
72
- }
73
- proto[PATCH_MARKER] = true;
74
-
75
- const originalRender = proto.render;
76
- const originalUnmount = proto.componentWillUnmount;
77
-
78
- proto.render = function buoyImagesRender(this: ExpoImageInstance) {
79
- const originalProps = this.props;
80
- if (isInternalImage(originalProps?.testID)) {
81
- return originalRender.call(this);
82
- }
83
- const tracker = trackerFor(this);
84
- tracker.syncSource(originalProps?.source);
85
- tracker.noteA11y(
86
- Boolean(originalProps?.alt ?? originalProps?.accessibilityLabel)
87
- );
88
- // Keep the action layer pointed at the live instance (reloadAsync etc.).
89
- registerExpoInstance(tracker.getRecordId(), this);
90
- const wrapped = tracker.handlers(originalProps as OriginalHandlers);
91
- try {
92
- // Simulation layer: global modes + per-record override + locate-flash.
93
- this.props = applyOverrides(
94
- { ...originalProps, ...wrapped },
95
- tracker.getRecordId(),
96
- "expo"
97
- );
98
- return originalRender.call(this);
99
- } finally {
100
- this.props = originalProps;
101
- }
102
- };
103
-
104
- proto.componentWillUnmount = function buoyImagesUnmount(this: ExpoImageInstance) {
105
- trackers.get(this)?.unmount();
106
- originalUnmount?.call(this);
107
- };
108
-
109
- s.expoPatched = true;
110
- }
111
-
112
- export function expoCaptureStatus(): { available: boolean; patched: boolean } {
113
- const s = imagesState();
114
- return { available: s.expoAvailable, patched: s.expoPatched };
115
- }
@@ -1,133 +0,0 @@
1
- /**
2
- * RN core <Image> interception via the official injection hook.
3
- *
4
- * `unstable_setImageComponentDecorator` wraps EVERY RN <Image> app-wide — but
5
- * the decorator is latched ONCE, at Image module evaluation
6
- * (`Image.ios.js` / `Image.android.js` read it at module scope). So install
7
- * order matters: this must run before anything touches `Image` from
8
- * 'react-native' — hence the `@buoy-gg/images/register` first-import.
9
- *
10
- * Detection strategy: the decorator sets `decoratorInvoked` when RN's Image
11
- * module applies it. `unstable_registerImageAttachedCallback` is a live Set
12
- * (never latched), so image mounts are always visible — if mounts happen but
13
- * the decorator never ran, we were too late and CaptureStatus reports it.
14
- *
15
- * All flags live on the globalThis singleton (../state.ts) so a second module
16
- * instance of this package neither re-installs nor reports a stale status.
17
- */
18
-
19
- import * as React from "react";
20
- import { imagesState } from "../state";
21
- import {
22
- getReloadNonce,
23
- getReloadRequest,
24
- subscribeActions,
25
- } from "./actions";
26
- import { applyOverrides, getRenderVersion, patchSourceCache } from "./overrides";
27
- import { ImageInstanceTracker, isInternalImage } from "./shared";
28
- import type { OriginalHandlers } from "./shared";
29
-
30
- /** Wraps one RN Image render with capture instrumentation + reload actions. */
31
- function useImageCapture(props: Record<string, unknown>): {
32
- props: Record<string, unknown>;
33
- reloadKey: number;
34
- } {
35
- const trackerRef = React.useRef<ImageInstanceTracker | null>(null);
36
- if (trackerRef.current == null) {
37
- trackerRef.current = new ImageInstanceTracker("rn");
38
- }
39
- const tracker = trackerRef.current;
40
-
41
- const internal = isInternalImage(props.testID);
42
- if (!internal) {
43
- tracker.syncSource(props.source);
44
- tracker.noteA11y(
45
- Boolean(props.alt ?? props.accessibilityLabel ?? props["aria-label"])
46
- );
47
- }
48
-
49
- // Action/override bus: version bumps re-render just this instance (snapshot
50
- // is per-record with a global floor, so unrelated actions don't invalidate
51
- // other images but global-mode toggles reach everyone).
52
- React.useSyncExternalStore(subscribeActions, () =>
53
- getRenderVersion(internal ? null : tracker.getRecordId())
54
- );
55
-
56
- React.useEffect(() => {
57
- if (internal) return undefined;
58
- return () => tracker.unmount();
59
- }, [tracker, internal]);
60
-
61
- if (internal) return { props, reloadKey: 0 };
62
-
63
- const recordId = tracker.getRecordId();
64
- const wrapped = tracker.handlers(props as OriginalHandlers);
65
- const out: Record<string, unknown> = { ...props, ...wrapped };
66
-
67
- // While a bust request is pending (cleared when the load settles), refetch
68
- // bypassing caches — VERIFIED both platforms honor source.cache: 'reload'.
69
- const request = getReloadRequest(recordId);
70
- if (request?.bust) {
71
- out.source = patchSourceCache(out.source, "reload");
72
- }
73
- // Simulation layer: global modes + per-record override + locate-flash.
74
- applyOverrides(out, recordId, "rn");
75
- return { props: out, reloadKey: getReloadNonce(recordId) };
76
- }
77
-
78
- export function installRnImageInterceptor(): void {
79
- const s = imagesState();
80
- if (s.rnInstalled) return;
81
- s.rnInstalled = true;
82
-
83
- try {
84
- // Deep import: evaluates ONLY the injection module, never Image itself.
85
- const injection = require("react-native/Libraries/Image/ImageInjection");
86
-
87
- const setDecorator: undefined | ((d: unknown) => void) =
88
- injection.unstable_setImageComponentDecorator;
89
- const registerAttached: undefined | ((cb: (node: unknown) => void) => void) =
90
- injection.unstable_registerImageAttachedCallback;
91
-
92
- if (typeof setDecorator === "function") {
93
- setDecorator((BaseImage: React.ComponentType<Record<string, unknown>>) => {
94
- imagesState().decoratorInvoked = true;
95
- const CapturedImage = React.forwardRef<unknown, Record<string, unknown>>(
96
- function BuoyCapturedImage(props, ref) {
97
- const { props: instrumented, reloadKey } = useImageCapture(props);
98
- // key bump forces a native remount → fresh load cycle (retry).
99
- return <BaseImage key={`buoy-r${reloadKey}`} {...instrumented} ref={ref} />;
100
- }
101
- );
102
- return CapturedImage;
103
- });
104
- s.decoratorRegistered = true;
105
- }
106
-
107
- // Live mount signal — works regardless of decorator timing. Used purely
108
- // to detect the "decorator registered too late" condition.
109
- if (typeof registerAttached === "function") {
110
- registerAttached(() => {
111
- imagesState().attachSeen++;
112
- });
113
- }
114
- } catch {
115
- // Internal RN path moved (version drift) or non-RN platform — expo-image
116
- // capture still works; status will report the decorator as inactive.
117
- s.decoratorRegistered = false;
118
- }
119
- }
120
-
121
- export function rnCaptureStatus(): {
122
- decoratorRegistered: boolean;
123
- decoratorActive: boolean;
124
- tooLate: boolean;
125
- } {
126
- const s = imagesState();
127
- return {
128
- decoratorRegistered: s.decoratorRegistered,
129
- decoratorActive: s.decoratorInvoked,
130
- // Mounts observed but our wrapper never rendered → Image latched first.
131
- tooLate: s.decoratorRegistered && !s.decoratorInvoked && s.attachSeen > 0,
132
- };
133
- }