diff --git a/packages/shared/src/point-picker/point-picker.test.ts b/packages/shared/src/point-picker/point-picker.test.ts index 55a85a4e..25403dfa 100644 --- a/packages/shared/src/point-picker/point-picker.test.ts +++ b/packages/shared/src/point-picker/point-picker.test.ts @@ -103,7 +103,9 @@ function mount(options: PointPickerOptions, o: MountOpts = {}) { const message = (data: Record, origin = PARENT, source: unknown = parent) => { const ev = new win.MessageEvent('message', { data: { [field]: options.channel, ...data } }); Object.defineProperty(ev, 'origin', { value: origin }); - Object.defineProperty(ev, 'source', { value: o.rn ? null : source }); + // A top-level WebView: the host dispatches from the window itself (its own parent); a + // caller passing `win` says so, the default stands for "not the parent". + Object.defineProperty(ev, 'source', { value: o.rn && source === parent ? null : source }); win.dispatchEvent(ev); }; /** The page's element (must exist). */ @@ -175,6 +177,20 @@ describe('pointPickerScript — single mode (Remix)', () => { expect(h.win.__codeamInspector).toBeUndefined(); expect(Object.isFrozen(h.win.__remixPoint)).toBe(true); }); + + it("inside a React Native WebView (the phone's Point at it) it answers over the bridge, no API needed", () => { + // Remix's phone app drives the picker with an `enable` dispatched from the window itself (its + // parent) and reads `ReactNativeWebView.postMessage`; before 2026-11-01 `send` dropped every + // reply of a top-level document without `globalName`, so the phone never saw `on` nor `pick`. + const h = mount({ ...REMIX, globalName: undefined }, { rn: true, html: '' }); + h.message({ type: 'enable', accent: '#FF5A1F' }, PARENT, h.win); + expect(h.sent[0]).toEqual({ msg: { channel: 'remix-point', type: 'on' }, origin: 'rn' }); + h.tap(h.q('#go span')); + expect(h.types()).toEqual(['on', 'pick']); + expect(h.picks()[0]).toMatchObject({ tag: 'button', id: 'go', text: 'Start' }); + expect(h.sent[1].origin).toBe('rn'); + expect(h.win.__codeamInspector).toBeUndefined(); + }); }); describe('pointPickerScript — the shared look', () => { diff --git a/packages/shared/src/point-picker/script.ts b/packages/shared/src/point-picker/script.ts index 3fceb2aa..e8bfcd5b 100644 --- a/packages/shared/src/point-picker/script.ts +++ b/packages/shared/src/point-picker/script.ts @@ -147,8 +147,10 @@ export function pointPickerScript(opts: PointPickerOptions): string { // Inside a React Native WebView the document is TOP-LEVEL (window.parent === // window), so the only way out is the bridge RN injects. It carries STRINGS. + // Any mode may answer through it: the host app owns the WebView, there is no + // third party to keep out — only the control API (the door below) needs + // \`globalName\`, because a global turns the picker on with nobody's permission. function rnBridge() { - if (!GLOBAL) return null; var rn = W.ReactNativeWebView; return rn && typeof rn.postMessage === 'function' ? rn : null; } @@ -159,13 +161,17 @@ export function pointPickerScript(opts: PointPickerOptions): string { msg[FIELD] = CH; msg.type = type; if (extra) for (var k in extra) msg[k] = extra[k]; + // A parent window first (the feed framing the app; a host that installed + // one); only a top-level document falls back to the React Native bridge + // (2026-11-01: Remix's phone app got no \`on\` and no \`pick\` here). + if (W.parent !== W) { + try { W.parent.postMessage(msg, driver); } catch (e) {} + return; + } var rn = rnBridge(); if (rn) { try { rn.postMessage(JSON.stringify(msg)); } catch (e) {} - return; } - if (W.parent === W) return; - try { W.parent.postMessage(msg, driver); } catch (e) {} } function clip(s, max) { return s.length > max ? s.slice(0, max - 1) + '\\u2026' : s; }