Skip to content

fix(android): prevent useAnimatedKeyboard crash "Can't change insets on an animation that is cancelled"#9670

Open
piaskowyk wants to merge 1 commit into
mainfrom
piaskowyk/fix-keyboard-state-update-race
Open

fix(android): prevent useAnimatedKeyboard crash "Can't change insets on an animation that is cancelled"#9670
piaskowyk wants to merge 1 commit into
mainfrom
piaskowyk/fix-keyboard-state-update-race

Conversation

@piaskowyk

@piaskowyk piaskowyk commented Jun 16, 2026

Copy link
Copy Markdown
Member

Summary

Fix for a keyboard animation race update crash:

Crash logs

Exception java.lang.IllegalStateException: Can't change insets on an animation that is cancelled.
  at android.view.InsetsAnimationControlImpl.setInsetsAndAlpha (InsetsAnimationControlImpl.java:296)
  at android.view.InsetsAnimationControlImpl.setInsetsAndAlpha (InsetsAnimationControlImpl.java:286)
  at android.view.InsetsController$InternalAnimationControlListener.lambda$onReady$0 (InsetsController.java:523)
  at android.view.InsetsController$InternalAnimationControlListener.$r8$lambda$BJTEi7zfHhP2W08t256nzVrDzao (Unknown Source)
  at android.view.InsetsController$InternalAnimationControlListener$$ExternalSyntheticLambda0.onAnimationUpdate (D8$$SyntheticClass)
  at android.animation.Animator$AnimatorCaller.lambda$static$4 (Animator.java:931)
  at android.animation.Animator$AnimatorCaller$$ExternalSyntheticLambda6.call (D8$$SyntheticClass)
  at android.animation.Animator.callOnList (Animator.java:742)
  at android.animation.ValueAnimator.animateValue (ValueAnimator.java:1666)
  at android.animation.ValueAnimator.setCurrentFraction (ValueAnimator.java:771)
  at android.animation.ValueAnimator.setCurrentPlayTime (ValueAnimator.java:734)
  at android.animation.ValueAnimator.start (ValueAnimator.java:1154)
  at android.animation.ValueAnimator.start (ValueAnimator.java:1173)
  at android.view.InsetsController$InternalAnimationControlListener.onReady (InsetsController.java:553)
  at android.view.InsetsController.lambda$startAnimation$8 (InsetsController.java:2477)
  at android.view.InsetsController.$r8$lambda$zHkX5nyqL2mGn5Rg6byt6DH0yFE (Unknown Source)
  at android.view.InsetsController$$ExternalSyntheticLambda11.run (D8$$SyntheticClass)
  at android.view.ViewRootInsetsControllerHost$1.onPreDraw (ViewRootInsetsControllerHost.java:69)
  at android.view.ViewTreeObserver.dispatchOnPreDraw (ViewTreeObserver.java:1229)

useAnimatedKeyboard() enables edge-to-edge mode (setDecorFitsSystemWindows(window, false)) and registers a WindowInsetsAnimationCompat.Callback. As a result, the system — not Reanimated — animates the keyboard insets through Android's InsetsController.

The bug

When the keyboard is shown, InsetsController creates an inset-animation runner and defers its start to the next pre-draw. At that point Android, in order:

  1. calls our KeyboardAnimationCallback.onStart (via dispatchWindowInsetsAnimationStart), and then
  2. starts the animation runner via its scheduled onReadysetInsetsAndAlpha.

The problem is step 1. Our onStart synchronously calls worklet, which re-enters Reanimated on the same thread. That reentrancy can cancel the still-pending runner inside this fragile window (e.g. a competing Keyboard.dismiss()/blur gets flushed). When onReady then fires in step 2, it calls setInsetsAndAlpha on a runner that is already cancelled and throws.

