react-native-image-stitcher 0.7.1 → 0.9.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/android/build.gradle +35 -1
  3. package/android/src/main/cpp/CMakeLists.txt +64 -2
  4. package/android/src/main/cpp/stitcher_jsi_install_jni.cpp +227 -0
  5. package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +30 -11
  6. package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +21 -3
  7. package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +78 -3
  8. package/android/src/main/java/io/imagestitcher/rn/SaveFrameAsJpegPlugin.kt +162 -0
  9. package/android/src/main/java/io/imagestitcher/rn/StitcherJsiInstallerModule.kt +103 -0
  10. package/android/src/main/java/io/imagestitcher/rn/StitcherWorkletRuntime.kt +256 -0
  11. package/android/src/main/java/io/imagestitcher/rn/TransferredNV21.kt +100 -0
  12. package/cpp/stitcher_frame_data.hpp +141 -0
  13. package/cpp/stitcher_frame_jsi.cpp +214 -0
  14. package/cpp/stitcher_frame_jsi.hpp +108 -0
  15. package/cpp/stitcher_proxy_jsi.cpp +109 -0
  16. package/cpp/stitcher_proxy_jsi.hpp +46 -0
  17. package/cpp/stitcher_worklet_dispatch.cpp +103 -0
  18. package/cpp/stitcher_worklet_dispatch.hpp +71 -0
  19. package/cpp/stitcher_worklet_registry.cpp +81 -0
  20. package/cpp/stitcher_worklet_registry.hpp +136 -0
  21. package/dist/camera/Camera.d.ts +62 -12
  22. package/dist/camera/Camera.js +30 -15
  23. package/dist/index.d.ts +6 -0
  24. package/dist/index.js +30 -1
  25. package/dist/stitching/StitcherFrame.d.ts +170 -0
  26. package/dist/stitching/StitcherFrame.js +4 -0
  27. package/dist/stitching/StitcherWorkletRegistry.d.ts +117 -0
  28. package/dist/stitching/StitcherWorkletRegistry.js +78 -0
  29. package/dist/stitching/ensureStitcherProxyInstalled.d.ts +8 -0
  30. package/dist/stitching/ensureStitcherProxyInstalled.js +81 -0
  31. package/dist/stitching/useFrameProcessor.d.ts +119 -0
  32. package/dist/stitching/useFrameProcessor.js +196 -0
  33. package/dist/stitching/useFrameStream.d.ts +34 -0
  34. package/dist/stitching/useFrameStream.js +219 -0
  35. package/dist/stitching/useThrottledFrameProcessor.d.ts +33 -0
  36. package/dist/stitching/useThrottledFrameProcessor.js +132 -0
  37. package/dist/types.d.ts +87 -0
  38. package/ios/Sources/RNImageStitcher/RNSARSession.swift +46 -10
  39. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.h +128 -0
  40. package/ios/Sources/RNImageStitcher/RNSARWorkletRuntime.mm +313 -0
  41. package/ios/Sources/RNImageStitcher/SaveFrameAsJpegPlugin.mm +185 -0
  42. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.h +60 -0
  43. package/ios/Sources/RNImageStitcher/StitcherFrameHostObject.mm +214 -0
  44. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.h +42 -0
  45. package/ios/Sources/RNImageStitcher/StitcherJsiInstaller.mm +103 -0
  46. package/package.json +1 -1
  47. package/src/camera/Camera.tsx +93 -28
  48. package/src/index.ts +35 -0
  49. package/src/stitching/StitcherFrame.ts +197 -0
  50. package/src/stitching/StitcherWorkletRegistry.ts +156 -0
  51. package/src/stitching/__tests__/StitcherWorkletRegistry.test.ts +176 -0
  52. package/src/stitching/__tests__/ensureStitcherProxyInstalled.test.ts +94 -0
  53. package/src/stitching/__tests__/useThrottledFrameProcessor.test.ts +178 -0
  54. package/src/stitching/ensureStitcherProxyInstalled.ts +141 -0
  55. package/src/stitching/useFrameProcessor.ts +226 -0
  56. package/src/stitching/useFrameStream.ts +255 -0
  57. package/src/stitching/useThrottledFrameProcessor.ts +145 -0
  58. package/src/types.ts +95 -0
