Skip to content

KeyboardChatScrollView crashes on screen unmount: useScrollState cleanup calls findNodeHandle on a detached fiber ("Unable to find node on an unmounted component") #1577

Description

@kaarl77

Description

Unmounting a screen that hosts KeyboardChatScrollView (e.g. popping it from a @react-navigation native stack) crashes the app with:

Error: Unable to find node on an unmounted component.

The throw comes from useScrollState's effect cleanup: useEventHandlerRegistration's returned cleanup re-resolves the view tag at teardown time (src/internal.ts):

return () => {
  const viewTag = findNodeHandle(viewTagRef.current);
  ...
};

Two things combine here:

  1. React runs passive-effect cleanups for a deleted subtree after the fibers are detached.
  2. The ref involved is Reanimated's useAnimatedRef (created internally by KeyboardChatScrollView — consumers can't influence it), and Reanimated's animated refs ignore the ref(null) call on detach, so viewTagRef.current still points at the unmounted instance.

findNodeHandle on that detached fiber then hits React's assertIsMounted invariant (in production this is minified invariant #188, so it's not a dev-only crash).

The code is identical in 1.22.2 (src, lib/module, lib/commonjs all re-resolve at cleanup), so upgrading doesn't help.

Steps to reproduce

  1. Render a KeyboardChatScrollView (any content — we inject a Reanimated.FlatList via ScrollViewComponent, but the internal registration path is the same for the default ScrollView) inside a screen of a native stack navigator.
  2. Push the screen, then pop it (header back/close).
  3. Crash on unmount with the stack below.

Chat demos that keep the chat mounted at the tab root never pop the screen, which is probably why this hasn't surfaced in the example apps.

Stack trace (RN 0.85, Fabric, dev)

assertIsMounted (ReactNativeRenderer-dev.js:1321)
findCurrentFiberUsingSlowPath
findCurrentHostFiber
findHostInstanceWithWarning
findNodeHandle
<anonymous> (react-native-keyboard-controller/src/internal.ts:66)
<anonymous> (react-native-keyboard-controller/src/components/hooks/useScrollState.ts:53)
commitHookEffectListUnmount
commitPassiveUnmountEffectsInsideOfDeletedTree_begin
...

Environment

package version
react-native-keyboard-controller 1.21.6 (bug present in 1.22.2 sources too)
react-native 0.85.3 (new architecture)
react-native-reanimated 4.3.1
expo 56
platform iOS

Suggested fix

Capture the view tag once at registration and use the captured tag in the cleanup, instead of re-resolving through findNodeHandle at teardown — unregistering with the tag that was registered is also more precise if the ref were ever re-pointed.

Workaround we ship today (patch-package)

Guarding the library's own findNodeHandle wrapper so the invariant resolves to null (its documented "cannot resolve" result); the skipped unregister is moot since the native view is destroyed with the tree:

diff --git a/node_modules/react-native-keyboard-controller/lib/commonjs/utils/findNodeHandle/index.native.js b/node_modules/react-native-keyboard-controller/lib/commonjs/utils/findNodeHandle/index.native.js
index a252c3f..1c414c7 100644
--- a/node_modules/react-native-keyboard-controller/lib/commonjs/utils/findNodeHandle/index.native.js
+++ b/node_modules/react-native-keyboard-controller/lib/commonjs/utils/findNodeHandle/index.native.js
@@ -3,11 +3,17 @@
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
-Object.defineProperty(exports, "findNodeHandle", {
-  enumerable: true,
-  get: function () {
-    return _reactNative.findNodeHandle;
-  }
-});
+exports.findNodeHandle = void 0;
 var _reactNative = require("react-native");
+
+// PATCHED (2026-07-29): see src/utils/findNodeHandle/index.native.ts — guards the
+// "Unable to find node on an unmounted component" throw during unmount-time cleanup.
+const findNodeHandle = componentOrHandle => {
+  try {
+    return _reactNative.findNodeHandle(componentOrHandle);
+  } catch {
+    return null;
+  }
+};
+exports.findNodeHandle = findNodeHandle;
 //# sourceMappingURL=index.native.js.map
diff --git a/node_modules/react-native-keyboard-controller/lib/module/utils/findNodeHandle/index.native.js b/node_modules/react-native-keyboard-controller/lib/module/utils/findNodeHandle/index.native.js
index 8e64bcf..7555496 100644
--- a/node_modules/react-native-keyboard-controller/lib/module/utils/findNodeHandle/index.native.js
+++ b/node_modules/react-native-keyboard-controller/lib/module/utils/findNodeHandle/index.native.js
@@ -1,3 +1,13 @@
-import { findNodeHandle } from "react-native";
+import { findNodeHandle as findNodeHandleRN } from "react-native";
+
+// PATCHED (2026-07-29): see src/utils/findNodeHandle/index.native.ts — guards the
+// "Unable to find node on an unmounted component" throw during unmount-time cleanup.
+const findNodeHandle = componentOrHandle => {
+  try {
+    return findNodeHandleRN(componentOrHandle);
+  } catch {
+    return null;
+  }
+};
 export { findNodeHandle };
 //# sourceMappingURL=index.native.js.map
diff --git a/node_modules/react-native-keyboard-controller/src/utils/findNodeHandle/index.native.ts b/node_modules/react-native-keyboard-controller/src/utils/findNodeHandle/index.native.ts
index 55ac2fa..296a5c3 100644
--- a/node_modules/react-native-keyboard-controller/src/utils/findNodeHandle/index.native.ts
+++ b/node_modules/react-native-keyboard-controller/src/utils/findNodeHandle/index.native.ts
@@ -1,3 +1,18 @@
-import { findNodeHandle } from "react-native";
+import { findNodeHandle as findNodeHandleRN } from "react-native";
+
+// PATCHED (2026-07-29): RN's findNodeHandle THROWS "Unable to find node on an
+// unmounted component" when the fiber sits inside a deleted tree — exactly what
+// `useScrollState`'s effect cleanup does on screen unmount (`internal.ts` resolves
+// `viewTagRef.current` at teardown, and reanimated animated refs are never nulled on detach),
+// crashing the app whenever a screen hosting `KeyboardChatScrollView` is popped. Resolving to
+// `null` is findNodeHandle's documented "cannot resolve" contract; the unregister that follows a
+// null tag is skipped, which is correct — the native view is being destroyed anyway.
+const findNodeHandle: typeof findNodeHandleRN = (componentOrHandle) => {
+  try {
+    return findNodeHandleRN(componentOrHandle);
+  } catch {
+    return null;
+  }
+};
 
 export { findNodeHandle };

Metadata

Metadata

Assignees

Labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions