Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/mobile
Steps to reproduce
Use a checkout of the commit under Version or commit with its locked dependencies installed. Save the script below outside the checkout as repro.sh and run env -u NODE_ENV sh /path/to/repro.sh "$PWD" from the checkout root. It creates one temporary fixture directory under apps/mobile with a private HOME, TMPDIR and T3CODE_HOME, writes a Vite config that extends the repository's react test project and a single test, runs that test, and removes the fixture on exit. No tracked file changes, and no app, emulator or device is needed.
The test renders the exported useSoftKeyboardShowing hook and the actual exported AndroidWorkspaceNavigation component with floating and the default rail=false. It keeps the installed react-native-keyboard-controller production JS module and its KeyboardController.isVisible() cache, and replaces only the controller's native event transport, the native navigation view, and the surrounding UI and context hooks. Then:
- It emits a control sequence:
keyboardWillShowat height 376, thenkeyboardWillHideandkeyboardDidHideat 0. - It emits the failure input:
keyboardDidShowat 376 with no preceding willShow, which is what the controller's nonanimated Android repair path sends. - It rerenders the parent, repeats a didHide/didShow pair, and finally remounts, sampling the controller cache and the mounted navigation after each step.
The assertions pin the current defect and the working controls, so the run passes while the defect is present.
Reproduction script: repro.sh
#!/bin/sh
# Run from a dependency-installed t3code checkout, or pass its root as argument 1.
# All generated fixtures/state are private and removed on exit.
set -eu
repo=$(cd "${1:-.}" && pwd -P)
mobile="$repo/apps/mobile"
fixture=$(mktemp -d "$mobile/.verify-keyboard.XXXXXX")
trap 'rm -rf "$fixture"' EXIT HUP INT TERM
mkdir -p "$fixture/fixture-home" "$fixture/tmp" "$fixture/t3"
export HOME="$fixture/fixture-home" TMPDIR="$fixture/tmp" T3CODE_HOME="$fixture/t3"
export T3_MOBILE_TEST_PLATFORM=android
cat > "$fixture/vite.config.ts" <<'TS'
import original from "../vite.config";
const reactProject = original.test!.projects!.find((p: any) => p.test?.name === "react") as any;
export default {
...original,
test: {
...original.test,
projects: [{ ...reactProject, test: { ...reactProject.test, include: [".verify-keyboard.*/repro.test.tsx"] } }],
},
};
TS
cat > "$fixture/repro.test.tsx" <<'TSX'
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vite-plus/test";
const bus = vi.hoisted(() => new Map<string, Set<(event: any) => void>>());
// Replace only the native event transport; keep the installed production JS cache.
vi.mock("react-native-keyboard-controller/src/bindings", () => ({
KeyboardControllerNative: { dismiss() {} },
KeyboardEvents: {
addListener(name: string, listener: (event: any) => void) {
const listeners = bus.get(name) ?? new Set();
bus.set(name, listeners);
listeners.add(listener);
return { remove: () => listeners.delete(listener) };
},
},
}));
vi.mock("react-native-keyboard-controller", async () => ({
...(await import("react-native-keyboard-controller/src/module")),
...(await import("react-native-keyboard-controller/src/bindings")),
}));
// Render the real production consumer too; replace its native view and surrounding contexts.
vi.mock("expo", () => ({ requireNativeView: () => () => <nav data-native="workspace-tabs" /> }));
vi.mock("react-native", () => ({
Keyboard: { dismiss() {} }, processColor: () => 0,
useWindowDimensions: () => ({ width: 400, height: 800 }),
View: ({ children }: any) => <div>{children}</div>,
}));
vi.mock("@react-navigation/native", () => ({ CommonActions: {}, useNavigation: () => ({}) }));
vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }) }));
vi.mock("../src/components/ui/useAndroidControlSizing", () => ({ useAndroidControlSizing: () => ({ scale: 1, fabSize: 56 }) }));
vi.mock("../src/components/ui/useToolbarLayout", () => ({ useToolbarLayout: () => ({ paddingTop: 0, height: 56 }) }));
vi.mock("../src/lib/rootNavigation", () => ({ rootNavigationRef: {} }));
vi.mock("../src/state/workspace", () => ({ useWorkspaceConnectionState: () => ({ hasConnections: true }) }));
vi.mock("../src/features/settings/appearance/AppearancePreferencesProvider", () => ({ useAppearancePreferences: () => ({ themeAppearance: "light", themeVariables: new Proxy({}, { get: () => "#ffffff" }) }) }));
vi.mock("../src/features/layout/adaptive-workspace-context", () => ({ useAdaptiveWorkspaceLayout: () => ({ layout: { usesSplitView: false } }) }));
vi.mock("../src/features/layout/workspace/AndroidWorkspaceTabsProvider", () => ({ useAndroidWorkspaceRoot: () => null }));
vi.mock("../src/features/layout/workspace/navigation-tabs", () => ({ useNavigationTabOrder: () => ["threads"], useHiddenNavigationTabs: () => [] }));
vi.mock("../src/features/layout/workspace/WorkspaceTabsContext", () => ({ useWorkspaceTabs: () => ({ state: { selectedTab: "threads" } }) }));
import { AndroidWorkspaceNavigation } from "../src/features/layout/workspace/AndroidWorkspaceNavigation.android";
import { KeyboardController } from "react-native-keyboard-controller";
import { useSoftKeyboardShowing } from "../src/features/keyboard/useSoftKeyboardShowing";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
function Probe({ tick = 0 }: { tick?: number }) {
const showing = useSoftKeyboardShowing();
// AndroidWorkspaceNavigation.android.tsx:105 consumes this exact boolean (rail=false).
return <><output data-tick={tick}>{showing ? "hidden" : "rendered"}</output><AndroidWorkspaceNavigation floating onNewThread={() => {}} /></>;
}
function emit(name: string, height: number) {
const event = { height, duration: 0, timestamp: 1, target: 42, type: "default", appearance: "light" };
act(() => { for (const listener of bus.get(name) ?? []) listener(event); });
}
it("contrasts iOS/animated will-events with Android's real did-only repair", () => {
const node = document.createElement("div");
const root = createRoot(node);
act(() => root.render(<Probe />));
const sample = (label: string) => {
const snapshot = { label, cacheVisible: KeyboardController.isVisible(), bar: node.textContent, nativeNavigationMounted: !!node.querySelector('[data-native="workspace-tabs"]') };
process.stdout.write(JSON.stringify(snapshot) + "\n");
return snapshot;
};
expect(sample("initial")).toMatchObject({ cacheVisible: false, bar: "rendered", nativeNavigationMounted: true });
emit("keyboardWillShow", 376);
expect(sample("CONTROL iOS/animated willShow")).toMatchObject({ cacheVisible: true, bar: "hidden", nativeNavigationMounted: false });
emit("keyboardWillHide", 0);
emit("keyboardDidHide", 0);
expect(sample("CONTROL willHide + didHide")).toMatchObject({ cacheVisible: false, bar: "rendered", nativeNavigationMounted: true });
emit("keyboardDidShow", 376);
expect(sample("DEFECT Android didShow-only")).toMatchObject({ cacheVisible: true, bar: "rendered", nativeNavigationMounted: true });
// A parent rerender is plausible on a window change; it must not manufacture the defect.
act(() => root.render(<Probe tick={1} />));
expect(sample("DEFECT after parent rerender")).toMatchObject({ cacheVisible: true, bar: "rendered", nativeNavigationMounted: true });
// The library really has emitted the matching didHide/didShow pair now, not a frozen cache.
emit("keyboardDidHide", 0);
emit("keyboardDidShow", 376);
expect(sample("DEFECT repeated didHide/didShow repair")).toMatchObject({ cacheVisible: true, bar: "rendered", nativeNavigationMounted: true });
// Remounting re-reads the corrected cache: this is a measured control, not a workaround claim.
act(() => root.unmount());
const root2 = createRoot(node);
act(() => root2.render(<Probe />));
expect(sample("CONTROL remount reads real cache")).toMatchObject({ cacheVisible: true, bar: "hidden", nativeNavigationMounted: false });
act(() => root2.unmount());
emit("keyboardDidHide", 0);
process.stdout.write("RESULT: defect present; controls correct; Android did-only repair leaves navigation decision rendered.\n");
});
TSX
cd "$mobile"
env -u NODE_ENV "$repo/node_modules/.bin/vp" test run --config "$fixture/vite.config.ts" --project react "$fixture/repro.test.tsx"Expected behavior
When the keyboard becomes visible, non-rail workspace navigation stops rendering. AndroidWorkspaceNavigation reads useSoftKeyboardShowing() and returns null when !rail && keyboardVisible. After the controller's didShow-only repair, its cache reports the keyboard as visible, so the mounted hook should report true and the navigation should take that hide branch, as it does after a normal willShow.
The repository already treats this repair sequence as a real show: #25847 ("keyboard state follows Android's nonanimated IME show") made the library cache, useKeyboardState and the terminal accessory store follow it, and terminalKeyboardShowing.ts documents the sequence and subscribes to keyboardDidShow for it. The rail variant intentionally stays visible and is outside this report.
Actual behavior
Summary. On Android, useSoftKeyboardShowing stays false after react-native-keyboard-controller repairs a desynchronized keyboard state with its nonanimated syncKeyboardPosition path, which emits keyboardDidShow without a preceding keyboardWillShow. The controller's own cache reports the keyboard as visible, but non-rail AndroidWorkspaceNavigation reads the stale hook and keeps its native navigation view mounted instead of taking its keyboard-visible hide branch. The normal willShow path hides the navigation correctly, and a remount re-reads the cache and corrects it.
In the focused run, the controller cache changes to visible after the didShow-only input, but the hook stays false and the real navigation component keeps its native view mounted. An ordinary parent rerender keeps the mismatch, and a repeated didHide/didShow pair does too. The willShow control hides the navigation, and a remount re-reads the cache and hides it.
Code locators, as breadcrumbs rather than a prescribed fix: useSoftKeyboardShowing.ts reads KeyboardController.isVisible() at import and on subscribe, then listens only to keyboardWillShow and keyboardWillHide; the subscribe function is stable, so a rerender neither resubscribes nor re-reads the cache. #25847 did not change this file.
This is the controller's nonanimated repair path, not every Android keyboard show. In the installed react-native-keyboard-controller 1.23.0 (the repository's patch does not touch its Android sources), KeyboardAnimationCallback.onStart emits keyboardWillShow and keyboardWillHide normally (android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt lines 230-233). onApplyWindowInsets in the same file (lines 176-185) calls syncKeyboardPosition when it detects a changed inset while the keyboard is not moving, and syncKeyboardPosition (lines 377-413) emits only keyboardDidShow or keyboardDidHide.
Visible effect, not established: the floating wrapper anchors to the pane bottom without a keyboard-height offset, and the root KeyboardProvider runs edge to edge, so a docked keyboard can cover the still-mounted bar. No Android device or emulator capture was made during this repair sequence, so whether any part of the navigation is exposed above the keyboard or obstructs content is unknown. The supported defect is the stale hook value and the native navigation view that stays mounted.
Evidence
- Expected source: the hide branch in
apps/mobile/src/features/layout/workspace/AndroidWorkspaceNavigation.android.tsxlines 83 and 105, and the repository's handling of the didShow-only repair in fix(mobile): keyboard state follows Android's nonanimated IME show #25847 andapps/mobile/src/features/terminal/terminalKeyboardShowing.tslines 3-6 and 21-22. - Failure source: the reproduction output under Logs or stack traces, produced by the exported
useSoftKeyboardShowingandAndroidWorkspaceNavigationwith the installed controller's production JS cache; the will-only subscription is atapps/mobile/src/features/keyboard/useSoftKeyboardShowing.tslines 6-20. - Evidence provenance: observed
- Local verification: reproduced
- Reproduction completeness: complete
The script above passed on a clean checkout of the tested commit and left the checkout clean. An earlier version of the same script, differing only in its fixture directory name and path resolution, produced identical result lines on upstream main at 86ff9d0, where the hook and navigation files are unchanged. The run is a focused production-entry-point reproduction under jsdom with the native event transport mocked; it is not an Android device capture.
Related
- fix(mobile): keyboard state follows Android's nonanimated IME show #25847 (merged): made the library cache,
useKeyboardStateand the terminal accessory store follow the didShow-only repair; it leftuseSoftKeyboardShowingunchanged. - [Bug]: Android keyboard state remains hidden after nonanimated IME recovery #22484 (closed): the earlier library-cache defect for the same repair path, fixed by fix(mobile): keyboard state follows Android's nonanimated IME show #25847.
- fix(mobile): Android composer and back follow the real keyboard after resume #21595 and fix(mobile): restore keyboard state after Android inset recovery #22514 (closed, unmerged): earlier library recovery work on this path.
- [Bug]: Thread switches and composer menus open the on-screen keyboard on Android tablets #26447 (open): a different problem, the web app focusing the composer and opening the keyboard on Android Chrome.
- Open PRs fix(mobile): extend Duo terminal keyboard toolbar across the rail #24628, fix(mobile): skill suggestions stay readable on the Duo's front screen #26829 and fix(mobile): Android terminal moves up with the keyboard instead of snapping #35465 change terminal width, composer suggestions and terminal keyboard animation; none changes this hook or the workspace navigation.
Restoration check
With the script's input, the samples after the didShow-only event, after the parent rerender and after the repeated didHide/didShow pair each show bar: "hidden" and nativeNavigationMounted: false while cacheVisible is true; the script's DEFECT assertions then fail, because they encode the current behavior. The controls must still hold: willShow hides the navigation, willHide plus didHide renders it again, and the rail variant is unaffected.
Impact
Cosmetic issue
Version or commit
d9805b2, rerun from a clean checkout; also reproduced on upstream main as noted under Evidence.
Environment
macOS arm64, Node v26.12.1, the repository's Vite+ test runner (v5.0.5), React and React DOM 19.3.0, React Native 0.88.1, patched react-native-keyboard-controller 1.23.0; jsdom with the Android test platform selected and NODE_ENV unset. No app, emulator or device was run.
Logs or stack traces
Result lines from the script's output at the tested commit:
{"label":"initial","cacheVisible":false,"bar":"rendered","nativeNavigationMounted":true}
{"label":"CONTROL iOS/animated willShow","cacheVisible":true,"bar":"hidden","nativeNavigationMounted":false}
{"label":"CONTROL willHide + didHide","cacheVisible":false,"bar":"rendered","nativeNavigationMounted":true}
{"label":"DEFECT Android didShow-only","cacheVisible":true,"bar":"rendered","nativeNavigationMounted":true}
{"label":"DEFECT after parent rerender","cacheVisible":true,"bar":"rendered","nativeNavigationMounted":true}
{"label":"DEFECT repeated didHide/didShow repair","cacheVisible":true,"bar":"rendered","nativeNavigationMounted":true}
{"label":"CONTROL remount reads real cache","cacheVisible":true,"bar":"hidden","nativeNavigationMounted":false}
RESULT: defect present; controls correct; Android did-only repair leaves navigation decision rendered.
Test Files 1 passed (1)
Tests 1 passed (1)
Screenshots, recordings, or supporting files
No response
Workaround
No practical user-facing workaround was verified. In the focused run, a later willShow or a remount of the navigation corrects the value, but no user action that reliably triggers either on a device was established.
Activity
juliusmarminge commentedon Nov 12, 2026
I tried to reproduce this on the current build and could not.
Commit fc2b5de (origin/main). I extended the existing test 'reports the keyboard shown after Android repairs the IME inset without keyboardWillShow' in apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx. It renders useSoftKeyboardShowing next to useKeyboardState, runs the installed react-native-keyboard-controller JS code with only the native event transport replaced, and sends keyboardDidShow at height 376 with no keyboardWillShow before it (Android's nonanimated syncKeyboardPosition repair). Ran
vp test run src/features/keyboard/keyboardControllerState.test.tsxfrom apps/mobile. Result: KeyboardController.isVisible() is true and useKeyboardState.isVisible is true, but useSoftKeyboardShowing is false, so the test fails. With a temporary fix (also listening for keyboardDidShow) both tests pass; I reverted the fix and pushed only the test. This shows the defect in the real library code path, not in a running app: I did not get a device to take the nonanimated IME path, so I did not see the navigation stay mounted on screen myself. AndroidWorkspaceNavigation.android.tsx:105 returns null only when this hook is true.Closing as not planned. If it still happens for you, reply with the exact steps and the version you are on and it will be looked at again.