@omercnet/paseo-shared-browser 1.2.2-next.232.1 → 1.2.2-next.239.1

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.
Files changed (169) hide show
  1. package/README.md +312 -25
  2. package/REMOTE_BROWSER_RESEARCH.md +395 -0
  3. package/STREAMING_DESIGN.md +191 -0
  4. package/client/browser-auto-capture-density.test.ts +102 -0
  5. package/client/browser-auto-capture-density.ts +30 -0
  6. package/client/browser-canvas-input.test.ts +198 -0
  7. package/client/browser-canvas-input.ts +202 -0
  8. package/client/browser-canvas-keyboard-web.test.ts +113 -0
  9. package/client/browser-canvas-keyboard-web.ts +230 -0
  10. package/client/browser-canvas-keyboard.test.ts +123 -0
  11. package/client/browser-canvas-keyboard.ts +126 -0
  12. package/client/browser-canvas-layout.test.ts +109 -0
  13. package/client/browser-canvas-layout.ts +51 -0
  14. package/client/browser-canvas-viewport-web.test.ts +77 -0
  15. package/client/browser-canvas-viewport-web.ts +63 -0
  16. package/client/browser-canvas-viewport.tsx +152 -0
  17. package/client/browser-capture-cadence.test.ts +9 -0
  18. package/client/browser-capture-cadence.ts +6 -0
  19. package/client/browser-capture-density-controls.tsx +75 -0
  20. package/client/browser-chrome.tsx +234 -0
  21. package/client/browser-client-presence.ts +184 -0
  22. package/client/browser-compose-controls.test.tsx +275 -0
  23. package/client/browser-compose-controls.tsx +174 -0
  24. package/client/browser-control-button.tsx +109 -0
  25. package/client/browser-control-dialog.tsx +71 -0
  26. package/client/browser-control-request.test.ts +27 -0
  27. package/client/browser-control-request.ts +57 -0
  28. package/client/browser-control-tooltip-web.ts +10 -0
  29. package/client/browser-dialog-body.tsx +22 -0
  30. package/client/browser-emulation-mode.test.ts +75 -0
  31. package/client/browser-emulation-mode.ts +98 -0
  32. package/client/browser-fill-viewport.test.ts +45 -0
  33. package/client/browser-fill-viewport.ts +28 -0
  34. package/client/browser-frame-buffer.test.ts +157 -0
  35. package/client/browser-frame-buffer.ts +148 -0
  36. package/client/browser-frame-image.tsx +62 -0
  37. package/client/browser-image-load.test.ts +23 -0
  38. package/client/browser-image-load.ts +26 -0
  39. package/client/browser-input-queue.test.ts +1201 -0
  40. package/client/browser-input-queue.ts +505 -0
  41. package/client/browser-menu-placement.test.ts +90 -0
  42. package/client/browser-menu-placement.ts +78 -0
  43. package/client/browser-panel-styles.ts +384 -0
  44. package/client/browser-quality-controls.test.ts +26 -0
  45. package/client/browser-quality-controls.tsx +139 -0
  46. package/client/browser-resolution-picker.tsx +189 -0
  47. package/client/browser-tabs.tsx +117 -0
  48. package/client/browser-toolbar-menu-web.test.ts +185 -0
  49. package/client/browser-toolbar-menu-web.ts +159 -0
  50. package/client/browser-toolbar-menu.tsx +255 -0
  51. package/client/browser-toolbar-submenu.tsx +93 -0
  52. package/client/browser-video-bytes.test.ts +44 -0
  53. package/client/browser-video-decoder.test.ts +473 -0
  54. package/client/browser-video-decoder.ts +311 -0
  55. package/client/browser-video-surface.tsx +21 -0
  56. package/client/browser-video-visibility.test.ts +45 -0
  57. package/client/browser-viewer-recovery.test.ts +111 -0
  58. package/client/browser-viewer-recovery.ts +78 -0
  59. package/client/browser.tsx +1642 -1569
  60. package/client/favorite-star.tsx +23 -0
  61. package/client/frame-lifecycle.ts +62 -59
  62. package/client/use-browser-auto-capture-density.test.ts +104 -0
  63. package/client/use-browser-auto-capture-density.ts +43 -0
  64. package/client/use-browser-canvas-input.ts +370 -0
  65. package/client/use-browser-capture-density.ts +87 -0
  66. package/client/use-browser-emulation-mode.ts +56 -0
  67. package/client/use-browser-fill-viewport.test.ts +108 -0
  68. package/client/use-browser-fill-viewport.ts +62 -0
  69. package/client/use-browser-frame-buffer.ts +66 -0
  70. package/client/use-browser-image-capture.ts +139 -0
  71. package/client/use-browser-scoped-mutation.test.ts +118 -0
  72. package/client/use-browser-scoped-mutation.ts +59 -0
  73. package/client/use-browser-video.test.ts +991 -0
  74. package/client/use-browser-video.ts +474 -0
  75. package/client/use-browser-viewer-recovery.ts +26 -0
  76. package/client/use-resolution-favorites.ts +90 -0
  77. package/client/web.test.ts +125 -0
  78. package/client/web.ts +405 -0
  79. package/index.client.tsx +0 -10
  80. package/index.server.ts +32 -0
  81. package/npm-shrinkwrap.json +7 -7
  82. package/package.json +9 -5
  83. package/paseo-plugin.json +3 -1
  84. package/scripts/prepare-runtime.mjs +1 -1
  85. package/server/agent-browser-runtime.ts +1598 -314
  86. package/server/browser-cursor.test.ts +64 -0
  87. package/server/browser-cursor.ts +69 -0
  88. package/server/browser-frame-context.ts +22 -0
  89. package/server/browser-frame-epoch.test.ts +95 -0
  90. package/server/browser-frame-rejection.ts +17 -0
  91. package/server/browser-gesture.ts +120 -0
  92. package/server/browser-operation-diagnostics.test.ts +79 -0
  93. package/server/browser-operation-diagnostics.ts +103 -0
  94. package/server/browser-policy.ts +1313 -151
  95. package/server/browser-video-input-authority.test.ts +208 -0
  96. package/server/browser-video-policy.test.ts +564 -0
  97. package/server/browser-video-socket.test.ts +368 -0
  98. package/server/browser.ts +151 -16
  99. package/server/capture-transport-policy.test.ts +105 -0
  100. package/server/capture-transport-policy.ts +154 -0
  101. package/server/capture-transport-runtime.test.ts +213 -0
  102. package/server/daemon-launch-chain.test.ts +70 -0
  103. package/server/daemon-shutdown.test.ts +289 -0
  104. package/server/discrete-input-freshness.test.ts +364 -0
  105. package/server/discrete-input-native-lifetime.test.ts +150 -0
  106. package/server/display-loss-recovery.test.ts +294 -0
  107. package/server/emulation-restoration.test.ts +262 -0
  108. package/server/input-cleanup.ts +23 -0
  109. package/server/input-generation.test.ts +31 -0
  110. package/server/input-generation.ts +36 -0
  111. package/server/jpeg-capture-demand.test.ts +76 -0
  112. package/server/jpeg-capture-demand.ts +98 -0
  113. package/server/jpeg-frame.ts +29 -0
  114. package/server/keyboard-input.test.ts +61 -0
  115. package/server/keyboard-input.ts +104 -0
  116. package/server/live-input-policy.test.ts +958 -0
  117. package/server/live-input-runtime.test.ts +732 -0
  118. package/server/live-input-supervisor.test.ts +147 -0
  119. package/server/mcp-entry.ts +44 -2
  120. package/server/native-video-backpressure.test.ts +228 -0
  121. package/server/native-video-buffer.test.ts +141 -0
  122. package/server/native-video-buffer.ts +114 -0
  123. package/server/native-video-capture.test.ts +599 -0
  124. package/server/native-video-capture.ts +497 -0
  125. package/server/native-video-extension-source.test.ts +201 -0
  126. package/server/native-video-extension-source.ts +364 -0
  127. package/server/native-video-extension.test.ts +41 -0
  128. package/server/native-video-extension.ts +83 -0
  129. package/server/native-video-packet.ts +9 -0
  130. package/server/native-video-settings.test.ts +245 -0
  131. package/server/navigation-budget.ts +8 -0
  132. package/server/navigation-commit.ts +125 -0
  133. package/server/navigation-policy.test.ts +153 -0
  134. package/server/process-identity.ts +58 -0
  135. package/server/reattach-held-input.test.ts +143 -0
  136. package/server/runtime-frame-reuse.test.ts +295 -0
  137. package/server/runtime-jpeg-demand.test.ts +200 -0
  138. package/server/runtime-jpeg-shutdown.test.ts +210 -0
  139. package/server/runtime-jpeg-source-order.test.ts +201 -0
  140. package/server/runtime-launch-environment.test.ts +111 -0
  141. package/server/runtime-navigation.test.ts +327 -0
  142. package/server/runtime-owner-display.test.ts +152 -0
  143. package/server/runtime-owner.ts +305 -31
  144. package/server/runtime-page-state.test.ts +249 -0
  145. package/server/runtime-protocol.ts +31 -1
  146. package/server/runtime-target-retention.test.ts +181 -0
  147. package/server/runtime-video-opt-in.test.ts +82 -0
  148. package/server/runtime-video.test.ts +444 -0
  149. package/server/screencast-source-time.ts +46 -0
  150. package/server/stale-wheel-admission.test.ts +136 -0
  151. package/server/supervisor-client.ts +28 -4
  152. package/server/supervisor-heartbeat.test.ts +80 -0
  153. package/server/supervisor.ts +553 -55
  154. package/server/video-source-recovery.test.ts +20 -0
  155. package/server/video-source-recovery.ts +64 -0
  156. package/server/viewer-capture-lifetime.ts +51 -0
  157. package/server/virtual-display.test.ts +184 -0
  158. package/server/virtual-display.ts +236 -0
  159. package/shared/browser-display-preferences.ts +46 -0
  160. package/shared/browser-video.ts +80 -0
  161. package/shared/browser.ts +286 -65
  162. package/shared/capture-density.ts +16 -0
  163. package/shared/capture-settings.ts +17 -0
  164. package/shared/device-presets.ts +90 -0
  165. package/shared/resolution-menu.test.ts +120 -0
  166. package/shared/resolution-menu.ts +72 -0
  167. package/shared/video-settings.test.ts +38 -0
  168. package/shared/video-settings.ts +34 -0
  169. package/shared/viewport-limits.ts +5 -0
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ type BrowserFrameBuffer,
4
+ createBrowserFrameBuffer,
5
+ type FrameCandidate,
6
+ } from "./browser-frame-buffer";
7
+
8
+ function candidate(id: string, pixels = id, generation = 0): FrameCandidate {
9
+ return {
10
+ viewerToken: "viewer-a",
11
+ mutationEpoch: 0,
12
+ frame: {
13
+ sessionId: "session-a",
14
+ frameId: id,
15
+ mimeType: "image/jpeg",
16
+ transport: "screenshot",
17
+ dataBase64: pixels,
18
+ byteLength: pixels.length,
19
+ width: 1280,
20
+ height: 800,
21
+ navigationGeneration: generation,
22
+ viewportGeneration: 0,
23
+ runtimeId: "runtime-a",
24
+ capturedAt: "2026-10-02T12:00:00.000Z",
25
+ },
26
+ };
27
+ }
28
+ function pendingTicket(buffer: BrowserFrameBuffer): number {
29
+ const state = buffer.snapshot();
30
+ if (state.pending === null) throw new Error("No pending decoder");
31
+ return state.layers[state.pending]!.ticket;
32
+ }
33
+ function visible(buffer: BrowserFrameBuffer) {
34
+ const state = buffer.snapshot();
35
+ return state.front === null ? null : state.layers[state.front];
36
+ }
37
+ function decodeFirst(buffer: BrowserFrameBuffer) {
38
+ buffer.offer(candidate("front"));
39
+ buffer.settle(pendingTicket(buffer), true, () => true);
40
+ }
41
+
42
+ describe("decoded Shared Browser frame handoff", () => {
43
+ it("keeps initial capture hidden until successful decode, then retains it during replacement", () => {
44
+ const buffer = createBrowserFrameBuffer();
45
+ buffer.offer(candidate("front"));
46
+ expect(visible(buffer)).toBeNull();
47
+ buffer.settle(pendingTicket(buffer), true, () => true);
48
+ const original = visible(buffer);
49
+ buffer.offer(candidate("next"));
50
+ expect(visible(buffer)).toBe(original);
51
+ expect(visible(buffer)?.candidate.frame.frameId).toBe("front");
52
+ buffer.settle(pendingTicket(buffer), true, () => true);
53
+ expect(visible(buffer)?.candidate.frame.frameId).toBe("next");
54
+ });
55
+
56
+ it("retains the decoded front and input frame after a failed replacement", () => {
57
+ const buffer = createBrowserFrameBuffer();
58
+ decodeFirst(buffer);
59
+ const original = visible(buffer);
60
+ buffer.offer(candidate("failed"));
61
+ buffer.settle(pendingTicket(buffer), false, () => true);
62
+ expect(visible(buffer)).toBe(original);
63
+ expect(buffer.snapshot().imageError).toBe(false);
64
+ buffer.offer(candidate("retry"));
65
+ buffer.settle(pendingTicket(buffer), true, () => true);
66
+ expect(visible(buffer)?.candidate.frame.frameId).toBe("retry");
67
+ });
68
+
69
+ it("shows the established initial error only when no decoded front exists", () => {
70
+ const buffer = createBrowserFrameBuffer();
71
+ buffer.offer(candidate("bad-first"));
72
+ buffer.settle(pendingTicket(buffer), false, () => true);
73
+ expect(buffer.snapshot().imageError).toBe(true);
74
+ expect(visible(buffer)).toBeNull();
75
+ });
76
+
77
+ it("advances identical-pixel frame authority without replacing its decoded source", () => {
78
+ const buffer = createBrowserFrameBuffer();
79
+ decodeFirst(buffer);
80
+ const source = visible(buffer)?.source;
81
+ const ticket = visible(buffer)?.ticket;
82
+ buffer.offer(candidate("new-authority", "front", 1));
83
+ expect(visible(buffer)?.candidate.frame.frameId).toBe("new-authority");
84
+ expect(visible(buffer)?.candidate.frame.navigationGeneration).toBe(1);
85
+ expect(visible(buffer)?.source).toBe(source);
86
+ expect(visible(buffer)?.ticket).toBe(ticket);
87
+ expect(buffer.snapshot().pending).toBeNull();
88
+ });
89
+
90
+ it("coalesces fast captures without cancelling the slow decoder or accumulating images", () => {
91
+ const buffer = createBrowserFrameBuffer();
92
+ decodeFirst(buffer);
93
+ buffer.offer(candidate("slow"));
94
+ const ticket = pendingTicket(buffer);
95
+ for (let i = 0; i < 100; i++) buffer.offer(candidate(`queued-${i}`));
96
+ expect(pendingTicket(buffer)).toBe(ticket);
97
+ expect(buffer.snapshot().layers.filter(Boolean)).toHaveLength(2);
98
+ expect(buffer.snapshot().queued?.frame.frameId).toBe("queued-99");
99
+ buffer.settle(ticket, true, () => true);
100
+ expect(visible(buffer)?.candidate.frame.frameId).toBe("slow");
101
+ buffer.settle(pendingTicket(buffer), true, () => true);
102
+ expect(visible(buffer)?.candidate.frame.frameId).toBe("queued-99");
103
+ });
104
+
105
+ it("ignores late callbacks when the same native slot was reused for a newer generation", () => {
106
+ const buffer = createBrowserFrameBuffer();
107
+ decodeFirst(buffer);
108
+ buffer.offer(candidate("obsolete"));
109
+ const oldTicket = pendingTicket(buffer);
110
+ buffer.invalidatePending();
111
+ buffer.offer(candidate("current", "current", 1));
112
+ const newTicket = pendingTicket(buffer);
113
+ buffer.settle(oldTicket, true, () => true);
114
+ buffer.settle(oldTicket, false, () => true);
115
+ expect(pendingTicket(buffer)).toBe(newTicket);
116
+ expect(visible(buffer)?.candidate.frame.frameId).toBe("front");
117
+ buffer.settle(newTicket, true, () => true);
118
+ expect(visible(buffer)?.candidate.frame.frameId).toBe("current");
119
+ });
120
+
121
+ it("never promotes a decoded frame whose viewer/runtime/generation authority expired", () => {
122
+ const buffer = createBrowserFrameBuffer();
123
+ decodeFirst(buffer);
124
+ buffer.offer(candidate("obsolete"));
125
+ buffer.offer({ ...candidate("fresh"), viewerToken: "viewer-b" });
126
+ const isCurrent = (item: FrameCandidate) => item.viewerToken === "viewer-b";
127
+ buffer.settle(pendingTicket(buffer), true, isCurrent);
128
+ expect(visible(buffer)?.candidate.frame.frameId).toBe("front");
129
+ buffer.settle(pendingTicket(buffer), true, isCurrent);
130
+ expect(visible(buffer)?.candidate.frame.frameId).toBe("fresh");
131
+ });
132
+
133
+ it("clears workspace pixels and prevents ticket replay after reset", () => {
134
+ const buffer = createBrowserFrameBuffer();
135
+ decodeFirst(buffer);
136
+ buffer.offer(candidate("old-workspace"));
137
+ const oldTicket = pendingTicket(buffer);
138
+ buffer.reset();
139
+ expect(visible(buffer)).toBeNull();
140
+ buffer.offer(candidate("new-workspace"));
141
+ const fresh = buffer.snapshot();
142
+ buffer.settle(oldTicket, true, () => true);
143
+ expect(buffer.snapshot()).toBe(fresh);
144
+ buffer.settle(pendingTicket(buffer), true, () => true);
145
+ expect(visible(buffer)?.candidate.frame.frameId).toBe("new-workspace");
146
+ });
147
+
148
+ it("a freshest capture identical to the front cancels obsolete pending promotion", () => {
149
+ const buffer = createBrowserFrameBuffer();
150
+ decodeFirst(buffer);
151
+ buffer.offer(candidate("transient"));
152
+ const oldTicket = pendingTicket(buffer);
153
+ buffer.offer(candidate("back-to-front", "front"));
154
+ buffer.settle(oldTicket, true, () => true);
155
+ expect(visible(buffer)?.candidate.frame.frameId).toBe("back-to-front");
156
+ });
157
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Owns decoded browser-frame handoff independently of transport capture cadence.
3
+ * Two stable image slots retain the decoded front while one replacement loads;
4
+ * one newest queued capture bounds memory without starving a slow decoder.
5
+ * Frame IDs are opaque. A decode ticket, not capture timestamps, rejects late
6
+ * native callbacks. Callers qualify session/generation ownership separately.
7
+ */
8
+ import type { BrowserFrame } from "../shared/browser";
9
+
10
+ export type FrameCandidate = {
11
+ frame: BrowserFrame;
12
+ /** Canonical CSS layout captured with these pixels, independent of capture density. */
13
+ viewport?: { width: number; height: number };
14
+ viewerToken: string;
15
+ mutationEpoch: number;
16
+ /** Held-video handoff request captured when this image RPC began. */
17
+ fallbackRevision?: number;
18
+ };
19
+ export type FrameLayer = {
20
+ ticket: number;
21
+ candidate: FrameCandidate;
22
+ source: { uri: string };
23
+ };
24
+ export type FrameBufferSnapshot = {
25
+ layers: readonly [FrameLayer | null, FrameLayer | null];
26
+ front: 0 | 1 | null;
27
+ pending: 0 | 1 | null;
28
+ queued: FrameCandidate | null;
29
+ imageError: boolean;
30
+ };
31
+
32
+ /** Exact JPEG reuse avoids reloading an already decoded image for a fresh frame ID. */
33
+ function samePixels(a: BrowserFrame, b: BrowserFrame): boolean {
34
+ return (
35
+ a.mimeType === b.mimeType &&
36
+ a.width === b.width &&
37
+ a.height === b.height &&
38
+ a.dataBase64 === b.dataBase64
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Create a bounded handoff model. Use plain closures rather than a class because
44
+ * the client is evaluated dynamically across Paseo hosts, outside Metro's normal
45
+ * class lowering. Visible input authority is published only after React commits.
46
+ */
47
+ export function createBrowserFrameBuffer() {
48
+ let nextTicket = 0;
49
+ let value: FrameBufferSnapshot = {
50
+ layers: [null, null],
51
+ front: null,
52
+ pending: null,
53
+ queued: null,
54
+ imageError: false,
55
+ };
56
+
57
+ function snapshot(): FrameBufferSnapshot {
58
+ return value;
59
+ }
60
+
61
+ /** Replace an obsolete pending decode without discarding the last decoded front. */
62
+ function invalidatePending(): FrameBufferSnapshot {
63
+ const layers: [FrameLayer | null, FrameLayer | null] = [...value.layers];
64
+ if (value.pending !== null) {
65
+ layers[value.pending] = null;
66
+ }
67
+ value = { ...value, layers, pending: null, queued: null };
68
+ return value;
69
+ }
70
+
71
+ /** Clear workspace-owned pixels. Ticket numbers never reset, including across workspaces. */
72
+ function reset(): FrameBufferSnapshot {
73
+ value = {
74
+ layers: [null, null],
75
+ front: null,
76
+ pending: null,
77
+ queued: null,
78
+ imageError: false,
79
+ };
80
+ return value;
81
+ }
82
+
83
+ /** Offer a qualified capture; newest queued metadata replaces earlier queued captures. */
84
+ function offer(candidate: FrameCandidate): FrameBufferSnapshot {
85
+ const front = value.front;
86
+ const frontLayer = front === null ? null : value.layers[front];
87
+ if (front !== null && frontLayer && samePixels(frontLayer.candidate.frame, candidate.frame)) {
88
+ invalidatePending();
89
+ const layers: [FrameLayer | null, FrameLayer | null] = [...value.layers];
90
+ layers[front] = { ...frontLayer, candidate };
91
+ value = { ...value, layers, imageError: false };
92
+ return value;
93
+ }
94
+
95
+ if (value.pending !== null) {
96
+ value = { ...value, queued: candidate };
97
+ return value;
98
+ }
99
+
100
+ const slot = front === 0 ? 1 : 0;
101
+ const layers: [FrameLayer | null, FrameLayer | null] = [...value.layers];
102
+ layers[slot] = {
103
+ ticket: ++nextTicket,
104
+ candidate,
105
+ source: { uri: `data:${candidate.frame.mimeType};base64,${candidate.frame.dataBase64}` },
106
+ };
107
+ value = { ...value, layers, pending: slot, queued: null, imageError: false };
108
+ return value;
109
+ }
110
+
111
+ /**
112
+ * Accept only this pending image's successful load. Failure or expired authority
113
+ * never removes the front. Only a still-qualified newest queued capture is loaded.
114
+ */
115
+ function settle(
116
+ ticket: number,
117
+ succeeded: boolean,
118
+ isCurrent: (candidate: FrameCandidate) => boolean,
119
+ ): FrameBufferSnapshot {
120
+ const pending = value.pending;
121
+ const layer = pending === null ? null : value.layers[pending];
122
+ if (pending === null || !layer || layer.ticket !== ticket) {
123
+ return value;
124
+ }
125
+
126
+ const queued = value.queued;
127
+ const accepted = succeeded && isCurrent(layer.candidate);
128
+ const layers: [FrameLayer | null, FrameLayer | null] = [...value.layers];
129
+ if (!accepted) {
130
+ layers[pending] = null;
131
+ }
132
+ value = {
133
+ ...value,
134
+ layers,
135
+ front: accepted ? pending : value.front,
136
+ pending: null,
137
+ queued: null,
138
+ imageError: !succeeded && value.front === null && isCurrent(layer.candidate),
139
+ };
140
+ if (queued && isCurrent(queued)) {
141
+ offer(queued);
142
+ }
143
+ return value;
144
+ }
145
+ return { snapshot, invalidatePending, reset, offer, settle };
146
+ }
147
+
148
+ export type BrowserFrameBuffer = ReturnType<typeof createBrowserFrameBuffer>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Owns one visual JPEG/decode slot. Input authority belongs to the frame-buffer
3
+ * hook, not this image. Metadata-only captures and mouse state must not rerender
4
+ * the native image or recreate its load callbacks when its pixels are unchanged.
5
+ */
6
+ import { memo, useCallback, useMemo } from "react";
7
+ import { Image, StyleSheet } from "react-native";
8
+ import { createBrowserImageLoadHandler } from "./browser-image-load";
9
+
10
+ interface BrowserFrameImageProps {
11
+ ticket: number;
12
+ source: { uri: string };
13
+ visible: boolean;
14
+ label: string;
15
+ x: number;
16
+ y: number;
17
+ width: number;
18
+ height: number;
19
+ cornerRadius: number;
20
+ settled(ticket: number, succeeded: boolean): boolean;
21
+ retry(): void;
22
+ }
23
+
24
+ const styles = StyleSheet.create({ frame: { position: "absolute" } });
25
+
26
+ /** Memoize visual values only; newer input frame IDs still commit through the parent hook. */
27
+ export const BrowserFrameImage = memo(function BrowserFrameImage(props: BrowserFrameImageProps) {
28
+ const { ticket, source, settled, retry } = props;
29
+ const onLoad = useMemo(
30
+ () => createBrowserImageLoadHandler(ticket, source.uri, settled),
31
+ [ticket, source.uri, settled],
32
+ );
33
+ const onError = useCallback(() => {
34
+ if (settled(ticket, false)) {
35
+ retry();
36
+ }
37
+ }, [ticket, settled, retry]);
38
+
39
+ return (
40
+ <Image
41
+ accessible={props.visible}
42
+ accessibilityLabel={props.label}
43
+ accessibilityRole="image"
44
+ fadeDuration={0}
45
+ onLoad={onLoad}
46
+ onError={onError}
47
+ resizeMode="contain"
48
+ source={source}
49
+ style={[
50
+ styles.frame,
51
+ {
52
+ left: props.x,
53
+ top: props.y,
54
+ width: props.width,
55
+ height: props.height,
56
+ borderRadius: props.cornerRadius,
57
+ opacity: props.visible ? 1 : 0,
58
+ },
59
+ ]}
60
+ />
61
+ );
62
+ });
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createBrowserImageLoadHandler } from "./browser-image-load";
3
+
4
+ describe("cross-platform browser image decode", () => {
5
+ it("accepts the desktop DOM load envelope without inventing a reported URI", () => {
6
+ const delivered: unknown[] = [];
7
+ const handler = createBrowserImageLoadHandler(1, "data:image/jpeg;base64,frame", (...args) =>
8
+ delivered.push(args),
9
+ );
10
+ expect(() => handler({ nativeEvent: new Event("load") })).not.toThrow();
11
+ expect(delivered).toEqual([[1, true]]);
12
+ });
13
+
14
+ it("accepts the native exact source and refuses a contradictory source", () => {
15
+ const delivered: unknown[] = [];
16
+ const handler = createBrowserImageLoadHandler(7, "expected", (...args) => delivered.push(args));
17
+ handler({ nativeEvent: { source: { uri: "other" } } });
18
+ handler({ nativeEvent: { source: { uri: 42 } } });
19
+ expect(delivered).toEqual([]);
20
+ handler({ nativeEvent: { source: { uri: "expected" } } });
21
+ expect(delivered).toEqual([[7, true]]);
22
+ });
23
+ });
@@ -0,0 +1,26 @@
1
+ /** Qualify native and web image-load events before the current decode-ticket handoff. */
2
+
3
+ /**
4
+ * Native Image may report its loaded source URI; React Native Web instead wraps
5
+ * a DOM load Event without that field. The unique decoder ticket remains the
6
+ * authority when no URI is reported. A contradictory reported URI is rejected.
7
+ * The settlement callback must still check current viewer, mutation and browser
8
+ * generations before committing pixels or input authority.
9
+ */
10
+ export function createBrowserImageLoadHandler(
11
+ ticket: number,
12
+ expectedUri: string,
13
+ settle: (ticket: number, succeeded: boolean) => unknown,
14
+ ): (event: unknown) => void {
15
+ return (event) => {
16
+ const nativeEvent =
17
+ event && typeof event === "object" && "nativeEvent" in event ? event.nativeEvent : undefined;
18
+ const source =
19
+ nativeEvent && typeof nativeEvent === "object" && "source" in nativeEvent
20
+ ? nativeEvent.source
21
+ : undefined;
22
+ const uri = source && typeof source === "object" && "uri" in source ? source.uri : undefined;
23
+ if (uri && uri !== expectedUri) return;
24
+ settle(ticket, true);
25
+ };
26
+ }