react-x11 2.29.0 → 2.31.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.
@@ -529,7 +529,9 @@ function PaneHostView({
529
529
  }
530
530
  let host;
531
531
  try {
532
- host = app.createPaneHost(wnd);
532
+ // `send` is for the windows the pane makes besides its own, which a
533
+ // backend whose pane process cannot show one shows here (Cocoa's)
534
+ host = app.createPaneHost(wnd, { send: (msg) => s.trySend?.(msg) });
533
535
  } catch (err) {
534
536
  embedErrorRef.current?.(err);
535
537
  return undefined;
@@ -539,17 +541,44 @@ function PaneHostView({
539
541
  // The pane's size is this box's laid-out size, told to the pane whenever
540
542
  // it changes — the same absolutize hook a <foreign> child window uses
541
543
  // (src/foreignnodes.js), because onViewport fires only for scrollers.
542
- let sentSize = null;
544
+ //
545
+ // And where the box is on the screen, in device pixels (`screen`): the
546
+ // pane has no window of its own to ask, and a popup it anchors to one of
547
+ // its nodes is placed against it (`windowOrigin`, src/anchor.js) — a
548
+ // dropdown in a pane opened from the screen's corner without it. It
549
+ // moves when the box does and when the window does, so it is sent on
550
+ // layout, on a scroll that shifts the box, and on every anchor change
551
+ // of the window, which a move of the window is.
552
+ let sent = null;
543
553
  const syncSize = () => {
544
554
  host.setRect(node.abs);
545
555
  const sc = node.scale ?? 1;
546
556
  const width = Math.max(1, Math.round(node.abs.width / sc));
547
557
  const height = Math.max(1, Math.round(node.abs.height / sc));
548
- if (sentSize && sentSize.width === width && sentSize.height === height) {
558
+ const origin = node.root?.window?._screenOrigin;
559
+ const screen = origin
560
+ ? {
561
+ x: Math.round(origin.x + node.abs.x),
562
+ y: Math.round(origin.y + node.abs.y),
563
+ }
564
+ : null;
565
+ if (
566
+ sent &&
567
+ sent.width === width &&
568
+ sent.height === height &&
569
+ sent.screen?.x === screen?.x &&
570
+ sent.screen?.y === screen?.y
571
+ ) {
549
572
  return;
550
573
  }
551
- sentSize = { width, height };
552
- s.trySend?.({ type: 'pane-rect', width, height, scale: sc });
574
+ sent = { width, height, screen };
575
+ s.trySend?.({
576
+ type: 'pane-rect',
577
+ width,
578
+ height,
579
+ scale: sc,
580
+ ...(screen && { screen }),
581
+ });
553
582
  };
554
583
  const origAbsolutize = node.absolutize.bind(node);
555
584
  node.absolutize = (ox, oy) => {
@@ -560,13 +589,35 @@ function PaneHostView({
560
589
  if (origShift) {
561
590
  node._shiftAbs = (dx, dy) => {
562
591
  origShift(dx, dy);
563
- host.setRect(node.abs);
592
+ syncSize();
564
593
  };
565
594
  }
595
+ const offAnchor = node.root?.onAnchorChange?.(syncSize);
566
596
  if (node.abs?.width) syncSize();
567
597
 
598
+ // The window's focus, which the pane has no window to hear: a menu it
599
+ // keeps open on the strength of the window having it closes when the
600
+ // user goes to another application (`useDismissOnWindowBlur`).
601
+ const offFocus = node.root?.onWindowFocusChange?.((focused) =>
602
+ s.trySend?.({
603
+ type: 'pane-event',
604
+ name: focused ? 'focus' : 'blur',
605
+ ev: { buttons: 0, time: Date.now() },
606
+ }),
607
+ );
608
+
568
609
  const off = s.transport.onMessage((msg) => {
569
- if (msg?.type === 'pane-present') {
610
+ if (msg?.type === 'pane-window') {
611
+ host.paneWindow?.(msg);
612
+ } else if (msg?.type === 'pane-menu') {
613
+ host.popUpMenu?.(msg);
614
+ } else if (msg?.type === 'pane-menu-cancel') {
615
+ host.cancelMenu?.(msg.menu);
616
+ } else if (msg?.type === 'pane-present' && msg.window != null) {
617
+ host.presentWindow?.(msg);
618
+ } else if (msg?.type === 'pane-cursor' && msg.window != null) {
619
+ host.windowCursor?.(msg);
620
+ } else if (msg?.type === 'pane-present') {
570
621
  host.setRect(node.abs);
571
622
  host.present(msg.id);
572
623
  } else if (msg?.type === 'pane-cursor') {
@@ -581,6 +632,8 @@ function PaneHostView({
581
632
  });
582
633
  return () => {
583
634
  off();
635
+ offAnchor?.();
636
+ offFocus?.();
584
637
  node.defaultCursor = undefined;
585
638
  node.root?.events?.refreshCursor();
586
639
  node.absolutize = origAbsolutize;
@@ -0,0 +1,255 @@
1
+ // Encoded image bytes → an ntk `Image` of straight RGBA, through the best
2
+ // decoder this runtime has. A ladder, tried in order:
3
+ //
4
+ // 1. The runtime's own: `Bun.Image` under Bun. libjpeg-turbo, spng and
5
+ // libwebp linked into the runtime, GIF and BMP everywhere, and TIFF,
6
+ // HEIC and AVIF through the system's codecs on macOS and Windows — all
7
+ // run on a worker thread. That is the reason to prefer it even for
8
+ // the formats the next rung can do: a 1080p JPEG costs the JavaScript
9
+ // thread ~7ms here against ~110ms inside jpeg-js (a 1080p PNG, ~5ms
10
+ // against ~150ms in pngjs), and a decode on the JavaScript thread is
11
+ // frames nobody gets. Found by its constructor, never by asking which
12
+ // runtime this is.
13
+ // 2. JavaScript. ntk's PNG and JPEG decoders (pngjs, jpeg-js), which are
14
+ // synchronous, and WebP through image-in-browser's decoder — imported
15
+ // the first time a WebP arrives, because evaluating it takes ~140ms
16
+ // that an app showing no WebP should never pay at startup.
17
+ //
18
+ // Two things here are easy to undo:
19
+ //
20
+ // - `Bun.Image` has no raw-pixel output: every terminal re-encodes. The
21
+ // cheapest round trip is a PNG at compression level 0 — stored deflate
22
+ // blocks, every row filtered None — which `storedPngImage` unwraps with
23
+ // one native inflate and a row copy. Reading that PNG with pngjs instead
24
+ // costs 80-130ms of the JavaScript thread at 1080p — all of what moving
25
+ // the decode off it saved.
26
+ // - Bun's WebP decode is libwebp's still-image decoder, which refuses an
27
+ // animation outright (Bun 1.4.0). An animated WebP that Bun turns down
28
+ // goes to the JavaScript decoder for its first frame, which is what an
29
+ // `<image>` shows of any animation; asking Bun first keeps a later Bun that
30
+ // can do it on the fast rung.
31
+ import { Image, decodeImage } from 'ntk/image';
32
+
33
+ /** `Bun.Image`, where the runtime has one. Read per call: it is one
34
+ * property lookup, and a cached answer would hide a test's stand-in. */
35
+ function runtimeDecoder() {
36
+ const decoder = globalThis.Bun?.Image;
37
+ return typeof decoder === 'function' ? decoder : null;
38
+ }
39
+
40
+ const ascii = (bytes, at, text) => {
41
+ for (let i = 0; i < text.length; i++) {
42
+ if (bytes[at + i] !== text.charCodeAt(i)) return false;
43
+ }
44
+ return true;
45
+ };
46
+
47
+ /** ISO-BMFF major brands of a HEIF still image; AVIF is told apart first. */
48
+ const HEIF_BRANDS = new Set(['heic', 'heix', 'heim', 'heis', 'mif1', 'msf1']);
49
+
50
+ /**
51
+ * The format a buffer's first bytes name, or null. The bytes, never a file
52
+ * extension, say what an image is — which is how Bun and ntk tell too.
53
+ */
54
+ export function imageFormat(bytes) {
55
+ if (bytes.length >= 8 && ascii(bytes, 1, 'PNG') && bytes[0] === 0x89) {
56
+ return 'png';
57
+ }
58
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8) {
59
+ return 'jpeg';
60
+ }
61
+ if (
62
+ bytes.length >= 12 &&
63
+ ascii(bytes, 0, 'RIFF') &&
64
+ ascii(bytes, 8, 'WEBP')
65
+ ) {
66
+ return 'webp';
67
+ }
68
+ if (bytes.length >= 6 && ascii(bytes, 0, 'GIF8')) return 'gif';
69
+ if (bytes.length >= 14 && ascii(bytes, 0, 'BM')) return 'bmp';
70
+ if (
71
+ bytes.length >= 4 &&
72
+ ((ascii(bytes, 0, 'II') && bytes[2] === 42 && bytes[3] === 0) ||
73
+ (ascii(bytes, 0, 'MM') && bytes[2] === 0 && bytes[3] === 42))
74
+ ) {
75
+ return 'tiff';
76
+ }
77
+ if (bytes.length >= 12 && ascii(bytes, 4, 'ftyp')) {
78
+ if (ascii(bytes, 8, 'avif') || ascii(bytes, 8, 'avis')) return 'avif';
79
+ const brand = String.fromCharCode(...bytes.subarray(8, 12));
80
+ if (HEIF_BRANDS.has(brand)) return 'heic';
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /** An extended-format WebP whose VP8X chunk says it is animated. */
86
+ export function isAnimatedWebP(bytes) {
87
+ return (
88
+ bytes.length >= 21 &&
89
+ ascii(bytes, 0, 'RIFF') &&
90
+ ascii(bytes, 8, 'WEBP') &&
91
+ ascii(bytes, 12, 'VP8X') &&
92
+ (bytes[20] & 0x02) !== 0
93
+ );
94
+ }
95
+
96
+ const NAMES = {
97
+ png: 'PNG',
98
+ jpeg: 'JPEG',
99
+ webp: 'WebP',
100
+ gif: 'GIF',
101
+ bmp: 'BMP',
102
+ tiff: 'TIFF',
103
+ heic: 'HEIC',
104
+ avif: 'AVIF',
105
+ };
106
+
107
+ const hex = (bytes) =>
108
+ Array.from(bytes.subarray(0, 8), (b) => b.toString(16).padStart(2, '0'))
109
+ .join(' ')
110
+ .trim();
111
+
112
+ /**
113
+ * Decode encoded bytes: an `Image`, or a promise of one where the decode
114
+ * runs off the JavaScript thread (Bun) or its decoder loads on demand (WebP
115
+ * elsewhere). Throws — or rejects — on bytes that are not an image this
116
+ * runtime can decode, with what to do about it.
117
+ *
118
+ * The bytes are read, never written, and under Bun they are read on another
119
+ * thread after this returns: the caller must not change them meanwhile,
120
+ * which `<image>`'s contract (a source is immutable content) already says.
121
+ */
122
+ export function decodeImageBytes(bytes) {
123
+ if (bytes.length === 0) {
124
+ throw new Error('react-x11: an empty buffer is not an image.');
125
+ }
126
+ const decoder = runtimeDecoder();
127
+ if (decoder) return decodeWithRuntime(decoder, bytes);
128
+ const format = imageFormat(bytes);
129
+ if (format === 'png' || format === 'jpeg') return decodeImage(bytes);
130
+ if (format === 'webp') return decodeWebP(bytes);
131
+ if (format) {
132
+ throw new Error(
133
+ `react-x11: ${NAMES[format]} needs a decoder this runtime does not ` +
134
+ 'have. Under Node, <image> decodes PNG, JPEG and WebP; under Bun it ' +
135
+ 'decodes GIF and BMP too, and TIFF, HEIC and AVIF on macOS and ' +
136
+ 'Windows. Convert the image, or run the app with Bun.',
137
+ );
138
+ }
139
+ throw new Error(
140
+ 'react-x11: these bytes are not an image format react-x11 recognizes ' +
141
+ `(PNG, JPEG or WebP; more under Bun) — they begin ${hex(bytes)}.`,
142
+ );
143
+ }
144
+
145
+ /** Read a file path or file URL and decode it. */
146
+ export async function loadImageFile(path) {
147
+ const { readFile } = await import('node:fs/promises');
148
+ return decodeImageBytes(await readFile(path));
149
+ }
150
+
151
+ async function decodeWithRuntime(BunImage, bytes) {
152
+ // Bun borrows the bytes on its worker thread and refuses a shared or a
153
+ // resizable buffer; those, and only those, it is handed a copy of
154
+ const input =
155
+ bytes.buffer instanceof ArrayBuffer && !bytes.buffer.resizable
156
+ ? bytes
157
+ : bytes.slice();
158
+ let png;
159
+ try {
160
+ png = await new BunImage(input).png({ compressionLevel: 0 }).bytes();
161
+ } catch (err) {
162
+ if (err?.code === 'ERR_IMAGE_DECODE_FAILED' && isAnimatedWebP(bytes)) {
163
+ return decodeWebP(bytes);
164
+ }
165
+ if (err?.code === 'ERR_IMAGE_FORMAT_UNSUPPORTED') {
166
+ const name = NAMES[imageFormat(bytes)] ?? 'This format';
167
+ throw new Error(
168
+ `react-x11: ${name} has no decoder on this machine. Bun decodes ` +
169
+ "TIFF, HEIC and AVIF through the system's codecs — ImageIO on " +
170
+ 'macOS, WIC on Windows (HEIC and AVIF need the HEIF and AV1 ' +
171
+ 'extensions from the Microsoft Store) — and Linux has none. ' +
172
+ 'Convert the image to PNG, JPEG or WebP.',
173
+ { cause: err },
174
+ );
175
+ }
176
+ throw err;
177
+ }
178
+ return storedPngImage(png) ?? decodeImage(png);
179
+ }
180
+
181
+ const IHDR = 0x49484452;
182
+ const IDAT = 0x49444154;
183
+ const IEND = 0x49454e44;
184
+
185
+ /**
186
+ * The pixels of the PNG `Bun.Image` re-encodes into, without pngjs: 8-bit
187
+ * RGBA, not interlaced, every row filtered None — what
188
+ * `png({ compressionLevel: 0 })` writes — read with one native inflate and a
189
+ * row copy. Null for any other PNG, which pngjs reads instead: correct, and
190
+ * slower, so a Bun that changes what it writes degrades rather than breaks.
191
+ */
192
+ export function storedPngImage(png) {
193
+ const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
194
+ let width = 0;
195
+ let height = 0;
196
+ const chunks = [];
197
+ let total = 0;
198
+ for (let at = 8; at + 12 <= png.length;) {
199
+ const length = view.getUint32(at);
200
+ const type = view.getUint32(at + 4);
201
+ const body = png.subarray(at + 8, at + 8 + length);
202
+ if (type === IHDR) {
203
+ width = view.getUint32(at + 8);
204
+ height = view.getUint32(at + 12);
205
+ // bit depth 8, colour type 6 (RGBA), no interlace
206
+ if (body[8] !== 8 || body[9] !== 6 || body[12] !== 0) return null;
207
+ } else if (type === IDAT) {
208
+ chunks.push(body);
209
+ total += length;
210
+ } else if (type === IEND) {
211
+ break;
212
+ }
213
+ at += 12 + length;
214
+ }
215
+ if (!width || !height || !chunks.length) return null;
216
+ let stream = chunks[0];
217
+ if (chunks.length > 1) {
218
+ stream = new Uint8Array(total);
219
+ let at = 0;
220
+ for (const chunk of chunks) {
221
+ stream.set(chunk, at);
222
+ at += chunk.length;
223
+ }
224
+ }
225
+ // windowBits 15: the zlib wrapper PNG puts around its deflate stream
226
+ const rows = globalThis.Bun.inflateSync(stream, { windowBits: 15 });
227
+ const stride = width * 4;
228
+ if (rows.length !== height * (stride + 1)) return null;
229
+ const data = new Uint8Array(height * stride);
230
+ for (let y = 0, from = 0; y < height; y++, from += stride + 1) {
231
+ if (rows[from] !== 0) return null;
232
+ data.set(rows.subarray(from + 1, from + 1 + stride), y * stride);
233
+ }
234
+ return new Image({ width, height, data });
235
+ }
236
+
237
+ let webpModule = null;
238
+
239
+ /** A WebP through image-in-browser's decoder; an animation's first frame. */
240
+ async function decodeWebP(bytes) {
241
+ webpModule ??= import('image-in-browser/lib/src/formats/webp-decoder.js');
242
+ const { WebPDecoder } = await webpModule;
243
+ // frame 0 alone: without an index it decodes every frame of an animation
244
+ // and still answers the first
245
+ const decoded = new WebPDecoder().decode({ bytes, frameIndex: 0 });
246
+ if (!decoded) {
247
+ throw new Error(
248
+ 'react-x11: these WebP bytes did not decode — the file is damaged or ' +
249
+ 'cut short.',
250
+ );
251
+ }
252
+ // order 0 is ChannelOrder.rgba; a WebP always decodes to four channels
253
+ const data = decoded.getBytes({ order: 0 });
254
+ return new Image({ width: decoded.width, height: decoded.height, data });
255
+ }
@@ -6,8 +6,9 @@
6
6
  // and the `cacheKey` cache live here, where a test needs no server; the node
7
7
  // half in nodes/image.js is only lifecycle — when to resolve, when to claim
8
8
  // damage, when to let go.
9
- import { Image, decodeImage } from 'ntk/image';
9
+ import { Image } from 'ntk/image';
10
10
 
11
+ import { decodeImageBytes } from './imagedecode.js';
11
12
  import { x11Ntk } from './ntkroot.js';
12
13
 
13
14
  /**
@@ -42,7 +43,7 @@ export function isPathImageSource(src) {
42
43
  );
43
44
  }
44
45
 
45
- /** …normalized for ntk's `loadImage`, which wants a string or a real URL. */
46
+ /** …normalized for `readFile`, which wants a string or a real URL. */
46
47
  export const toLoadablePath = (src) =>
47
48
  typeof src === 'string' || src instanceof URL ? src : new URL(src.href);
48
49
 
@@ -139,9 +140,9 @@ const describe = (value) =>
139
140
 
140
141
  /** Stated once, so every error lists the same set of accepted forms. */
141
142
  const SRC_FORMS =
142
- 'a file path or file URL (PNG/JPEG), encoded PNG/JPEG bytes (Buffer or ' +
143
- 'Uint8Array), raw RGBA ({ width, height, data }), an ntk Image/Surface, ' +
144
- "or a symbol by name ({ symbol: 'speaker.wave.3.fill' })";
143
+ 'a file path or file URL (PNG/JPEG/WebP), encoded PNG/JPEG/WebP bytes ' +
144
+ '(Buffer or Uint8Array), raw RGBA ({ width, height, data }), an ntk ' +
145
+ "Image/Surface, or a symbol by name ({ symbol: 'speaker.wave.3.fill' })";
145
146
 
146
147
  function validateServerSource(kind, desc) {
147
148
  const shape =
@@ -198,7 +199,7 @@ function validateRawSource(src) {
198
199
  'react-x11: <image src={{ width, height, data }}> wants straight ' +
199
200
  '(non-premultiplied) RGBA bytes — data.length must be ' +
200
201
  `width × height × 4 = ${want}, got ${bytes ?? describe(data)}. ` +
201
- 'For encoded PNG/JPEG bytes pass the buffer itself as `src`.',
202
+ 'For encoded PNG/JPEG/WebP bytes pass the buffer itself as `src`.',
202
203
  );
203
204
  }
204
205
  }
@@ -254,14 +255,17 @@ export function validateImageProps(props) {
254
255
  }
255
256
 
256
257
  /**
257
- * Decode a synchronous source into an ntk `Image`: encoded bytes through the
258
- * PNG/JPEG decoders, raw RGBA wrapped as-is (no copy — the object is treated
259
- * as immutable content from here on). File paths stay async and do not come
260
- * here. May throw on corrupt bytes; the caller treats that as a content
261
- * failure, because encoded bytes usually arrive from outside the program.
258
+ * Decode an in-memory source into an ntk `Image`: encoded bytes through the
259
+ * decoder ladder in imagedecode.js, raw RGBA wrapped as-is (no copy — the
260
+ * object is treated as immutable content from here on). Returns the `Image`,
261
+ * or a promise of one where the bytes decode off the JavaScript thread
262
+ * (under Bun) or their decoder loads on demand (WebP elsewhere). File paths
263
+ * do not come here. May throw or reject on bytes that do not decode; the
264
+ * caller treats that as a content failure, because encoded bytes usually
265
+ * arrive from outside the program.
262
266
  */
263
267
  export function decodeImageSource(src) {
264
- if (src instanceof Uint8Array) return decodeImage(src);
268
+ if (src instanceof Uint8Array) return decodeImageBytes(src);
265
269
  return new Image({ width: src.width, height: src.height, data: src.data });
266
270
  }
267
271
 
@@ -317,9 +321,10 @@ const sourceCaches = new WeakMap();
317
321
  /**
318
322
  * Take a hold on the entry for `key`, creating it with `load()` on first
319
323
  * use. `load` returns `{ image }` for a synchronous source or `{ promise }`
320
- * for a file read — the promise resolves an `Image` or, on content failure,
321
- * `null` (never rejects; the loader reports the failure once, not per
322
- * holder). Every acquire is paired with a `releaseImageSource`.
324
+ * for a file read or a decode that lands later — the promise resolves an
325
+ * `Image` or, on content failure, `null` (never rejects; the loader reports
326
+ * the failure once, not per holder). Every acquire is paired with a
327
+ * `releaseImageSource`.
323
328
  */
324
329
  export function acquireImageSource(app, key, load) {
325
330
  let cache = sourceCaches.get(app);
package/src/node.d.ts CHANGED
@@ -284,6 +284,11 @@ export interface Sprite {
284
284
  rect: Rect;
285
285
  /** What it draws, untransformed, when that is more than `rect`. */
286
286
  reach?: Rect;
287
+ /** Where it shows, when the element clips it: device pixels, window
288
+ * coordinates. Its layer is cut to the rect, and so it is to the clip of
289
+ * every ancestor with square corners (a rounded one has to hold all of
290
+ * it). */
291
+ clip?: Rect;
287
292
  /** Draws the part where it is in the window, as `paint` would, but at
288
293
  * opacity 1 and untransformed. */
289
294
  paint(ctx: Context2D): void;
@@ -16,11 +16,7 @@ import {
16
16
  toLoadablePath,
17
17
  validateImageProps,
18
18
  } from '../imagesource.js';
19
- // Namespace import for `Surface`, the same shape and the same reason as
20
- // `paintcache.js`: a named import of something an older ntk does not export
21
- // is a *load-time* SyntaxError, which would take the renderer down rather
22
- // than the one feature that needs it.
23
- import { loadImage } from 'ntk/image';
19
+ import { loadImageFile } from '../imagedecode.js';
24
20
  import { symbolWeight, symbolsFor, warnOnce } from '../symbols.js';
25
21
  import { intrinsicSize } from './layout.js';
26
22
  import { Node } from './node.js';
@@ -32,6 +28,18 @@ import { DEV } from './util.js';
32
28
  * both "nothing changed". */
33
29
  const IMAGE_SOURCE_PROPS = new Set(['src', 'picture', 'drawable', 'cacheKey']);
34
30
 
31
+ const BYTES_FAILED = 'react-x11: <image src> bytes did not decode:';
32
+
33
+ /** A load that never rejects: a failure is logged and resolves null. */
34
+ const reported = (promise, prefix) =>
35
+ promise.then(
36
+ (image) => image,
37
+ (err) => {
38
+ console.error(prefix, err.message);
39
+ return null;
40
+ },
41
+ );
42
+
35
43
  export class ImageNode extends Node {
36
44
  constructor(props, app) {
37
45
  super('image', props, app);
@@ -133,7 +141,7 @@ export class ImageNode extends Node {
133
141
  }
134
142
  if (cacheKey != null) {
135
143
  const entry = acquireImageSource(this.app, cacheKey, () =>
136
- this._loadEntry(src),
144
+ this._load(src),
137
145
  );
138
146
  this._hold = entry;
139
147
  if (entry.image) {
@@ -148,63 +156,54 @@ export class ImageNode extends Node {
148
156
  }
149
157
  return;
150
158
  }
151
- if (isPathImageSource(src)) {
152
- this._loadFile(src);
153
- return;
154
- }
155
- const image = this._decode(src);
159
+ const { image, promise } = this._load(src);
156
160
  if (image) {
157
161
  this._ownedImage = image;
158
162
  this.image = image;
163
+ } else if (promise) {
164
+ this._adopt(promise);
159
165
  }
160
166
  }
161
167
 
162
- /** `{ image }` or `{ promise }` for the cache. Failures resolve to null
163
- * and are reported once, here — not per node holding the key. */
164
- _loadEntry(src) {
168
+ /**
169
+ * `{ image }` for a source decoded on the spot, `{ promise }` for a file
170
+ * read or a decode that lands later — off the JavaScript thread under
171
+ * Bun, or behind a decoder loaded on demand. The promise resolves an
172
+ * Image, or null on a content failure, which is reported once, here: not
173
+ * per node holding the key.
174
+ */
175
+ _load(src) {
165
176
  if (isPathImageSource(src)) {
166
177
  return {
167
- promise: loadImage(toLoadablePath(src)).then(
168
- (image) => image,
169
- (err) => {
170
- console.error(
171
- `react-x11: failed to load image ${src}:`,
172
- err.message,
173
- );
174
- return null;
175
- },
178
+ promise: reported(
179
+ loadImageFile(toLoadablePath(src)),
180
+ `react-x11: failed to load image ${src}:`,
176
181
  ),
177
182
  };
178
183
  }
179
- return { image: this._decode(src) };
180
- }
181
-
182
- _decode(src) {
184
+ let decoded;
183
185
  try {
184
- return decodeImageSource(src);
186
+ decoded = decodeImageSource(src);
185
187
  } catch (err) {
186
188
  // corrupt or unrecognized bytes are a content failure, not a
187
189
  // programming error: log and show nothing, like a missing file
188
- console.error(
189
- 'react-x11: <image src> bytes did not decode:',
190
- err.message,
191
- );
192
- return null;
190
+ console.error(BYTES_FAILED, err.message);
191
+ return { image: null };
193
192
  }
193
+ return typeof decoded?.then === 'function'
194
+ ? { promise: reported(decoded, BYTES_FAILED) }
195
+ : { image: decoded };
194
196
  }
195
197
 
196
- async _loadFile(src) {
198
+ /** Take an Image that lands after the frame that asked for it — unless
199
+ * the source changed or the node went first, in which case it was never
200
+ * painted, so nothing was uploaded and there is nothing to free. */
201
+ async _adopt(promise) {
197
202
  const token = ++this._loadToken;
198
- try {
199
- const image = await loadImage(toLoadablePath(src));
200
- if (token !== this._loadToken || this.destroyed) return;
201
- this._ownedImage = image;
202
- this._setImage(image);
203
- } catch (err) {
204
- if (token === this._loadToken && !this.destroyed) {
205
- console.error(`react-x11: failed to load image ${src}:`, err.message);
206
- }
207
- }
203
+ const image = await promise;
204
+ if (!image || token !== this._loadToken || this.destroyed) return;
205
+ this._ownedImage = image;
206
+ this._setImage(image);
208
207
  }
209
208
 
210
209
  /** Adopt pixels that arrived after the frame that asked for them. A size
@@ -238,7 +237,7 @@ export class ImageNode extends Node {
238
237
  }
239
238
 
240
239
  _releaseSource() {
241
- this._loadToken++; // orphan any in-flight file read
240
+ this._loadToken++; // orphan any in-flight read or decode
242
241
  if (this._hold) {
243
242
  releaseImageSource(this.app, this._hold);
244
243
  this._hold = null;