From 7a2a16a304f683afd10b3dc07f1e701390c9e266 Mon Sep 17 00:00:00 2001 From: Vojtech Novak Date: Fri, 23 Oct 2026 01:13:44 +0200 Subject: [PATCH] =?UTF-8?q?[agent-cli]=20runtime:tree=20finds=20the=20focu?= =?UTF-8?q?sed=20screen=20of=20an=20expo-router=2058=20app=20(llp/0018=20?= =?UTF-8?q?=C2=A7Shipped=20decisions=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem (dogfood finding N3-08): in an expo-router 58 app, runtime:tree said "which screen is focused could not be determined" and listed every mounted screen, oldest first. After three pushes of street/[name], --testID street-length returned three matches, so an agent read a stale screen first. Cause: the focus detection read only a fiber named Screen with props isFocused and name. That is the expo-router native tabs shape. React Navigation's Screen and SceneView carry focused and route instead, so on the native stack and JS tabs focus was null. Change: the screen reader keeps the native tabs shape (Screen with isFocused and name) and accepts the React Navigation shape (Screen for JS tabs and drawer, SceneView for the native stack, with focused and route). The focus rule is unchanged. JS stack, JS top tabs and headless ui tabs render no screen fiber either shape matches; they still report focusedScreen null and the whole tree, and the LLP says so. The test fixture screen() is renamed nativeTabsScreen() to name its source. expo-router 58.0.24 source: native tabs render Screen with name and isFocused (build/native-tabs/NativeTabsView.ios.js:65-69). JS tabs render elements.Screen with focused and route (build/react-navigation/bottom-tabs/views/BottomTabView.js:260), and so does the drawer (drawer/views/DrawerView.js:249-251). The native stack renders a SceneView with focused and route (native-stack/views/NativeStackView.native.js:100,385-387). The core SceneView (core/SceneView.js:74) is rendered inside every route and has no focused prop. The JS stack renders CardContainerInner with focused and scene (stack/views/Stack/CardStack.js:476-481). Red before: the focus filter in an expo-router 58 app > finds the top stack screen as the focused one, and matches one element of three street screens (focusedScreen was null, expected "street/[name]"). Guard: the focus filter in an expo-router 58 app > reports no focused screen and the whole tree under the JS stack, whose cards are not screen fibers (passes before; fails with "expected 'street/[name]' to be null" if a SceneView without focused counts as focused). The core SceneView inside each route of the first test fails it with "expected +0 to be 1" if a missing focused reads as false. Co-Authored-By: Claude Opus 5.5 --- packages/@expo/agent-cli/CHANGELOG.md | 1 + .../llp/0018-interaction-commands.rfc.md | 9 +- .../interact/__tests__/expression-test.ts | 94 ++++++++++++++++--- .../runtime/interact/__tests__/fiberTree.ts | 25 ++++- .../src/runtime/interact/expression.ts | 20 ++-- 5 files changed, 129 insertions(+), 20 deletions(-) diff --git a/packages/@expo/agent-cli/CHANGELOG.md b/packages/@expo/agent-cli/CHANGELOG.md index ea4e7e229..08493c516 100644 --- a/packages/@expo/agent-cli/CHANGELOG.md +++ b/packages/@expo/agent-cli/CHANGELOG.md @@ -48,6 +48,7 @@ - `smoke` and `navigate` no longer target Expo Go for a project that cannot run in it. The deep-link decision now reads the same Expo Go compatibility the plan engine and `status` read, and `smoke` will not report `passed` when the app that answered is an Expo Go the project's native code is not in. ([#2](https://github.com/expo/expo-agent-cli/pull/2) by [@kudo](https://github.com/kudo)) - `smoke` now reloads an app that was already running onto the code on disk before it reads it, so the gate no longer passes an app whose error window and screenshot are from before the edit. A reload it cannot prove is `inconclusive` rather than `passed`, and `--no-reload` opts out. ([#2](https://github.com/expo/expo-agent-cli/pull/2) by [@kudo](https://github.com/kudo)) - `dev --detach` no longer reports a detached dev server for a plan step that opens the app and is then refused by macOS after the lock appeared. The grace window that catches that refusal no longer requires `--wait-ready`, so a `dev --detach --ios` now spends it before reporting. ([#2](https://github.com/expo/expo-agent-cli/pull/2) by [@kudo](https://github.com/kudo)) +- `runtime:tree` and the interaction commands find the focused screen of an expo-router 58 app on the native stack, JS tabs and the drawer, so they read the screen on top, not every mounted screen. The JS stack, JS top tabs and headless `ui` tabs still report no focused screen. ([#199](https://github.com/expo/expo-agent-cli/pull/199) by [@vonovak](https://github.com/vonovak)) ### 💡 Others diff --git a/packages/@expo/agent-cli/llp/0018-interaction-commands.rfc.md b/packages/@expo/agent-cli/llp/0018-interaction-commands.rfc.md index 2707bc860..2f6ab8dd0 100644 --- a/packages/@expo/agent-cli/llp/0018-interaction-commands.rfc.md +++ b/packages/@expo/agent-cli/llp/0018-interaction-commands.rfc.md @@ -92,7 +92,14 @@ this section. 2. Focus is "no unfocused `Screen` ancestor", not "the one focused `Screen`". A nested navigator has two focused `Screen` fibers, one inside the other. A node is off the focused screen when any `Screen` ancestor of it is unfocused. `focusedScreen` reports - the deepest focused `Screen` whose ancestors are all focused. + the deepest focused `Screen` whose ancestors are all focused. A screen fiber has one of + two shapes in `expo-router` 58: expo-router native tabs render a `Screen` with + `isFocused` and `name`; React Navigation renders a `Screen` (JS tabs, drawer) or a + `SceneView` (native stack) with `focused` and `route`. So focus is read on the native + stack, JS tabs, drawer and native tabs (test: "the focus filter in an expo-router 58 + app"). JS stack, JS top tabs and headless `ui` tabs are not covered yet: they report + `focusedScreen: null` and the whole tree (test: "reports no focused screen and the whole + tree under the JS stack"). 3. `runtime:type` takes `--index` and `--force`. It matches the same way `tap` does, so it meets `ambiguous` the same way. `editable={false}` is what a `TextInput` uses for diff --git a/packages/@expo/agent-cli/src/runtime/interact/__tests__/expression-test.ts b/packages/@expo/agent-cli/src/runtime/interact/__tests__/expression-test.ts index a838eeb7a..30554284c 100644 --- a/packages/@expo/agent-cli/src/runtime/interact/__tests__/expression-test.ts +++ b/packages/@expo/agent-cli/src/runtime/interact/__tests__/expression-test.ts @@ -21,8 +21,9 @@ import { evaluateExpression, installHook, installHookWithoutRoots, + nativeTabsScreen, removeHook, - screen, + routerScreen, type FiberSpec, } from './fiberTree'; @@ -550,7 +551,7 @@ describe('the focus filter', () => { name: 'RNSTabsHostIOS', props: {}, children: recorded.map((node) => - screen(String(node.scalarProps.name), node.scalarProps.isFocused === true, [ + nativeTabsScreen(String(node.scalarProps.name), node.scalarProps.isFocused === true, [ { name: 'Pressable', props: { testID: `${node.scalarProps.name}-button`, onPress: () => {} }, @@ -623,16 +624,16 @@ describe('the focus filter', () => { name: 'Root', props: {}, children: [ - screen('tab-a', true, [ - screen('detail', true, [ + nativeTabsScreen('tab-a', true, [ + nativeTabsScreen('detail', true, [ { name: 'Pressable', props: { testID: 'deep', onPress: () => {} } }, ]), - screen('list', false, [ + nativeTabsScreen('list', false, [ { name: 'Pressable', props: { testID: 'behind', onPress: () => {} } }, ]), ]), - screen('tab-b', false, [ - screen('other', true, [ + nativeTabsScreen('tab-b', false, [ + nativeTabsScreen('other', true, [ { name: 'Pressable', props: { testID: 'other-tab', onPress: () => {} } }, ]), ]), @@ -675,8 +676,12 @@ describe('the focus filter', () => { name: 'Root', props: {}, children: [ - screen('one', false, [{ name: 'Pressable', props: { testID: 'a', onPress: () => {} } }]), - screen('two', false, [{ name: 'Pressable', props: { testID: 'b', onPress: () => {} } }]), + nativeTabsScreen('one', false, [ + { name: 'Pressable', props: { testID: 'a', onPress: () => {} } }, + ]), + nativeTabsScreen('two', false, [ + { name: 'Pressable', props: { testID: 'b', onPress: () => {} } }, + ]), ], }; @@ -691,6 +696,73 @@ describe('the focus filter', () => { }); }); +// @ref llp/0018-interaction-commands.rfc.md §Eight shipped decisions 2. React Navigation's screen fibers +// carry `focused` and `route`, and each route also renders a core `SceneView` that has no `focused`. +describe('the focus filter in an expo-router 58 app', () => { + const street = (testID: string) => + ({ name: 'Text', props: { testID, children: testID } }) satisfies FiberSpec; + + /** The core `SceneView` every navigator renders inside each route: `route`, no `focused`. */ + const coreSceneView = (routeName: string, children: FiberSpec[]) => + ({ + name: 'SceneView', + props: { route: { key: `${routeName}-key`, name: routeName }, navigation: {} }, + children, + }) satisfies FiberSpec; + + const route = ( + component: 'Screen' | 'SceneView', + routeName: string, + focused: boolean, + children: FiberSpec[] = [] + ) => routerScreen(component, routeName, focused, [coreSceneView(routeName, children)]); + + /** Root stack: (tabs) under three pushed street screens, the last one on top. */ + function app(): FiberSpec { + return { + name: 'Root', + props: {}, + children: [ + route('SceneView', '(tabs)', false, [ + route('Screen', 'index', true, [ + { name: 'Pressable', props: { testID: 'street-row', onPress: () => {} } }, + ]), + route('Screen', 'map', false), + ]), + route('SceneView', 'street/[name]', false, [street('street-length')]), + route('SceneView', 'street/[name]', false, [street('street-length')]), + route('SceneView', 'street/[name]', true, [street('street-length')]), + ], + }; + } + + const treeOf = (testID: string | null) => + buildTreeExpression({ full: false, allScreens: false, testID, maxNodes: 500 }); + + it(`finds the top stack screen as the focused one, and matches one element of three street screens`, () => { + const answer = against(app(), treeOf(null)); + + expect(answer.focusedScreen).toBe('street/[name]'); + expect(answer.screensSeen).toEqual(['(tabs)', 'index', 'map', 'street/[name]']); + expect(against(app(), treeOf('street-length')).matched).toBe(1); + }); + + it(`reports no focused screen and the whole tree under the JS stack, whose cards are not screen fibers`, () => { + const card = (focused: boolean) => + ({ + name: 'CardContainerInner', + props: { focused, scene: { route: { key: 'street-key', name: 'street/[name]' } } }, + children: [coreSceneView('street/[name]', [street('street-length')])], + }) satisfies FiberSpec; + const jsStack = { name: 'Root', props: {}, children: [card(false), card(false), card(true)] }; + + const answer = against(jsStack, treeOf(null)); + + expect(answer.focusedScreen).toBeNull(); + expect(against(jsStack, treeOf('street-length')).matched).toBe(3); + }); +}); + // @ref llp/0018-interaction-commands.rfc.md §Must not lose 6. The default is bounded by what it // keeps, not by a fiber depth: fiber depth on the spike's app was 152, and every visible element // sat between 128 and 152. @@ -1323,7 +1395,7 @@ describe('the type call', () => { // which would have failed for a different reason. it(`answers "no candidate takes text" before it answers "which one"`, () => { const shared = (screenName: string): FiberSpec => - screen(screenName, true, [ + nativeTabsScreen(screenName, true, [ { name: 'Pressable', props: { testID: 'shared-id', onPress: () => {} } }, ]); const answer = against( @@ -1344,7 +1416,7 @@ describe('the type call', () => { it(`still asks which one when the candidates do take text`, () => { const input = (screenName: string): FiberSpec => - screen(screenName, true, [ + nativeTabsScreen(screenName, true, [ { name: 'TextInput', props: { testID: 'shared-input', onChangeText: () => {} } }, ]); const answer = against( diff --git a/packages/@expo/agent-cli/src/runtime/interact/__tests__/fiberTree.ts b/packages/@expo/agent-cli/src/runtime/interact/__tests__/fiberTree.ts index 97870a45c..64b37d095 100644 --- a/packages/@expo/agent-cli/src/runtime/interact/__tests__/fiberTree.ts +++ b/packages/@expo/agent-cli/src/runtime/interact/__tests__/fiberTree.ts @@ -76,8 +76,12 @@ export function buildFiber(spec: FiberSpec, parent: FakeFiber | null = null): Fa return fiber; } -/** A React Navigation `Screen` fiber, which is where "which screen is on screen" is answerable. */ -export function screen(name: string, focused: boolean, children: FiberSpec[] = []): FiberSpec { +/** An expo-router native tabs `Screen` fiber: `isFocused` and `name`, one per tab. */ +export function nativeTabsScreen( + name: string, + focused: boolean, + children: FiberSpec[] = [] +): FiberSpec { return { name: 'Screen', props: { name, isFocused: focused, routeKey: `${name}-key` }, @@ -85,6 +89,23 @@ export function screen(name: string, focused: boolean, children: FiberSpec[] = [ }; } +/** + * A React Navigation screen fiber: `Screen` (JS tabs, drawer) or `SceneView` (native stack), with + * `focused` and `route`, not `isFocused`. + */ +export function routerScreen( + component: 'Screen' | 'SceneView', + routeName: string, + focused: boolean, + children: FiberSpec[] = [] +): FiberSpec { + return { + name: component, + props: { focused, route: { key: `${routeName}-key`, name: routeName } }, + children, + }; +} + /** * Install a fake DevTools hook over one or more root trees, and take it away again. * diff --git a/packages/@expo/agent-cli/src/runtime/interact/expression.ts b/packages/@expo/agent-cli/src/runtime/interact/expression.ts index cfc5fee05..cde2e836c 100644 --- a/packages/@expo/agent-cli/src/runtime/interact/expression.ts +++ b/packages/@expo/agent-cli/src/runtime/interact/expression.ts @@ -113,15 +113,23 @@ const PRELUDE = ` return found; } /** - * React Navigation's own \`Screen\`, which is where "which screen is the user looking at" is - * answerable. React itself does not know: with native tabs the switching happens natively and - * every \`Offscreen\` fiber still reports \`mode: "visible"\` (llp/0018-interaction-commands.rfc.md §Must not lose). + * The navigator's screen fiber, the only place that knows which screen the user is looking at. + * @ref llp/0018-interaction-commands.rfc.md §Eight shipped decisions 2 */ function screenOf(f) { - if (nameOf(f) !== 'Screen') return null; + var component = nameOf(f); + if (component !== 'Screen' && component !== 'SceneView') return null; var p = propsOf(f); - if (!p || typeof p.isFocused !== 'boolean' || typeof p.name !== 'string') return null; - return { name: p.name, focused: p.isFocused }; + if (!p) return null; + // expo-router native tabs: Screen with isFocused and name. + if (typeof p.isFocused === 'boolean' && typeof p.name === 'string') { + return { name: p.name, focused: p.isFocused }; + } + // React Navigation Screen (JS tabs, drawer) / SceneView (native stack): focused and route. + if (typeof p.focused === 'boolean' && p.route && typeof p.route.name === 'string') { + return { name: p.route.name, focused: p.focused }; + } + return null; } var roots = [];