mHost.addOnPreDrawRunnable(() -> {
    if (runner.isCancelled()) {          // (A) cancellation gate — checked ONCE, here
        return;
    }
    mHost.dispatchWindowInsetsAnimationStart(...);  // (B) -> our KeyboardAnimationCallback.onStart() - here keyboard can be dismissed, and the runner will be canceled
    listener.onReady(runner, types);                // (C) -> ValueAnimator.start() -> setInsetsAndAlpha() - try to update canceled runner and crash
});

Reanimated doesn't cancel runner itself, it only opens a window to make it possibly.

This mirrors the fix the react-native-keyboard-controller maintainer landed for the
identical crash in kirillzyusko/react-native-keyboard-controller#1461.

Test plan

Reproduction code
/**
 * Reanimated 4 — minimal reproduction of:
 *
 *   java.lang.IllegalStateException:
 *     Can't change insets on an animation that is cancelled.
 *       at android.view.InsetsAnimationControlImpl.setInsetsAndAlpha
 *       at android.view.InsetsController$InternalAnimationControlListener.onReady
 *       at android.animation.ValueAnimator.start
 *       at android.view.ViewRootInsetsControllerHost$1.onPreDraw
 *       ... (ViewRootImpl.performTraversals / Choreographer.doFrame)
 *
 * Root cause (AOSP race exposed by Reanimated)
 * --------------------------------------------
 * `useAnimatedKeyboard()` turns on edge-to-edge
 * (`WindowCompat.setDecorFitsSystemWindows(window, false)`) and registers a
 * `WindowInsetsAnimationCompat.Callback`. From then on the SYSTEM animates the
 * IME insets via `InsetsController$InternalAnimationControlListener`.
 *
 * When you request the IME to SHOW, `InsetsController` creates an inset-animation
 * "runner" and DEFERS its start to the next `onPreDraw`. If, within the SAME
 * frame (before that pre-draw), you request the IME to HIDE, the system cancels
 * the pending show runner (`mCancelled = true`) but its `onReady` is still
 * scheduled. When `onReady` fires at pre-draw it starts a `ValueAnimator` that
 * immediately calls `setInsetsAndAlpha()` on the cancelled runner -> crash.
 *
 * => The trigger is a SAME-FRAME show + hide:
 *      inputRef.focus();     // schedules a show runner (onReady deferred)
 *      Keyboard.dismiss();   // cancels that runner in the same frame
 *    Next pre-draw: onReady -> ValueAnimator.start() -> setInsetsAndAlpha() on a
 *    cancelled control -> IllegalStateException.
 *
 * Reanimated is required because, without edge-to-edge + the inset-animation
 * callback that `useAnimatedKeyboard()` installs, the IME show/hide does not go
 * through this animated `InsetsController` path.
 *
 * REQUIREMENTS
 * ------------
 * - Android, New Architecture (this example app already has newArch on).
 * - The SOFT keyboard must actually appear (real device best; on an emulator
 *   turn the hardware keyboard OFF). Focus the input: the blue bar should grow.
 * - STRONGLY RECOMMENDED — disable animations so start() updates synchronously:
 *     Settings -> Developer options ->
 *       Window animation scale / Transition animation scale /
 *       Animator duration scale = OFF
 *
 * USE
 * ---
 * 1. Tap "Slam once (show+hide)". If it doesn't crash immediately, tap a few
 *    times, or use "Slam loop".
 * 2. Watch: adb logcat *:E | grep -i "insets\|InsetsController\|cancelled"
 */
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
  Button,
  Keyboard,
  Platform,
  StyleSheet,
  Text,
  TextInput,
  View,
} from 'react-native';
import Animated, {
  useAnimatedKeyboard,
  useAnimatedStyle,
} from 'react-native-reanimated';

