Skip to content

Commit 0a9a732

Browse files
Lykhoydaclaude
andauthored
fix(interact): cdp_interact value-injection for Controller inputs (#336) (#372)
* docs(336): design spec — fix cdp_interact value-injection for Controller inputs Bug #2: type-match in setFieldValue (coerce number->string only when the field currently holds a string), preserving the intentional number/boolean passthrough. Bug #1: optional value on press (onPress(value) instead of the synthetic event) for value-bearing controls like radios/chips. HELPERS_VERSION 32->33. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(336): TDD implementation plan — cdp_interact value-injection fix Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(336): apply multi-LLM plan review — ES5 imperative press return; fix harness wording Codex + Gemini reviewed the plan: no blocking/important findings. One unanimous advisory applied — build the press result object imperatively (var + assignment) instead of object-spread, matching the injected-helpers' deliberate ES5 style (safe on older Hermes). Spec wording corrected: tests inline the runInteract VM harness copied from gh-126, not a shared inject-harness.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(336): setFieldValue type-matches number->string for string fields; HELPERS_VERSION 33 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(336): press passes value to onPress for value-bearing controls; doc value/action Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(changeset): cdp_interact value-injection fix (#336, cdp + plugin patch) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(336): clarify value describe — string-preservation applies when field holds a string Final-review finding: the setFieldValue string-coercion keys on the field's current value, so string fields want a "" default. Describe now says so. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 53fb3bb commit 0a9a732

8 files changed

Lines changed: 774 additions & 10 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"rn-dev-agent-cdp": patch
3+
"rn-dev-agent-plugin": patch
4+
---
5+
6+
fix(interact): cdp_interact no longer corrupts react-hook-form Controller-wrapped inputs (#336). `setFieldValue` keeps a string a string for string-typed fields (a digit-string injected as a number is coerced back to string only when the field currently holds a string — number/boolean fields are untouched). `press` gains an optional `value`: when provided, `onPress` receives the value instead of a synthetic event, so radio/chip-style controls whose onPress sets a form value select correctly. HELPERS_VERSION bumped to 33.

docs/superpowers/plans/2026-06-30-336-cdp-interact-value-coercion.md

Lines changed: 394 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# #336 — Fix `cdp_interact` value-injection for Controller-wrapped inputs
2+
3+
Date: 2026-06-30
4+
Issue: [#336](https://github.com/Lykhoyda/rn-dev-agent/issues/336)
5+
Status: Design approved — ready for implementation plan
6+
7+
## Problem
8+
9+
`cdp_interact` value-injection corrupts react-hook-form `Controller`-wrapped custom
10+
inputs in two ways (reproduced on iOS bridgeless, RN 0.83 New Arch, Expo 55):
11+
12+
1. **Press → event object.** `cdp_interact press testID=<radio_option>` calls
13+
`props.onPress({ nativeEvent: {} })`. For a value-bearing control whose `onPress`
14+
routes to the Controller's `onChange` (radio/chip), the **synthetic event object**
15+
becomes the field value → Zod fails "received object".
16+
2. **Digit-string → number.** `cdp_interact setFieldValue value="15112345678"` ends up
17+
calling `setValue(name, 15112345678)` with a **number**, so a `z.string()` schema
18+
fails "Expected string, received number".
19+
20+
Net: any `Controller`-wrapped custom input (radios, phone, other `onValueChange`
21+
fields) cannot be driven via the preferred JS interaction tier, forcing `device_*`
22+
workarounds.
23+
24+
## Root cause (verified in code)
25+
26+
- **Bug #1:** `scripts/cdp-bridge/src/injected-helpers.ts:1333`
27+
`props.onPress({ nativeEvent: {} })`. Correct for a plain button; wrong when
28+
`onPress` is a value setter expecting the option value.
29+
- **Bug #2:** NOT a coercion in the bridge. `interact.ts` forwards `value` verbatim and
30+
`JSON.stringify` preserves types; the `setFieldValue` helper
31+
(`injected-helpers.ts:1533,1577`) passes `opts.value` straight to `setValue`. The
32+
trap is the **schema**: `index.ts:984` types `value` as
33+
`z.union([z.string(), z.number(), z.boolean()])`. Because `number` is admitted, a
34+
digit-string is naturally emitted/parsed as the number `15112345678`, which the
35+
helper faithfully forwards. The `number`/`boolean` passthrough is an **intentional
36+
feature**`test/unit/gh-126-set-field-value.test.js:189` asserts `value:42` /
37+
`value:true` pass through unchanged — so a fix must preserve it.
38+
39+
## Approach
40+
41+
Both fixes live in the **injected helpers** (deterministic, unit-testable via the
42+
`runInteract` VM-sandbox harness copied from `test/unit/gh-126-set-field-value.test.js`).
43+
`interact.ts` already forwards `value` for
44+
every action, so there is **no TS handler logic change**. `index.ts` gets schema
45+
*description* updates only (the union stays `string|number|boolean`). Bump
46+
`HELPERS_VERSION` 32 → 33 so on-device helpers re-inject on next use.
47+
48+
### Bug #2 — type-match in `setFieldValue` (injected-helpers.ts, before `setValue` ~1576)
49+
50+
Coerce a number back to string ONLY when the field currently holds a string:
51+
52+
```js
53+
// GH #336: the value union admits `number`, so a digit-string meant as a string
54+
// (phone, codes, IDs) can arrive as a number and fail a z.string() schema. If the
55+
// field currently holds a string but the injected value is a number, coerce to
56+
// string. Number/boolean fields (current value is number/undefined/object) are
57+
// untouched, preserving the intentional gh-126 passthrough.
58+
var coercedToString = false;
59+
if (typeof fieldValue === 'number') {
60+
var current;
61+
try { current = formReturn.getValues(fieldName); } catch (e) { current = undefined; }
62+
if (typeof current === 'string') { fieldValue = String(fieldValue); coercedToString = true; }
63+
}
64+
```
65+
66+
- Phone (default `''`) + injected number → `getValues` returns `''` (string) →
67+
`"15112345678"`. Fixed.
68+
- gh-126: `getValues('age')` returns `{}`/`undefined` (not a string) → no coercion →
69+
`42`/`true` unchanged. Preserved.
70+
- Direction is number→string **only** (the reported bug); never string→number. A
71+
`getValues` throw → no coercion. The result JSON carries `coercedToString` for
72+
observability.
73+
74+
### Bug #1 — optional `value` on `press` (injected-helpers.ts:1333)
75+
76+
```js
77+
if (action === 'press') {
78+
if (typeof props.onPress !== 'function') { /* unchanged error */ }
79+
if (opts.value !== undefined) props.onPress(opts.value); // GH #336: value-bearing control
80+
else props.onPress({ nativeEvent: {} }); // unchanged: plain button
81+
return JSON.stringify({ success: true, action: 'press', component: typeName,
82+
testID: selector, ...(opts.value !== undefined ? { value: opts.value } : {}) });
83+
}
84+
```
85+
86+
- `press value="male"``onPress("male")` → Controller's `onChange` gets the value,
87+
not the event. Fixed.
88+
- `press` with no `value``{ nativeEvent: {} }` unchanged → existing press behavior
89+
and tests preserved.
90+
- Press `value` is passed **verbatim** (no `getValues` type-match — press has no form
91+
`name` to consult). Radio option values are typically non-numeric strings, and the
92+
caller controls the type.
93+
94+
### Schema / docs (index.ts — describe-only, no type change)
95+
96+
- `value` describe: also used by `press` for value-bearing controls (radios/chips) —
97+
`onPress` receives the value instead of a synthetic event.
98+
- `action` enum describe: `press` → "calls onPress (with `value` if provided, for
99+
radio/chip-style value-bearing controls)".
100+
101+
## Components & data flow
102+
103+
- `injected-helpers.ts` — only file with logic changes: `press` block (Bug #1),
104+
`setFieldValue` block (Bug #2), `HELPERS_VERSION` 32 → 33.
105+
- `index.ts``value` + `action` describe strings (Bug #1 documentation).
106+
- `interact.ts` — unchanged (value already forwarded for all actions). Optional: a
107+
one-line comment noting `value` is now also a press input.
108+
- Tests — `test/unit/gh-336-interact-value-injection.test.js` (new), using the harness.
109+
110+
## Error handling
111+
112+
- `setFieldValue` with a string-typed field + number → silent, correct coercion
113+
(logged via `coercedToString`). No new error path.
114+
- `press value=...` on a component with no `onPress` → the existing
115+
"Component has no onPress handler" error, unchanged.
116+
- `getValues` throwing inside the helper is caught → falls back to no coercion (never
117+
fails the call over a type-match).
118+
119+
## Testing
120+
121+
**Unit (primary, via the `runInteract` VM harness copied from gh-126):**
122+
- Bug #2: string-field (`getValues``''`) + number → `setValue` receives
123+
`"15112345678"` (string) and `coercedToString:true`; numeric field
124+
(`getValues``undefined`/`{}`) + `42` → unchanged number (re-assert gh-126);
125+
boolean unchanged; injected string → unchanged; `getValues` throws → number
126+
unchanged.
127+
- Bug #1: `press value="A"``onPress` called with `"A"` (assert the arg is the
128+
string, not an object); `press` with no value → `onPress({nativeEvent:{}})`;
129+
`press` on a fiber with no `onPress` → error unchanged.
130+
131+
**Device verification (iOS + Android per project workflow):** a screen with an RHF
132+
radio group (drive via `press value=<option>`) and a `z.string()` phone field (drive
133+
via `setFieldValue`) — confirm the form value types are correct and the form submits
134+
without falling back to `device_*`.
135+
136+
## Out of scope
137+
138+
- Dropping `number`/`boolean` from the `value` union (keeps the intentional feature).
139+
- Symmetric string→number coercion for `setFieldValue`.
140+
- `getValues` type-match for press `value` (press has no form `name`).
141+
- Any native `device_*` interaction path — these bugs are JS-injection only.
142+
143+
## Success criteria
144+
145+
- `cdp_interact press testID=<radio> value=<option>` selects the option (Controller
146+
`onChange` receives the option value, not an event object).
147+
- `cdp_interact setFieldValue name=<phone> value="15112345678"` leaves the field a
148+
string when the field is string-typed; numeric/boolean injection is unchanged.
149+
- New unit tests cover both bugs and re-assert the gh-126 passthrough.
150+
- `HELPERS_VERSION` bumped so released bridges re-inject.
151+
- Device-verified on iOS + Android.

scripts/cdp-bridge/dist/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -657,7 +657,7 @@ trackedTool('cdp_dev_settings', 'Control React Native dev settings programmatica
657657
trackedTool('cdp_interact', 'Interact with React components by testID (preferred) or accessibilityLabel — press buttons, long-press, type text, scroll, or set a React Hook Form field value directly. Calls JS handlers directly (not native touch). testID matches strictly; accessibilityLabel matches in tiers (exact → trim/case-insensitive → substring) and returns an ambiguity error when >1 component matches. Prefer testID for unambiguous targeting. For native gestures (swipe, drag), use device_swipe/device_press instead. setFieldValue (GH #126 Gap A): explicit fallback when typeText fails because the field routes through a Controller — pass name + value, walks UP to the nearest FormProvider and calls its setValue. Use only when typeText returns "no handler". Portal-root coverage (GH #126 Gap B): if your app uses react-native-actions-sheet, @gorhom/bottom-sheet, or any Modal-based portal whose fiber root is not in React DevTools\' getFiberRoots() registry, set `globalThis.__RN_AGENT_EXTRA_ROOTS__ = () => [sheetRef.current, ...]` in your __DEV__ block — testID resolution will then reach inside those subtrees. See CLAUDE.md template for the canonical snippet.', {
658658
action: z
659659
.enum(['press', 'longPress', 'typeText', 'scroll', 'setFieldValue'])
660-
.describe('press: calls onPress. longPress: calls onLongPress. typeText: calls onChangeText. scroll: calls scrollTo or onScroll. setFieldValue: walks UP to nearest React Hook Form FormProvider and calls setValue(name, value, {shouldValidate, shouldDirty}).'),
660+
.describe('press: calls onPress (with `value` if provided, for radio/chip-style value-bearing controls). longPress: calls onLongPress. typeText: calls onChangeText. scroll: calls scrollTo or onScroll. setFieldValue: walks UP to nearest React Hook Form FormProvider and calls setValue(name, value, {shouldValidate, shouldDirty}).'),
661661
testID: z
662662
.string()
663663
.optional()
@@ -696,7 +696,7 @@ trackedTool('cdp_interact', 'Interact with React components by testID (preferred
696696
value: z
697697
.union([z.string(), z.number(), z.boolean()])
698698
.optional()
699-
.describe('Required for setFieldValue: the value to set. Passed verbatim to setValue; no coercion.'),
699+
.describe('Value to set. For setFieldValue: passed to setValue (a digit-string is kept a string when the field currently holds a string — give string fields a "" default so this applies). For press: when provided, onPress receives this value instead of a synthetic event — use for radio/chip-style value-bearing controls.'),
700700
shouldValidate: z
701701
.boolean()
702702
.optional()

scripts/cdp-bridge/dist/injected-helpers.js

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// whenever the injected surface changes; it flows into the IIFE's freshness
33
// check (__RN_AGENT.__v) AND the post-injection log line, so they can never
44
// drift (the log previously hard-coded a stale "v11").
5-
export const HELPERS_VERSION = 32;
5+
export const HELPERS_VERSION = 33;
66
export const INJECTED_HELPERS = `
77
(function() {
88
var __HELPERS_VERSION__ = ${HELPERS_VERSION};
@@ -1329,8 +1329,14 @@ export const INJECTED_HELPERS = `
13291329
if (typeof props.onPress !== 'function') {
13301330
return JSON.stringify({ error: 'Component has no onPress handler', component: typeName, testID: selector });
13311331
}
1332-
props.onPress({ nativeEvent: {} });
1333-
return JSON.stringify({ success: true, action: 'press', component: typeName, testID: selector });
1332+
if (opts.value !== undefined) {
1333+
props.onPress(opts.value);
1334+
} else {
1335+
props.onPress({ nativeEvent: {} });
1336+
}
1337+
var pressResult = { success: true, action: 'press', component: typeName, testID: selector };
1338+
if (opts.value !== undefined) pressResult.value = opts.value;
1339+
return JSON.stringify(pressResult);
13341340
}
13351341
13361342
if (action === 'typeText') {
@@ -1572,6 +1578,15 @@ export const INJECTED_HELPERS = `
15721578
hint: 'No React Hook Form FormProvider ancestor with setValue+getValues+control was reachable within ' + ANCESTOR_DEPTH_CAP + ' levels. Either the form is not wrapped in <FormProvider {...methods}>, or the testID anchor sits outside the form subtree. If you only need to fire onChangeText/onChange, use action="typeText" instead.'
15731579
});
15741580
}
1581+
var coercedToString = false;
1582+
if (typeof fieldValue === 'number') {
1583+
var currentValue;
1584+
try { currentValue = formReturn.getValues(fieldName); } catch (e2) { currentValue = undefined; }
1585+
if (typeof currentValue === 'string') {
1586+
fieldValue = String(fieldValue);
1587+
coercedToString = true;
1588+
}
1589+
}
15751590
try {
15761591
formReturn.setValue(fieldName, fieldValue, { shouldValidate: shouldValidate, shouldDirty: shouldDirty });
15771592
} catch (e) {
@@ -1588,6 +1603,7 @@ export const INJECTED_HELPERS = `
15881603
testID: selector,
15891604
name: fieldName,
15901605
value: fieldValue,
1606+
coercedToString: coercedToString,
15911607
shouldValidate: shouldValidate,
15921608
shouldDirty: shouldDirty,
15931609
ancestorVisits: ancestorVisits

scripts/cdp-bridge/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -931,7 +931,7 @@ trackedTool(
931931
action: z
932932
.enum(['press', 'longPress', 'typeText', 'scroll', 'setFieldValue'])
933933
.describe(
934-
'press: calls onPress. longPress: calls onLongPress. typeText: calls onChangeText. scroll: calls scrollTo or onScroll. setFieldValue: walks UP to nearest React Hook Form FormProvider and calls setValue(name, value, {shouldValidate, shouldDirty}).',
934+
'press: calls onPress (with `value` if provided, for radio/chip-style value-bearing controls). longPress: calls onLongPress. typeText: calls onChangeText. scroll: calls scrollTo or onScroll. setFieldValue: walks UP to nearest React Hook Form FormProvider and calls setValue(name, value, {shouldValidate, shouldDirty}).',
935935
),
936936
testID: z
937937
.string()
@@ -984,7 +984,7 @@ trackedTool(
984984
.union([z.string(), z.number(), z.boolean()])
985985
.optional()
986986
.describe(
987-
'Required for setFieldValue: the value to set. Passed verbatim to setValue; no coercion.',
987+
'Value to set. For setFieldValue: passed to setValue (a digit-string is kept a string when the field currently holds a string — give string fields a "" default so this applies). For press: when provided, onPress receives this value instead of a synthetic event — use for radio/chip-style value-bearing controls.',
988988
),
989989
shouldValidate: z
990990
.boolean()

scripts/cdp-bridge/src/injected-helpers.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// whenever the injected surface changes; it flows into the IIFE's freshness
33
// check (__RN_AGENT.__v) AND the post-injection log line, so they can never
44
// drift (the log previously hard-coded a stale "v11").
5-
export const HELPERS_VERSION = 32;
5+
export const HELPERS_VERSION = 33;
66

77
export const INJECTED_HELPERS = `
88
(function() {
@@ -1330,8 +1330,14 @@ export const INJECTED_HELPERS = `
13301330
if (typeof props.onPress !== 'function') {
13311331
return JSON.stringify({ error: 'Component has no onPress handler', component: typeName, testID: selector });
13321332
}
1333-
props.onPress({ nativeEvent: {} });
1334-
return JSON.stringify({ success: true, action: 'press', component: typeName, testID: selector });
1333+
if (opts.value !== undefined) {
1334+
props.onPress(opts.value);
1335+
} else {
1336+
props.onPress({ nativeEvent: {} });
1337+
}
1338+
var pressResult = { success: true, action: 'press', component: typeName, testID: selector };
1339+
if (opts.value !== undefined) pressResult.value = opts.value;
1340+
return JSON.stringify(pressResult);
13351341
}
13361342
13371343
if (action === 'typeText') {
@@ -1573,6 +1579,15 @@ export const INJECTED_HELPERS = `
15731579
hint: 'No React Hook Form FormProvider ancestor with setValue+getValues+control was reachable within ' + ANCESTOR_DEPTH_CAP + ' levels. Either the form is not wrapped in <FormProvider {...methods}>, or the testID anchor sits outside the form subtree. If you only need to fire onChangeText/onChange, use action="typeText" instead.'
15741580
});
15751581
}
1582+
var coercedToString = false;
1583+
if (typeof fieldValue === 'number') {
1584+
var currentValue;
1585+
try { currentValue = formReturn.getValues(fieldName); } catch (e2) { currentValue = undefined; }
1586+
if (typeof currentValue === 'string') {
1587+
fieldValue = String(fieldValue);
1588+
coercedToString = true;
1589+
}
1590+
}
15761591
try {
15771592
formReturn.setValue(fieldName, fieldValue, { shouldValidate: shouldValidate, shouldDirty: shouldDirty });
15781593
} catch (e) {
@@ -1589,6 +1604,7 @@ export const INJECTED_HELPERS = `
15891604
testID: selector,
15901605
name: fieldName,
15911606
value: fieldValue,
1607+
coercedToString: coercedToString,
15921608
shouldValidate: shouldValidate,
15931609
shouldDirty: shouldDirty,
15941610
ancestorVisits: ancestorVisits

0 commit comments

Comments
 (0)