Skip to content

Commit ecb6512

Browse files
Lykhoydaclaude
andauthored
feat(cdp-interact): setFieldValue action for React Hook Form fallback (closes #126 Gap A) (#179)
Adds cdp_interact action="setFieldValue" — explicit React Hook Form escape hatch for when typeText's onChangeText/onChange descendant walk can't find a typeable handler. Closes Gap A of #126. Background: typeText handles the wrapper-Pressable case (depth-bounded descendant walk for inner TextInput) but fails when a field's value flows through field.onChange → FormProvider context → setValue with no inner TextInput-shaped fiber to find. Common with design-system TextFields built as custom Controllers around <Pressable> + custom rendering, where the agent reports "no handler" but the form IS fillable via context.setValue. API: cdp_interact({ action: 'setFieldValue', testID: 'email-field', // anchor inside the form subtree name: 'email', // RHF field name (matches useController/Controller name) value: 'a@b.com', // any string | number | boolean, passed verbatim shouldValidate: true, // default true; pass-through to setValue options shouldDirty: true, // default true; pass-through to setValue options }) Algorithm (injected-helpers.ts setFieldValue handler): - Match the testID anchor (existing infrastructure) - Walk UP via node.return, bounded at 32 levels / 100 fiber visits - Look for the first Provider fiber whose memoizedProps.value duck-types as UseFormReturn: typeof value.setValue === 'function' AND typeof value.getValues === 'function' AND value.control is an object - Call value.setValue(name, value, {shouldValidate, shouldDirty}) - Surface the thrown message if setValue rejects (bad field name etc.) Closest ancestor wins (natural React context resolution) — nested forms behave intuitively. Why walk-up via fiber.return instead of useFormContext(): injected helpers run OUTSIDE React's rendering cycle, so hooks can't be called. The codebase already uses this pattern (memoizedProps walk) for nav and store context — adding setFieldValue follows the established convention. Why explicit action instead of implicit typeText fallback: the design question was deliberate (per discussion). Implicit fallback would change the meaning of an existing tool call and risk surprising users who expected the existing "no handler" error. Explicit setFieldValue is opt-in, self-contained, and documented in the tool description. Tests (8 new in gh-126-set-field-value.test.js, all green): - happy path: walks up to FormProvider, calls setValue with defaults - shouldValidate/shouldDirty pass through verbatim - numeric and boolean values pass through without coercion - nested-forms: closest provider wins (React context semantics) - missing opts.name → clear error, does NOT walk the tree - no FormProvider ancestor → actionable hint pointing at <FormProvider> - setValue throwing → caught + thrown message surfaced - duck-type rejects Providers missing setValue (e.g. ThemeProvider) Other changes: - Helpers bundle version bumped 21 → 22 (existing source-guard test + version-pin assertion both updated) - cdp_interact schema: action enum gains 'setFieldValue' + 'longPress' (latter was already in the description string but missing from the TS type union — fixed for consistency) - New args: name, value, shouldValidate, shouldDirty (all optional; required only for setFieldValue, enforced at handler boundary) Note: codex-pair did not review this commit (the codex MCP server appears disconnected this session — no log entries since May 20). The 8-test coverage + the explicit design approval are the safety net. Verified: 1514/1514 cdp-bridge unit tests passing (+8 net new). Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9e0a586 commit ecb6512

9 files changed

Lines changed: 447 additions & 13 deletions

File tree

scripts/cdp-bridge/dist/index.js

Lines changed: 7 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export const INJECTED_HELPERS = `
22
(function() {
3-
var __HELPERS_VERSION__ = 21;
3+
var __HELPERS_VERSION__ = 22;
44
if (globalThis.__RN_AGENT && globalThis.__RN_AGENT.__v === __HELPERS_VERSION__) return;
55
if (globalThis.__RN_AGENT) delete globalThis.__RN_AGENT;
66
@@ -1247,6 +1247,87 @@ export const INJECTED_HELPERS = `
12471247
});
12481248
}
12491249
1250+
if (action === 'setFieldValue') {
1251+
// Issue #126 Gap A — explicit React Hook Form fallback. typeText's
1252+
// handler chain walks DOWN looking for a TextInput descendant with
1253+
// onChangeText/onChange. That works for wrapper-Pressable patterns
1254+
// where the inner TextInput is reachable, but fails when the field's
1255+
// value flows through field.onChange → FormProvider context →
1256+
// setValue. There's no inner TextInput-shaped fiber to find, because
1257+
// the design-system field calls field.onChange directly via a
1258+
// Controller render prop.
1259+
//
1260+
// Resolution: walk UP from the matched fiber (the testID anchor)
1261+
// looking for a Provider fiber whose memoizedProps.value duck-types
1262+
// as a React Hook Form UseFormReturn. Then call value.setValue(
1263+
// name, value, options). The closest ancestor wins (natural React
1264+
// context resolution), so nested forms behave intuitively.
1265+
var fieldName = opts.name;
1266+
var fieldValue = opts.value;
1267+
if (typeof fieldName !== 'string' || fieldName.length === 0) {
1268+
return JSON.stringify({
1269+
error: 'setFieldValue requires opts.name (the RHF field name)',
1270+
testID: selector,
1271+
hint: 'Pass the same \`name\` string you used in \`useController({ name })\` or \`<Controller name="..." />\`.'
1272+
});
1273+
}
1274+
var shouldValidate = opts.shouldValidate !== false;
1275+
var shouldDirty = opts.shouldDirty !== false;
1276+
1277+
var ANCESTOR_DEPTH_CAP = 32;
1278+
var ANCESTOR_VISIT_CAP = 100;
1279+
function looksLikeUseFormReturn(v) {
1280+
return (
1281+
v && typeof v === 'object'
1282+
&& typeof v.setValue === 'function'
1283+
&& typeof v.getValues === 'function'
1284+
&& v.control && typeof v.control === 'object'
1285+
);
1286+
}
1287+
var ancestor = found.return;
1288+
var ancestorDepth = 0;
1289+
var ancestorVisits = 0;
1290+
var formReturn = null;
1291+
while (ancestor && ancestorDepth < ANCESTOR_DEPTH_CAP && ancestorVisits < ANCESTOR_VISIT_CAP) {
1292+
ancestorVisits++;
1293+
var aProps = ancestor.memoizedProps;
1294+
if (aProps && looksLikeUseFormReturn(aProps.value)) {
1295+
formReturn = aProps.value;
1296+
break;
1297+
}
1298+
ancestor = ancestor.return;
1299+
ancestorDepth++;
1300+
}
1301+
if (!formReturn) {
1302+
return JSON.stringify({
1303+
error: 'setFieldValue: no FormProvider ancestor found',
1304+
testID: selector,
1305+
ancestorVisits: ancestorVisits,
1306+
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.'
1307+
});
1308+
}
1309+
try {
1310+
formReturn.setValue(fieldName, fieldValue, { shouldValidate: shouldValidate, shouldDirty: shouldDirty });
1311+
} catch (e) {
1312+
return JSON.stringify({
1313+
error: 'setFieldValue: setValue threw: ' + (e && e.message ? e.message : String(e)),
1314+
testID: selector,
1315+
name: fieldName,
1316+
hint: 'The form was found but its setValue rejected the call. Common causes: name does not exist on the form, value type mismatch, or the form is in a transitioning state.'
1317+
});
1318+
}
1319+
return JSON.stringify({
1320+
success: true,
1321+
action: 'setFieldValue',
1322+
testID: selector,
1323+
name: fieldName,
1324+
value: fieldValue,
1325+
shouldValidate: shouldValidate,
1326+
shouldDirty: shouldDirty,
1327+
ancestorVisits: ancestorVisits
1328+
});
1329+
}
1330+
12501331
if (action === 'scroll') {
12511332
var x = opts.scrollX !== undefined ? opts.scrollX : 0;
12521333
var y = opts.scrollY !== undefined ? opts.scrollY : 300;

scripts/cdp-bridge/dist/tools/interact.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ export function createInteractHandler(getClient) {
77
if (args.action === 'typeText' && args.text === undefined) {
88
return failResult('text parameter is required for typeText action');
99
}
10+
if (args.action === 'setFieldValue') {
11+
if (args.name === undefined || args.name.length === 0) {
12+
return failResult('name parameter is required for setFieldValue action — the React Hook Form field name');
13+
}
14+
if (args.value === undefined) {
15+
return failResult('value parameter is required for setFieldValue action');
16+
}
17+
}
1018
const opts = { action: args.action };
1119
if (args.testID !== undefined)
1220
opts.testID = args.testID;
@@ -19,6 +27,14 @@ export function createInteractHandler(getClient) {
1927
if (args.scrollY !== undefined)
2028
opts.scrollY = args.scrollY;
2129
opts.animated = args.animated;
30+
if (args.name !== undefined)
31+
opts.name = args.name;
32+
if (args.value !== undefined)
33+
opts.value = args.value;
34+
if (args.shouldValidate !== undefined)
35+
opts.shouldValidate = args.shouldValidate;
36+
if (args.shouldDirty !== undefined)
37+
opts.shouldDirty = args.shouldDirty;
2238
const result = await client.evaluate(`__RN_AGENT.interact(${JSON.stringify(opts)})`);
2339
if (result.error) {
2440
return failResult(`Interact error: ${result.error}`);

scripts/cdp-bridge/src/index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -507,15 +507,19 @@ trackedTool(
507507

508508
trackedTool(
509509
'cdp_interact',
510-
'Interact with React components by testID (preferred) or accessibilityLabel — press buttons, long-press, type text, scroll. 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.',
510+
'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".',
511511
{
512-
action: z.enum(['press', 'longPress', 'typeText', 'scroll']).describe('press: calls onPress. longPress: calls onLongPress. typeText: calls onChangeText. scroll: calls scrollTo or onScroll.'),
513-
testID: z.string().optional().describe('testID prop of the target component (strict match — preferred)'),
512+
action: z.enum(['press', 'longPress', 'typeText', 'scroll', 'setFieldValue']).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}).'),
513+
testID: z.string().optional().describe('testID prop of the target component (strict match — preferred). For setFieldValue, this is the testID anchor inside the form\'s subtree from which to walk up.'),
514514
accessibilityLabel: z.string().optional().describe('accessibilityLabel prop (used if testID not provided). Tiered match: exact → normalized (trim+lowercase) → substring. Returns Ambiguous error if >1 component matches.'),
515515
text: z.string().optional().describe('Required for typeText: the text to enter'),
516516
scrollX: z.number().optional().describe('For scroll: horizontal offset in pixels (default 0)'),
517517
scrollY: z.number().optional().describe('For scroll: vertical offset in pixels (default 300)'),
518518
animated: z.boolean().default(true).describe('For scroll: whether to animate'),
519+
name: z.string().optional().describe('Required for setFieldValue: the React Hook Form field name (same string you passed to useController({name}) or <Controller name="...">).'),
520+
value: z.union([z.string(), z.number(), z.boolean()]).optional().describe('Required for setFieldValue: the value to set. Passed verbatim to setValue; no coercion.'),
521+
shouldValidate: z.boolean().optional().describe('For setFieldValue: pass-through to setValue\'s options.shouldValidate (default true). Set false to suppress synchronous validation.'),
522+
shouldDirty: z.boolean().optional().describe('For setFieldValue: pass-through to setValue\'s options.shouldDirty (default true). Set false to keep the field marked pristine.'),
519523
},
520524
createInteractHandler(getClient),
521525
);

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

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export const INJECTED_HELPERS = `
22
(function() {
3-
var __HELPERS_VERSION__ = 21;
3+
var __HELPERS_VERSION__ = 22;
44
if (globalThis.__RN_AGENT && globalThis.__RN_AGENT.__v === __HELPERS_VERSION__) return;
55
if (globalThis.__RN_AGENT) delete globalThis.__RN_AGENT;
66
@@ -1247,6 +1247,87 @@ export const INJECTED_HELPERS = `
12471247
});
12481248
}
12491249
1250+
if (action === 'setFieldValue') {
1251+
// Issue #126 Gap A — explicit React Hook Form fallback. typeText's
1252+
// handler chain walks DOWN looking for a TextInput descendant with
1253+
// onChangeText/onChange. That works for wrapper-Pressable patterns
1254+
// where the inner TextInput is reachable, but fails when the field's
1255+
// value flows through field.onChange → FormProvider context →
1256+
// setValue. There's no inner TextInput-shaped fiber to find, because
1257+
// the design-system field calls field.onChange directly via a
1258+
// Controller render prop.
1259+
//
1260+
// Resolution: walk UP from the matched fiber (the testID anchor)
1261+
// looking for a Provider fiber whose memoizedProps.value duck-types
1262+
// as a React Hook Form UseFormReturn. Then call value.setValue(
1263+
// name, value, options). The closest ancestor wins (natural React
1264+
// context resolution), so nested forms behave intuitively.
1265+
var fieldName = opts.name;
1266+
var fieldValue = opts.value;
1267+
if (typeof fieldName !== 'string' || fieldName.length === 0) {
1268+
return JSON.stringify({
1269+
error: 'setFieldValue requires opts.name (the RHF field name)',
1270+
testID: selector,
1271+
hint: 'Pass the same \`name\` string you used in \`useController({ name })\` or \`<Controller name="..." />\`.'
1272+
});
1273+
}
1274+
var shouldValidate = opts.shouldValidate !== false;
1275+
var shouldDirty = opts.shouldDirty !== false;
1276+
1277+
var ANCESTOR_DEPTH_CAP = 32;
1278+
var ANCESTOR_VISIT_CAP = 100;
1279+
function looksLikeUseFormReturn(v) {
1280+
return (
1281+
v && typeof v === 'object'
1282+
&& typeof v.setValue === 'function'
1283+
&& typeof v.getValues === 'function'
1284+
&& v.control && typeof v.control === 'object'
1285+
);
1286+
}
1287+
var ancestor = found.return;
1288+
var ancestorDepth = 0;
1289+
var ancestorVisits = 0;
1290+
var formReturn = null;
1291+
while (ancestor && ancestorDepth < ANCESTOR_DEPTH_CAP && ancestorVisits < ANCESTOR_VISIT_CAP) {
1292+
ancestorVisits++;
1293+
var aProps = ancestor.memoizedProps;
1294+
if (aProps && looksLikeUseFormReturn(aProps.value)) {
1295+
formReturn = aProps.value;
1296+
break;
1297+
}
1298+
ancestor = ancestor.return;
1299+
ancestorDepth++;
1300+
}
1301+
if (!formReturn) {
1302+
return JSON.stringify({
1303+
error: 'setFieldValue: no FormProvider ancestor found',
1304+
testID: selector,
1305+
ancestorVisits: ancestorVisits,
1306+
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.'
1307+
});
1308+
}
1309+
try {
1310+
formReturn.setValue(fieldName, fieldValue, { shouldValidate: shouldValidate, shouldDirty: shouldDirty });
1311+
} catch (e) {
1312+
return JSON.stringify({
1313+
error: 'setFieldValue: setValue threw: ' + (e && e.message ? e.message : String(e)),
1314+
testID: selector,
1315+
name: fieldName,
1316+
hint: 'The form was found but its setValue rejected the call. Common causes: name does not exist on the form, value type mismatch, or the form is in a transitioning state.'
1317+
});
1318+
}
1319+
return JSON.stringify({
1320+
success: true,
1321+
action: 'setFieldValue',
1322+
testID: selector,
1323+
name: fieldName,
1324+
value: fieldValue,
1325+
shouldValidate: shouldValidate,
1326+
shouldDirty: shouldDirty,
1327+
ancestorVisits: ancestorVisits
1328+
});
1329+
}
1330+
12501331
if (action === 'scroll') {
12511332
var x = opts.scrollX !== undefined ? opts.scrollX : 0;
12521333
var y = opts.scrollY !== undefined ? opts.scrollY : 300;

scripts/cdp-bridge/src/tools/interact.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { CDPClient } from '../cdp-client.js';
22
import { okResult, failResult, warnResult, withConnection } from '../utils.js';
33

4-
type InteractAction = 'press' | 'typeText' | 'scroll';
4+
type InteractAction = 'press' | 'longPress' | 'typeText' | 'scroll' | 'setFieldValue';
55

66
interface InteractArgs {
77
action: InteractAction;
@@ -11,6 +11,11 @@ interface InteractArgs {
1111
scrollX?: number;
1212
scrollY?: number;
1313
animated: boolean;
14+
// setFieldValue — see injected-helpers.ts setFieldValue handler.
15+
name?: string;
16+
value?: string | number | boolean;
17+
shouldValidate?: boolean;
18+
shouldDirty?: boolean;
1419
}
1520

1621
export function createInteractHandler(getClient: () => CDPClient) {
@@ -21,6 +26,14 @@ export function createInteractHandler(getClient: () => CDPClient) {
2126
if (args.action === 'typeText' && args.text === undefined) {
2227
return failResult('text parameter is required for typeText action');
2328
}
29+
if (args.action === 'setFieldValue') {
30+
if (args.name === undefined || args.name.length === 0) {
31+
return failResult('name parameter is required for setFieldValue action — the React Hook Form field name');
32+
}
33+
if (args.value === undefined) {
34+
return failResult('value parameter is required for setFieldValue action');
35+
}
36+
}
2437

2538
const opts: Record<string, unknown> = { action: args.action };
2639
if (args.testID !== undefined) opts.testID = args.testID;
@@ -29,6 +42,10 @@ export function createInteractHandler(getClient: () => CDPClient) {
2942
if (args.scrollX !== undefined) opts.scrollX = args.scrollX;
3043
if (args.scrollY !== undefined) opts.scrollY = args.scrollY;
3144
opts.animated = args.animated;
45+
if (args.name !== undefined) opts.name = args.name;
46+
if (args.value !== undefined) opts.value = args.value;
47+
if (args.shouldValidate !== undefined) opts.shouldValidate = args.shouldValidate;
48+
if (args.shouldDirty !== undefined) opts.shouldDirty = args.shouldDirty;
3249

3350
const result = await client.evaluate(
3451
`__RN_AGENT.interact(${JSON.stringify(opts)})`

0 commit comments

Comments
 (0)