@@ -0,0 +1,197 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * v0.8.0 — unified frame contract for the lib's worklet processor.
5
+ *
6
+ * Worklets registered via the v0.8.0 `useFrameProcessor` hook (also in
7
+ * this directory) receive a `StitcherFrame` regardless of capture mode.
8
+ * The lib-owned worklet runtime guarantees the same JS-visible shape
9
+ * whether the underlying source is a vision-camera `Frame` (non-AR
10
+ * mode, sourced from the FP plugin) or an ARKit `ARFrame` / ARCore
11
+ * `Frame` (AR mode, sourced from a lib-managed delegate that the AR
12
+ * worklet runtime drives).
13
+ *
14
+ * ## Why structural (NOT `extends Frame`)
15
+ *
16
+ * vision-camera's iOS `Frame` is `CMSampleBufferRef`-shaped; ARFrame's
17
+ * `capturedImage` (a `CVPixelBufferRef`) can be wrapped into one
18
+ * (Phase-0 audit confirmed the iOS path). But vision-camera's
19
+ * **Android** `Frame` is `androidx.camera.core.ImageProxy`-coupled —
20
+ * ARCore does NOT produce `ImageProxy` instances. Forcing
21
+ * `StitcherFrame extends Frame` would either (a) require reverse-
22
+ * engineering ImageProxy on Android (intractable + fragile), or
23
+ * (b) make the type asymmetric per platform. Both are worse than
24
+ * making `StitcherFrame` a structural sibling type that vc Frames
25
+ * happen to satisfy (because vc Frames carry the same width / height /
26
+ * orientation / pixelFormat / timestamp / toArrayBuffer surface).
27
+ *
28
+ * The `__source: 'vc' | 'ar'` discriminator lets worklets gate on
29
+ * mode without a typeof / try-catch dance — e.g., skip work that
30
+ * needs AR tracking state when the source is `'vc'`.
31
+ *
32
+ * ## Buffer lifetime
33
+ *
34
+ * The underlying camera buffer (CMSampleBufferRef / ImageProxy /
35
+ * ARFrame.capturedImage) is valid only for the duration of the worklet
36
+ * call. Worklets that need to retain frame data MUST copy
37
+ * synchronously inside the worklet body (via `toArrayBuffer()` or via
38
+ * a JPEG-encode frame-processor plugin). Returning a reference and
39
+ * reading it later will read into freed memory.
40
+ */
41
+ export interface StitcherFrame {
42
+ // ── vision-camera-shaped fields (structural compat) ─────────────
43
+ // Worklets written against a vc `Frame` work unchanged against a
44
+ // `StitcherFrame` (the fields below are a strict subset of vc
45
+ // Frame's JS-visible surface).
46
+
47
+ /** Pixel width of the camera image. */
48
+ width: number;
49
+
50
+ /** Pixel height of the camera image. */
51
+ height: number;
52
+
53
+ /**
54
+ * Pixel format identifier. Both modes today emit `'yuv'` (NV12 on
55
+ * iOS, NV21 on Android). Other vision-camera formats may appear
56
+ * in future releases.
57
+ *
58
+ * **`'unknown'` semantics:** the lib reached a code path that
59
+ * doesn't recognise the underlying camera buffer's pixel format
60
+ * (e.g., a future ARKit version emits BGRA when historically it
61
+ * only emitted NV12). Worklets that depend on a known layout
62
+ * should treat `'unknown'` as "skip this frame". `toArrayBuffer()`
63
+ * still returns bytes when the format is `'unknown'`, but the
64
+ * layout is undefined — the bytes are the underlying buffer's
65
+ * first plane and may not be interpretable. When this happens
66
+ * the native side also emits an `os_log` / logcat warning.
67
+ */
68
+ pixelFormat: 'yuv' | 'rgb' | 'unknown';
69
+
70
+ /**
71
+ * Display orientation tag, matching vision-camera's
72
+ * `Frame.orientation`.
73
+ *
74
+ * **AR-mode limitation (v0.8.0):** AR-source frames return only
75
+ * the coarse two-value set `'landscape-right' | 'portrait'` (the
76
+ * lib reads `pose.imageWidth >= pose.imageHeight` as the
77
+ * discriminator since ARKit's `capturedImage` is always in the
78
+ * camera's native landscape-right orientation regardless of
79
+ * device pose). Worklets that need to distinguish
80
+ * `landscape-left` (upside-down landscape) or
81
+ * `portrait-upside-down` should consult device-orientation sensors
82
+ * separately while running in AR mode. Non-AR frames (vc source)
83
+ * return the full four-value set. Fixing the AR side requires
84
+ * threading `UIDevice.current.orientation` through; deferred to
85
+ * v0.8.1+ unless a consumer hits it.
86
+ */
87
+ orientation:
88
+ | 'portrait'
89
+ | 'portrait-upside-down'
90
+ | 'landscape-left'
91
+ | 'landscape-right';
92
+
93
+ /**
94
+ * Monotonic timestamp in **nanoseconds** (matches vision-camera's
95
+ * `Frame.timestamp` convention). Use timestamp deltas for
96
+ * inter-frame timing; the absolute value is implementation-defined
97
+ * and not comparable to `Date.now()`.
98
+ */
99
+ timestamp: number;
100
+
101
+ /**
102
+ * Copies the underlying pixel buffer into a JSI `ArrayBuffer`.
103
+ * Worklet-callable. Allocates O(width × height × bytesPerPixel)
104
+ * each call — avoid in tight inner loops; prefer plugin-side
105
+ * processing where possible.
106
+ */
107
+ toArrayBuffer(): ArrayBuffer;
108
+
109
+ // ── Lib additions ─────────────────────────────────────────────
110
+
111
+ /**
112
+ * Camera pose at frame-capture time. Always present.
113
+ *
114
+ * Rotation quaternion order is `(x, y, z, w)`; the lib uses
115
+ * `q = q_yaw * q_pitch * q_roll` throughout the engine + sensor
116
+ * fusion. Same convention surfaced by the v0.7.0
117
+ * `AcceptedKeyframe.pose` field.
118
+ *
119
+ * Translation is metres in world coordinates. Populated by AR
120
+ * mode (real ARKit / ARCore camera transform); undefined in
121
+ * non-AR mode (gyro provides only rotation — no spatial anchor).
122
+ */
123
+ pose: {
124
+ rotation: [number, number, number, number];
125
+ translation?: [number, number, number];
126
+ };
127
+
128
+ /**
129
+ * Discriminator for the frame source. Worklets branch on this to
130
+ * gate AR-only field access without try/catch. Standard TS
131
+ * discriminated-union pattern.
132
+ *
133
+ * - `'vc'` — vision-camera Frame Processor (non-AR mode)
134
+ * - `'ar'` — AR-session frame (AR mode); `arDepth` / `arAnchors` /
135
+ * `arTrackingState` fields may be populated
136
+ */
137
+ source: 'vc' | 'ar';
138
+
139
+ // ── AR-only optional fields ───────────────────────────────────
140
+ // Always undefined in `__source === 'vc'` mode.
141
+
142
+ /**
143
+ * Depth data when available — AR mode + a device that supports
144
+ * the AR framework's depth API (iPhone Pro LiDAR; ARCore Depth
145
+ * API on supported Android devices).
146
+ *
147
+ * Resolution is typically lower than the camera image (e.g.,
148
+ * 256×192 on iPhone Pro LiDAR). `confidenceMap` is per-pixel:
149
+ * `0` = low, `1` = medium, `2` = high confidence. `Float32`
150
+ * depth in metres; `Uint8` confidence.
151
+ */
152
+ arDepth?: {
153
+ width: number;
154
+ height: number;
155
+ depthMap: ArrayBuffer;
156
+ confidenceMap?: ArrayBuffer;
157
+ };
158
+
159
+ /**
160
+ * Tracked AR anchors visible in this frame. Empty array if AR
161
+ * is active but no anchors are tracked. Undefined in non-AR mode.
162
+ */
163
+ arAnchors?: ARAnchor[];
164
+
165
+ /**
166
+ * AR tracking quality. Worklets that should skip work when
167
+ * tracking is degraded check this. Undefined in non-AR mode.
168
+ */
169
+ arTrackingState?: 'notAvailable' | 'limited' | 'normal';
170
+ }
171
+
172
+ /**
173
+ * v0.8.0 — public AR anchor type. Subset of ARKit/ARCore anchor info
174
+ * exposed to JS worklets. Extend with plane-extent / image-name
175
+ * fields as the JSI binding learns them.
176
+ */
177
+ export interface ARAnchor {
178
+ /** Stable per-session anchor identifier. */
179
+ id: string;
180
+ /** Anchor kind. `'point'` is Android (ARCore) only. */
181
+ type: 'plane' | 'image' | 'point';
182
+ /**
183
+ * 4×4 row-major transform from anchor space to world space.
184
+ * 16 numbers.
185
+ */
186
+ transform: number[];
187
+ }
188
+
189
+ /**
190
+ * v0.8.0 — worklet function signature for the unified frame processor.
191
+ *
192
+ * Must be a `'worklet'`-prefixed function (so it can run on the
193
+ * worklet runtime). Receives a `StitcherFrame` per camera frame; the
194
+ * return value is ignored (use `runOnJS` / shared values to surface
195
+ * results back to the JS thread).
196
+ */
197
+ export type StitcherFrameProcessor = (frame: StitcherFrame) => void;
@@ -0,0 +1,156 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ import type { StitcherFrameProcessor } from './StitcherFrame';
4
+
5
+ /**
6
+ * v0.8.0 Phase 4a — process-scope registry of host-supplied worklets
7
+ * that the v0.8.0 `useFrameProcessor` hook registers into.
8
+ *
9
+ * ## What this is (Phase 4a)
10
+ *
11
+ * A plain JS singleton holding an ordered list of registered
12
+ * worklets. Hosts mount the `useFrameProcessor` hook (in this
13
+ * directory); the hook registers its worklet into this singleton
14
+ * on mount and unregisters on unmount. Each entry carries:
15
+ *
16
+ * - `id`: stable identifier issued by `register`; passed to
17
+ * `unregister`.
18
+ * - `worklet`: the host's `StitcherFrameProcessor` function.
19
+ * MUST be `'worklet'`-prefixed at the call site (TS can't
20
+ * enforce that — convention).
21
+ * - `isFirstParty`: `false` for host-supplied worklets;
22
+ * reserved for the lib's own first-party stitching path which
23
+ * today is wired natively (not through this registry).
24
+ *
25
+ * Order is stable: first-party entries (none in Phase 4a) come
26
+ * first, then host entries by registration order. Re-registration
27
+ * of the same worklet by identity yields a new entry — hosts that
28
+ * re-render and call `register` again ARE responsible for calling
29
+ * `unregister` first. The `useFrameProcessor` hook handles this
30
+ * via its `deps` dependency array.
31
+ *
32
+ * ## What this is NOT (Phase 4b)
33
+ *
34
+ * **The native AR worklet runtime does NOT yet read this registry.**
35
+ * Worklets registered here for AR-mode captures will not fire
36
+ * until Phase 4b lands the cross-runtime handoff (a
37
+ * worklets-core `SharedValue` mirror that `RNSARWorkletRuntime`
38
+ * reads on each `dispatchFrame:pose:` call; the runtime then
39
+ * constructs a `StitcherFrameHostObject` + invokes each
40
+ * registered worklet via `RNWorklet::WorkletInvoker::call`).
41
+ *
42
+ * In non-AR mode the host-supplied worklet IS invoked, but via
43
+ * vision-camera's Frame Processor runtime directly (the
44
+ * `useFrameProcessor` hook returns vc's processor object which
45
+ * `<Camera>` passes to vision-camera). So Phase 4a's public API
46
+ * is fully functional for non-AR; AR is API-stable but
47
+ * runtime-deferred.
48
+ *
49
+ * ## Singleton lifetime
50
+ *
51
+ * The registry is a module-level instance. It lives for the
52
+ * lifetime of the JS runtime (= until app reload). Entries
53
+ * accumulate only via `register` and shed only via `unregister`
54
+ * — no GC / weak-ref logic. Hosts that mount `useFrameProcessor`
55
+ * inside React components MUST rely on the hook's effect cleanup
56
+ * to unregister on unmount, or they'll leak entries until
57
+ * reload. The hook handles this correctly today.
58
+ *
59
+ * ## Why a singleton (vs context provider)
60
+ *
61
+ * The native AR worklet runtime is itself a process-scope
62
+ * singleton (`RNSARWorkletRuntime`, `StitcherWorkletRuntime`).
63
+ * The Phase 4b handoff between TS and native is necessarily
64
+ * process-scope. Wrapping the registry in a React context
65
+ * would force every consumer to be in the same provider tree
66
+ * which is friction for layer-2 hosts that compose
67
+ * `<ARCameraView>` / `useIncrementalStitcher` themselves. The
68
+ * singleton is the right shape; the React-level ergonomics are
69
+ * provided by the `useFrameProcessor` hook.
70
+ */
71
+ export interface StitcherWorkletEntry {
72
+ readonly id: string;
73
+ readonly worklet: StitcherFrameProcessor;
74
+ readonly isFirstParty: boolean;
75
+ }
76
+
77
+ class Registry {
78
+ private entries: StitcherWorkletEntry[] = [];
79
+ private nextHostCounter = 0;
80
+
81
+ /**
82
+ * Register a worklet. Returns a stable ID for `unregister`.
83
+ *
84
+ * Entries are appended in registration order; first-party
85
+ * entries (if any are added in future) sort to the front.
86
+ */
87
+ register(opts: {
88
+ worklet: StitcherFrameProcessor;
89
+ isFirstParty?: boolean;
90
+ }): string {
91
+ const isFirstParty = opts.isFirstParty ?? false;
92
+ const id = isFirstParty
93
+ ? `fp-${this.nextHostCounter++}`
94
+ : `host-${this.nextHostCounter++}`;
95
+ const entry: StitcherWorkletEntry = {
96
+ id,
97
+ worklet: opts.worklet,
98
+ isFirstParty,
99
+ };
100
+ this.entries.push(entry);
101
+ // Re-sort so first-party always runs before host entries.
102
+ // Stable sort: registration order is preserved within each
103
+ // partition. Single-pass O(n log n) is fine — registration
104
+ // is rare (per-`<Camera>`-mount, not per-frame).
105
+ this.entries.sort((a, b) => {
106
+ if (a.isFirstParty !== b.isFirstParty) {
107
+ return a.isFirstParty ? -1 : 1;
108
+ }
109
+ return 0;
110
+ });
111
+ return id;
112
+ }
113
+
114
+ /**
115
+ * Remove a previously-registered worklet by ID. No-op if the ID
116
+ * isn't found. Hosts call this in their effect's cleanup.
117
+ */
118
+ unregister(id: string): void {
119
+ this.entries = this.entries.filter((e) => e.id !== id);
120
+ }
121
+
122
+ /**
123
+ * Snapshot the current entries. Returned array is a copy —
124
+ * mutations don't affect the registry. Phase 4b's native
125
+ * handoff will read a `SharedValue` mirror of this list so the
126
+ * AR runtime doesn't need a JS-thread hop on the hot per-frame
127
+ * path; for Phase 4a this method is the JS-side accessor.
128
+ */
129
+ getEntries(): readonly StitcherWorkletEntry[] {
130
+ return [...this.entries];
131
+ }
132
+
133
+ /**
134
+ * Total number of registered worklets (first-party + host).
135
+ * Useful for diagnostics + tests.
136
+ */
137
+ get count(): number {
138
+ return this.entries.length;
139
+ }
140
+
141
+ /**
142
+ * Test-only — clear all entries. NOT exported from
143
+ * `src/index.ts`. Used in unit tests to reset state between
144
+ * cases.
145
+ */
146
+ _resetForTests(): void {
147
+ this.entries = [];
148
+ this.nextHostCounter = 0;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Process-scope singleton. Imported by `useFrameProcessor` (in
154
+ * this directory) + by the Phase 4b native-handoff code (TBD).
155
+ */
156
+ export const StitcherWorkletRegistry = new Registry();
@@ -0,0 +1,176 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Unit tests for the v0.8.0 Phase 4a `StitcherWorkletRegistry` singleton.
4
+ *
5
+ * The registry is the JS-side staging area for host worklets — the
6
+ * Phase 4b native handoff will read from it to fan out invocations on
7
+ * the AR runtime. These tests pin the invariants the native handoff
8
+ * is going to rely on:
9
+ *
10
+ * - Stable, unique IDs out of `register`.
11
+ * - Registration order preserved within the host-entry partition.
12
+ * - First-party entries always sort to the front of `getEntries`.
13
+ * - `unregister` is a no-op for unknown IDs (no throw — the native
14
+ * handoff may race a JS-side unregister with a frame in flight).
15
+ * - `getEntries` returns a snapshot — mutating the returned array
16
+ * can't corrupt registry state.
17
+ * - `_resetForTests` returns the registry to a pristine state
18
+ * (used by these tests; documented as test-only in the source).
19
+ */
20
+
21
+ import { StitcherWorkletRegistry } from '../StitcherWorkletRegistry';
22
+ import type { StitcherFrameProcessor } from '../StitcherFrame';
23
+
24
+ // Fresh no-op worklet stubs. These are NOT real worklets — they have
25
+ // no `'worklet'` directive — but the registry doesn't care about
26
+ // invocation, only about identity + ordering.
27
+ const makeWorklet = (label: string): StitcherFrameProcessor => {
28
+ const fn = (_frame: unknown) => {
29
+ void label;
30
+ };
31
+ return fn as unknown as StitcherFrameProcessor;
32
+ };
33
+
34
+ describe('StitcherWorkletRegistry', () => {
35
+ beforeEach(() => {
36
+ StitcherWorkletRegistry._resetForTests();
37
+ });
38
+
39
+ describe('register', () => {
40
+ it('returns a non-empty string ID', () => {
41
+ const id = StitcherWorkletRegistry.register({ worklet: makeWorklet('a') });
42
+ expect(typeof id).toBe('string');
43
+ expect(id.length).toBeGreaterThan(0);
44
+ });
45
+
46
+ it('issues distinct IDs across calls', () => {
47
+ const id1 = StitcherWorkletRegistry.register({ worklet: makeWorklet('a') });
48
+ const id2 = StitcherWorkletRegistry.register({ worklet: makeWorklet('b') });
49
+ expect(id1).not.toBe(id2);
50
+ });
51
+
52
+ it('issues distinct IDs even for the same worklet identity', () => {
53
+ // Hosts that re-render and re-register without unregistering get
54
+ // a fresh slot — the hook itself handles cleanup via deps, but
55
+ // the registry treats each `register` as independent.
56
+ const w = makeWorklet('shared');
57
+ const id1 = StitcherWorkletRegistry.register({ worklet: w });
58
+ const id2 = StitcherWorkletRegistry.register({ worklet: w });
59
+ expect(id1).not.toBe(id2);
60
+ expect(StitcherWorkletRegistry.count).toBe(2);
61
+ });
62
+
63
+ it('host entries default to isFirstParty=false', () => {
64
+ StitcherWorkletRegistry.register({ worklet: makeWorklet('host') });
65
+ const [entry] = StitcherWorkletRegistry.getEntries();
66
+ expect(entry.isFirstParty).toBe(false);
67
+ });
68
+
69
+ it('first-party flag passes through to the entry', () => {
70
+ StitcherWorkletRegistry.register({
71
+ worklet: makeWorklet('fp'),
72
+ isFirstParty: true,
73
+ });
74
+ const [entry] = StitcherWorkletRegistry.getEntries();
75
+ expect(entry.isFirstParty).toBe(true);
76
+ });
77
+ });
78
+
79
+ describe('getEntries ordering', () => {
80
+ it('preserves host registration order when no first-party entries', () => {
81
+ const wa = makeWorklet('a');
82
+ const wb = makeWorklet('b');
83
+ const wc = makeWorklet('c');
84
+ StitcherWorkletRegistry.register({ worklet: wa });
85
+ StitcherWorkletRegistry.register({ worklet: wb });
86
+ StitcherWorkletRegistry.register({ worklet: wc });
87
+ const entries = StitcherWorkletRegistry.getEntries();
88
+ expect(entries.map((e) => e.worklet)).toEqual([wa, wb, wc]);
89
+ });
90
+
91
+ it('sorts first-party entries before host entries regardless of registration order', () => {
92
+ const host1 = makeWorklet('host1');
93
+ const fp1 = makeWorklet('fp1');
94
+ const host2 = makeWorklet('host2');
95
+ const fp2 = makeWorklet('fp2');
96
+ // Interleave registrations.
97
+ StitcherWorkletRegistry.register({ worklet: host1 });
98
+ StitcherWorkletRegistry.register({ worklet: fp1, isFirstParty: true });
99
+ StitcherWorkletRegistry.register({ worklet: host2 });
100
+ StitcherWorkletRegistry.register({ worklet: fp2, isFirstParty: true });
101
+ const entries = StitcherWorkletRegistry.getEntries();
102
+ // First-party block first (in registration order),
103
+ // then host block (in registration order).
104
+ expect(entries.map((e) => e.worklet)).toEqual([fp1, fp2, host1, host2]);
105
+ });
106
+
107
+ it('returns a snapshot — mutating the returned array does not affect the registry', () => {
108
+ StitcherWorkletRegistry.register({ worklet: makeWorklet('a') });
109
+ const entries = StitcherWorkletRegistry.getEntries();
110
+ // Cast away readonly so we can attempt a mutation.
111
+ (entries as unknown as unknown[]).push({} as never);
112
+ // Registry's own count is unchanged.
113
+ expect(StitcherWorkletRegistry.count).toBe(1);
114
+ expect(StitcherWorkletRegistry.getEntries()).toHaveLength(1);
115
+ });
116
+ });
117
+
118
+ describe('unregister', () => {
119
+ it('removes a previously-registered entry by ID', () => {
120
+ const id = StitcherWorkletRegistry.register({ worklet: makeWorklet('a') });
121
+ expect(StitcherWorkletRegistry.count).toBe(1);
122
+ StitcherWorkletRegistry.unregister(id);
123
+ expect(StitcherWorkletRegistry.count).toBe(0);
124
+ });
125
+
126
+ it('is a no-op for an unknown ID (no throw)', () => {
127
+ StitcherWorkletRegistry.register({ worklet: makeWorklet('a') });
128
+ expect(() => StitcherWorkletRegistry.unregister('host-9999')).not.toThrow();
129
+ expect(StitcherWorkletRegistry.count).toBe(1);
130
+ });
131
+
132
+ it('removes the right entry when multiple are registered', () => {
133
+ const id1 = StitcherWorkletRegistry.register({ worklet: makeWorklet('a') });
134
+ const id2 = StitcherWorkletRegistry.register({ worklet: makeWorklet('b') });
135
+ const id3 = StitcherWorkletRegistry.register({ worklet: makeWorklet('c') });
136
+ StitcherWorkletRegistry.unregister(id2);
137
+ const remainingIds = StitcherWorkletRegistry.getEntries().map((e) => e.id);
138
+ expect(remainingIds).toEqual([id1, id3]);
139
+ });
140
+
141
+ it('survives double-unregister of the same ID', () => {
142
+ const id = StitcherWorkletRegistry.register({ worklet: makeWorklet('a') });
143
+ StitcherWorkletRegistry.unregister(id);
144
+ expect(() => StitcherWorkletRegistry.unregister(id)).not.toThrow();
145
+ expect(StitcherWorkletRegistry.count).toBe(0);
146
+ });
147
+ });
148
+
149
+ describe('count', () => {
150
+ it('starts at 0 after reset', () => {
151
+ expect(StitcherWorkletRegistry.count).toBe(0);
152
+ });
153
+
154
+ it('reflects register/unregister deltas', () => {
155
+ expect(StitcherWorkletRegistry.count).toBe(0);
156
+ const id1 = StitcherWorkletRegistry.register({ worklet: makeWorklet('a') });
157
+ expect(StitcherWorkletRegistry.count).toBe(1);
158
+ StitcherWorkletRegistry.register({ worklet: makeWorklet('b') });
159
+ expect(StitcherWorkletRegistry.count).toBe(2);
160
+ StitcherWorkletRegistry.unregister(id1);
161
+ expect(StitcherWorkletRegistry.count).toBe(1);
162
+ });
163
+ });
164
+
165
+ describe('_resetForTests', () => {
166
+ it('clears all entries', () => {
167
+ StitcherWorkletRegistry.register({ worklet: makeWorklet('a') });
168
+ StitcherWorkletRegistry.register({ worklet: makeWorklet('b'), isFirstParty: true });
169
+ StitcherWorkletRegistry.register({ worklet: makeWorklet('c') });
170
+ expect(StitcherWorkletRegistry.count).toBe(3);
171
+ StitcherWorkletRegistry._resetForTests();
172
+ expect(StitcherWorkletRegistry.count).toBe(0);
173
+ expect(StitcherWorkletRegistry.getEntries()).toEqual([]);
174
+ });
175
+ });
176
+ });
@@ -0,0 +1,94 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Unit tests for the v0.8.0 Phase 4b `ensureStitcherProxyInstalled`
4
+ * helper. The native install path can't be exercised in jest (no
5
+ * JSI runtime), so these tests cover only the JS-side branches:
6
+ *
7
+ * - Idempotency (second call short-circuits).
8
+ * - "module missing" path returns false + warns once.
9
+ * - "install returns false" path returns false + warns once.
10
+ * - "install throws" path returns false + warns once.
11
+ * - "install succeeds" path returns true + caches.
12
+ *
13
+ * NativeModules is stubbed via the jest mock at
14
+ * `jest.mocks/react-native.js`; per-test customization swaps in a
15
+ * scenario-specific module.
16
+ */
17
+
18
+ import { NativeModules } from 'react-native';
19
+
20
+ import {
21
+ _resetStitcherProxyInstallStateForTests,
22
+ ensureStitcherProxyInstalled,
23
+ } from '../ensureStitcherProxyInstalled';
24
+
25
+ type MutableGlobal = Record<string, unknown>;
26
+
27
+ describe('ensureStitcherProxyInstalled', () => {
28
+ beforeEach(() => {
29
+ _resetStitcherProxyInstallStateForTests();
30
+ // Clear any previous __stitcherProxy from prior test cases so the
31
+ // module's "already installed" fast-path doesn't bypass our
32
+ // scenario-specific NativeModules stub.
33
+ delete (globalThis as MutableGlobal).__stitcherProxy;
34
+ // Wipe the NativeModules.StitcherJsiInstaller entry so each test
35
+ // starts with a clean slate (the mock module is a shared object
36
+ // across the whole test file).
37
+ (NativeModules as MutableGlobal).StitcherJsiInstaller = undefined;
38
+ });
39
+
40
+ it('returns false when the native module is missing', () => {
41
+ expect(ensureStitcherProxyInstalled()).toBe(false);
42
+ });
43
+
44
+ it('returns true when install() returns true', () => {
45
+ const install = jest.fn(() => true);
46
+ (NativeModules as MutableGlobal).StitcherJsiInstaller = { install };
47
+ expect(ensureStitcherProxyInstalled()).toBe(true);
48
+ expect(install).toHaveBeenCalledTimes(1);
49
+ });
50
+
51
+ it('returns false when install() returns false', () => {
52
+ const install = jest.fn(() => false);
53
+ (NativeModules as MutableGlobal).StitcherJsiInstaller = { install };
54
+ expect(ensureStitcherProxyInstalled()).toBe(false);
55
+ expect(install).toHaveBeenCalledTimes(1);
56
+ });
57
+
58
+ it('returns false when install() throws', () => {
59
+ const install = jest.fn(() => {
60
+ throw new Error('boom');
61
+ });
62
+ (NativeModules as MutableGlobal).StitcherJsiInstaller = { install };
63
+ expect(ensureStitcherProxyInstalled()).toBe(false);
64
+ expect(install).toHaveBeenCalledTimes(1);
65
+ });
66
+
67
+ it('is idempotent — second call short-circuits without re-invoking install()', () => {
68
+ const install = jest.fn(() => true);
69
+ (NativeModules as MutableGlobal).StitcherJsiInstaller = { install };
70
+ expect(ensureStitcherProxyInstalled()).toBe(true);
71
+ expect(ensureStitcherProxyInstalled()).toBe(true);
72
+ expect(install).toHaveBeenCalledTimes(1);
73
+ });
74
+
75
+ it('short-circuits if __stitcherProxy is already on globalThis (e.g., other consumer installed it first)', () => {
76
+ // Simulate a different SDK instance having installed the proxy.
77
+ (globalThis as MutableGlobal).__stitcherProxy = {
78
+ install: jest.fn(),
79
+ uninstall: jest.fn(),
80
+ count: jest.fn(() => 0),
81
+ };
82
+ const install = jest.fn();
83
+ (NativeModules as MutableGlobal).StitcherJsiInstaller = { install };
84
+ expect(ensureStitcherProxyInstalled()).toBe(true);
85
+ // We did NOT call our own native install — we accepted the
86
+ // already-installed proxy.
87
+ expect(install).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it('treats `install: undefined` as a missing module (not a crash)', () => {
91
+ (NativeModules as MutableGlobal).StitcherJsiInstaller = {};
92
+ expect(ensureStitcherProxyInstalled()).toBe(false);
93
+ });
94
+ });