export default function KeyboardInsetsCrashRepro() {
  // Keep edge-to-edge + the system IME inset-animation path active the whole
  // time. This is what makes IME show/hide go through InsetsController.
  const keyboard = useAnimatedKeyboard();
  const inputRef = useRef<TextInput>(null);
  const [loop, setLoop] = useState(false);
  const [slams, setSlams] = useState(0);
  const slamsRef = useRef(0);

  const inputStyle = useAnimatedStyle(() => ({
    transform: [{ translateY: -keyboard.height.value }],
  }));
  // Visual proof the IME inset animation is running. If this never grows, the
  // soft keyboard isn't showing and nothing will crash — fix that first.
  const barStyle = useAnimatedStyle(() => ({
    height: keyboard.height.value,
  }));

  // The actual trigger: request show and hide in the SAME JS tick (same frame).
  const slam = useCallback(() => {
    slamsRef.current += 1;
    setSlams(slamsRef.current);
    inputRef.current?.focus(); // show(ime): schedules a runner, onReady deferred
    Keyboard.dismiss(); // hide(ime): cancels that pending runner this frame
  }, []);

  useEffect(() => {
    if (!loop) {
      return;
    }
    // Alternate show / hide ONE PER FRAME. The show on frame N creates an inset
    // runner whose onReady is posted to frame N+1's pre-draw; the hide on frame
    // N+1 (rAF runs in the animation phase, BEFORE the traversal pre-draw)
    // cancels that runner just before onReady fires it -> crash. Running every
    // frame guarantees we land in this window.
    let raf: number;
    let phase = 0;
    const tick = () => {
      if (phase % 2 === 0) {
        inputRef.current?.focus(); // show(ime)
      } else {
        Keyboard.dismiss(); // hide(ime) -> cancels the still-pending show runner
      }
      phase += 1;
      slamsRef.current += 1;
      setSlams(slamsRef.current);
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [loop]);

  return (
    <View style={styles.root}>
      <Text style={styles.title}>
        Reanimated 4 · keyboard insets crash repro
      </Text>

      {Platform.OS !== 'android' ? (
        <Text style={styles.warn}>
          Android-only crash. Run on Android (New Arch).
        </Text>
      ) : null}

      <Text style={styles.note}>
        Disable all 3 animation scales in Developer options. Soft keyboard MUST
        show (focus the input — the blue bar should grow to keyboard height).
      </Text>

      <Button title="Slam once (show + hide same frame)" onPress={slam} />
      <Button
        title={loop ? 'Stop slam loop' : 'Slam loop (every frame)'}
        onPress={() => setLoop((l) => !l)}
      />

      <Text style={styles.counter}>slams: {slams}</Text>

      <View style={styles.stage}>
        <Animated.View style={inputStyle}>
          <TextInput
            ref={inputRef}
            style={styles.input}
            placeholder="repro input"
            placeholderTextColor="#999"
          />
        </Animated.View>
        <Animated.View style={[styles.bar, barStyle]} />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  root: {
    flex: 1,
    padding: 24,
    paddingTop: 64,
    gap: 12,
    backgroundColor: '#fff',
  },
  title: { fontSize: 18, fontWeight: '700', color: '#111' },
  note: { fontSize: 13, lineHeight: 19, color: '#333' },
  warn: { fontSize: 13, color: '#b00020', fontWeight: '600' },
  counter: { fontSize: 13, color: '#555' },
  stage: { flex: 1, justifyContent: 'flex-end' },
  input: {
    height: 44,
    borderRadius: 8,
    paddingHorizontal: 12,
    backgroundColor: '#fff',
    borderWidth: StyleSheet.hairlineWidth,
    borderColor: '#ccc',
    color: '#111',
  },
  bar: {
    marginTop: 8,
    alignSelf: 'stretch',
    backgroundColor: '#3478f6',
    borderRadius: 4,
  },
});

Note that, system animation should be turned off in developer settings to make it crash more deterministic.

@piaskowyk piaskowyk changed the title ## fix(android): prevent useAnimatedKeyboard crash "Can't change insets on an animation that is cancelled" fix(android): prevent useAnimatedKeyboard crash "Can't change insets on an animation that is cancelled" Jun 16, 2026
@piaskowyk piaskowyk marked this pull request as ready for review June 16, 2026 09:40

@MatiPl01 MatiPl01 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Seems to be correct.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants