From 86b780bc924f5f6db2acbffa6580dfd6ded875ce Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 01:44:36 +0000 Subject: [PATCH] fix(mobile): keep split-view inspectors registered across renders Co-authored-by: oliver <97427849+flamboh@users.noreply.github.com> --- .../features/files/ThreadFilesRouteScreen.tsx | 8 +- .../layout/AdaptiveWorkspaceLayout.tsx | 32 +++-- .../thread-inspector-navigation.test.tsx | 119 ++++++++++++++++++ 3 files changed, 142 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 3f2255a56b13..c0a766f480b0 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -361,6 +361,7 @@ function useThreadFilesWorkspace(params: { readonly threadId?: string | string[]; readonly cwd?: string | string[]; readonly projectName?: string | string[]; + readonly path?: string | string[]; }) { const routeEnvironmentId = firstRouteParam(params.environmentId); const routeThreadId = firstRouteParam(params.threadId); @@ -382,6 +383,7 @@ function useThreadFilesWorkspace(params: { cwd: routeCwd ?? selectedThreadCwd ?? project?.workspaceRoot ?? null, environmentId, projectName: routeProjectName ?? project?.title ?? "Files", + relativePath: normalizeRoutePath(params.path), selectedThread, threadId, }; @@ -594,11 +596,9 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const { appearance, setCodeWordBreak } = useAppearancePreferences(); const iconColor = useUniwindTheme()["--color-icon"]; const params = props.route.params; - const relativePath = normalizeRoutePath(params.path); const targetLine = normalizeRouteLine(firstRouteParam(params.line)); - const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( - props.route.params, - ); + const { cwd, environmentId, projectName, relativePath, selectedThread, threadId } = + useThreadFilesWorkspace(props.route.params); const fileAccessSession = useEnvironmentQuery( environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null, ); diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 7dd22f8fa3ee..bcb55e12724a 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -135,28 +135,34 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi deactivateRef.current = registerWorkspaceInspector(wrappedRender); }); - useFocusEffect(() => { - focusedRef.current = true; - syncRegistration(); - return () => { + useEffect(() => { + if (navigation === undefined) return; + const focus = () => { + if (focusedRef.current) return; + focusedRef.current = true; + syncRegistration(); + }; + const blur = () => { focusedRef.current = false; syncRegistration(); }; - }); + if (navigation.isFocused()) focus(); + const unsubscribeFocus = navigation.addListener("focus", focus); + const unsubscribeBlur = navigation.addListener("blur", blur); + return () => { + unsubscribeFocus(); + unsubscribeBlur(); + focusedRef.current = false; + deactivateRef.current?.(); + deactivateRef.current = null; + }; + }, [navigation]); useEffect(() => { if (focusedRef.current) { syncRegistration(); } }, [wrappedRender]); - - useEffect( - () => () => { - deactivateRef.current?.(); - deactivateRef.current = null; - }, - [], - ); } export function AdaptiveWorkspaceLayout(props: { diff --git a/apps/mobile/src/features/threads/thread-inspector-navigation.test.tsx b/apps/mobile/src/features/threads/thread-inspector-navigation.test.tsx index a8cf66463de5..cd148bff2d96 100644 --- a/apps/mobile/src/features/threads/thread-inspector-navigation.test.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-navigation.test.tsx @@ -1,4 +1,6 @@ +import * as NodePath from "node:path"; import * as NodeModule from "node:module"; +import * as React from "react"; import { act, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; @@ -167,3 +169,120 @@ it("keeps the parent when the maximized child's Agents sheet opens another child expect(screen.open({ type: "thread", ...child, threadId: "grandchild" }, child)).toBe(false); expect(screen.state).toEqual(originalState); }); + +it("keeps a focused inspector registered across renders and updates its content", () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const requireMobile = NodeModule.createRequire(import.meta.url); + const listeners = { focus: new Set<() => void>(), blur: new Set<() => void>() }; + let focused = true; + let activeRender: (() => React.ReactNode) | undefined; + const deactivate = vi.fn(() => { + activeRender = undefined; + }); + const registerWorkspaceInspector = vi.fn((render: () => React.ReactNode) => { + activeRender = render; + return deactivate; + }); + const navigation = { + isFocused: () => focused, + addListener(event: "focus" | "blur", listener: () => void) { + listeners[event].add(listener); + return () => { + listeners[event].delete(listener); + }; + }, + }; + const navigationContext = React.createContext(navigation); + const routeContext = React.createContext({ key: "file" }); + const babel = requireMobile("@babel/core") as { + transformFileSync(file: string, options: Record): { code: string }; + }; + const compiled = babel.transformFileSync( + NodePath.resolve(__dirname, "../layout/AdaptiveWorkspaceLayout.tsx"), + { + babelrc: false, + configFile: false, + presets: [ + [ + requireMobile.resolve("babel-preset-expo"), + { unstable_transformImportMeta: true, enableBabelRuntime: false }, + ], + ], + caller: { + name: "metro", + bundler: "metro", + platform: "ios", + isDev: false, + isServer: false, + isNodeModule: false, + supportsStaticESM: false, + supportsReactCompiler: true, + }, + }, + ); + const exports: { + useRegisterWorkspaceInspector?: (render: (() => React.ReactNode) | undefined) => void; + } = {}; + const load = (name: string) => { + if (name === "react" || name.startsWith("react/")) return requireMobile(name); + if (name === "./adaptive-workspace-context") + return { + useAdaptiveWorkspaceLayout: () => ({ registerWorkspaceInspector }), + }; + if (name === "@react-navigation/native") + return { + NavigationContext: navigationContext, + NavigationRouteContext: routeContext, + useFocusEffect: (callback: () => (() => void) | undefined) => + React.useEffect(callback, [callback]), + }; + return {}; + }; + new Function("require", "exports", compiled.code)(load, exports); + const useRegisterWorkspaceInspector = exports.useRegisterWorkspaceInspector!; + function Screen({ render }: { render: (() => React.ReactNode) | undefined }) { + useRegisterWorkspaceInspector(render); + return null; + } + const root = createRoot(document.createElement("div")); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + const render = (content: (() => React.ReactNode) | undefined) => + act(() => root.render()); + const first = () => "first"; + render(first); + registerWorkspaceInspector.mockClear(); + deactivate.mockClear(); + render(first); + render(first); + expect(registerWorkspaceInspector).not.toHaveBeenCalled(); + expect(deactivate).not.toHaveBeenCalled(); + const second = () => "second"; + render(second); + expect(registerWorkspaceInspector).toHaveBeenCalledTimes(1); + const paneContainer = document.createElement("div"); + const paneRoot = createRoot(paneContainer); + using _pane = { [Symbol.dispose]: () => act(() => paneRoot.unmount()) }; + act(() => paneRoot.render(activeRender?.())); + expect(paneContainer.textContent).toBe("second"); + const emit = (event: "focus" | "blur") => + act(() => { + focused = event === "focus"; + for (const listener of listeners[event]) listener(); + }); + emit("blur"); + expect(deactivate).toHaveBeenCalledTimes(1); + registerWorkspaceInspector.mockClear(); + render(first); + expect(registerWorkspaceInspector).not.toHaveBeenCalled(); + emit("focus"); + expect(registerWorkspaceInspector).toHaveBeenCalledTimes(1); + emit("focus"); + expect(registerWorkspaceInspector).toHaveBeenCalledTimes(1); + render(undefined); + expect(activeRender).toBeUndefined(); + render(second); + expect(activeRender).toBeDefined(); + act(() => root.unmount()); + expect(activeRender).toBeUndefined(); + expect(listeners.focus.size + listeners.blur.size).toBe(0); +});