DEV Community

XNeuronal
XNeuronal

Posted on

The editor that swallowed the last sentence: a React Native TextInput unmounted before onBlur, fixed in three layers

An inline editor has one job: what you type is what gets saved. Ours lost the last field you touched, only when you tapped the Done button without leaving the field first, and it did so without a warning, a red box, or a failed request. The keystrokes were on screen one moment and gone the next, and the server had never heard of them.

This is a React Native 0.81 project on the New Architecture. The editor lives inside a structured card (a title, a row of chips, sections of checkable items, numbered steps). Every piece of text on the card is a tap-to-edit field: tap a word, it becomes a TextInput, blur it, the new value is sent as a PATCH. The bug took two commits and three distinct mechanisms to close, and the interesting part is why the first correct fix was not enough. Every block below is copied from the repository at the commit named above it.

The contract that looked fine

The field component renders one of three things depending on two booleans: editable (the parent's edit mode) and focused (its own state). Not editable means plain Text. Editable and idle means a Pressable that turns focus on. Editable and focused means the TextInput, and the save happens on blur through a five line commit function that clears focused and calls onSave(draft) when the draft differs from value.

frontend/src/components/memocard/EditableText.tsx at commit a3a01e5, the day the editor landed. The first lines are the tail of the if (!editable) branch, the rest is the focused branch:

  if (!value) return <View style={containerStyle} />;
  return (
    <View style={containerStyle}>
      <Text style={[textStyle, italic && styles.italic, textAlign ? { textAlign } : null]}>
        {value}
      </Text>
    </View>
  );
}

if (focused) {
  return (
    <View style={[containerStyle, styles.focusedWrap]}>
      <TextInput
        ref={inputRef}
        value={draft}
        onChangeText={setDraft}
        onBlur={commit}
        onSubmitEditing={multiline ? undefined : commit}
        autoFocus
        multiline={multiline}
        maxLength={maxLength}
        placeholder={placeholder}
        placeholderTextColor={colors.textSecondary}
        blurOnSubmit={!multiline}
        returnKeyType={multiline ? 'default' : 'done'}
        style={[
          textStyle,
          italic && styles.italic,
          textAlign ? { textAlign } : null,
          styles.input
        ]}
      />
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

Read the order of the branches. !editable wins over focused. So when the parent flips edit mode off while this field still holds focus, the very next render skips the TextInput branch entirely and returns a Text. The input is unmounted in that commit. Its onBlur is a closure that belongs to a fiber that no longer exists; whether the native side ever dispatches a blur for a view it is busy detaching does not matter, because nobody is listening. The typed characters live in draft, a piece of React state, and the state dies with the component.

That is the whole bug. No error, because nothing failed. The PATCH was never built.

Layers one and two: watch the prop, drop the keyboard

The first fix, thirty three minutes later, added two independent guards.

The obvious one is in the button. Before this commit, the Done handler was a single setIsEditing(false).

frontend/src/components/overlays/NoteOverlay.tsx at commit ed5a883:

{isEditing ? (
  <Pressable
    onPress={() => {
      // Drop the keyboard first so the currently-focused
      // TextInput blurs and commits its draft before we
      // unmount it via the editable-toggle.
      Keyboard.dismiss();
      setIsEditing(false);
    }}
    style={styles.doneButton}
    accessibilityRole="button"
    accessibilityLabel="Terminer l'édition"
  >
    <Text style={styles.doneText}>Terminé</Text>
  </Pressable>
) : (
  <View />
)}
Enter fullscreen mode Exit fullscreen mode

Keyboard.dismiss() blurs the currently focused input. But the blur event travels from native to JavaScript asynchronously, while setIsEditing(false) is queued in the same handler. Nothing guarantees that the blur is processed before the render that removes the input commits. This layer wins the race often enough to look like a fix, which is exactly why it cannot be the only one.

The second guard is in the field itself. It mirrors its state into refs, then watches editable.

frontend/src/components/memocard/EditableText.tsx at commit ed5a883:

const [focused, setFocused] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<TextInput>(null);
// Mirror draft & value in refs so the editable-toggle effect below can
// read the latest values without re-running every keystroke.
const draftRef = useRef(draft);
const valueRef = useRef(value);
const onSaveRef = useRef(onSave);
useEffect(() => {
  draftRef.current = draft;
}, [draft]);
useEffect(() => {
  valueRef.current = value;
  onSaveRef.current = onSave;
}, [value, onSave]);
Enter fullscreen mode Exit fullscreen mode
useEffect(() => {
  if (!editable && focused) {
    const draftNow = draftRef.current;
    if (draftNow !== valueRef.current) onSaveRef.current(draftNow);
    setFocused(false);
  }
}, [editable, focused]);
Enter fullscreen mode Exit fullscreen mode

This effect runs after the commit that already removed the TextInput, and that is fine: the draft was never in the native view, it was in React state, and the component that owns that state is still mounted. The refs keep the dependency list down to [editable, focused] so the effect does not re-run on every keystroke, and they make sure the callback invoked is the latest onSave, not the one captured when the effect was created. The draftNow !== valueRef.current guard means that if the native blur does arrive as well, the worst case is a second PATCH with an identical body, not a wrong one.

This layer is correct. It covers the title, the chips, the section headings, whose wrappers stay the same element in both modes. It did nothing for the steps.

Why the correct fix arrived too late

The step rows are tappable in read mode, so you can tick a step off while cooking. In edit mode they are not, so the row is a plain container with a delete badge. The component picked the wrapper element by mode.

frontend/src/components/memocard/MemoFiche.tsx at commit b7f346e, the edit branch of the step row:

if (editing) {
  return (
    <View key={idx} style={[styles.stepRow, last && styles.stepRowLast]}>
Enter fullscreen mode Exit fullscreen mode

And the read branch, a few lines below:

return (
  <Pressable
    key={idx}
    onPress={() => onToggle(idx)}
    accessibilityRole="checkbox"
    accessibilityState={{ checked }}
    style={({ pressed }) => [
      styles.stepRow,
      last && styles.stepRowLast,
      pressed && styles.stepRowPressed
    ]}
  >
Enter fullscreen mode Exit fullscreen mode

Same key, different element type. React's reconciler compares the type before anything else, and when it differs it does not update the subtree, it throws it away and builds a new one. So when Done flips editing, the EditableText inside a step row is not re-rendered with editable={false}. It is unmounted. An effect keyed on [editable, focused] needs a render to observe the prop change, and that render never happens. Layer one was right about the mechanism and simply never got to run.

Layer three: flush on unmount

The second commit, twenty five minutes after the first, added the guard that survives a teardown: a cleanup function on an effect with an empty dependency list, which React calls exactly once, when the component unmounts. It also added a fourth mirror, focusedRef, kept in sync by the same kind of one-line effect as the other three.

frontend/src/components/memocard/EditableText.tsx at commit b7f346e:

useEffect(
  () => () => {
    if (focusedRef.current && draftRef.current !== valueRef.current) {
      onSaveRef.current(draftRef.current);
    }
  },
  []
);
Enter fullscreen mode Exit fullscreen mode

Everything in that cleanup is a ref, including focused, which is why the fourth ref appeared. State captured at render time is frozen at whatever the first render saw, because the effect closure was created once. By the time the cleanup runs, the only trustworthy values are the ones written into refs by the small mirroring effects on every change.

The two field-side layers are complementary, not redundant. When the field survives the toggle and re-renders as Text, its TextInput child unmounts but the field does not, so no cleanup fires and layer one does the work. When the parent swaps element types, or deletes the whole section while you are typing in it, the field unmounts and only layer three can act. The keyboard dismiss is the belt over both braces.

The line that tells two failures apart

The same first commit touched one file outside the app: backend/src/routes/anonymous.ts gained a console.log when a PATCH arrives, with the neuron id and the list of patched fields, and a console.warn when the database rejects it. Not a fix. "My edit did not save" has two completely different causes with one face: the request never left the phone, or the database refused the row. Before those two lines, the server log could not distinguish them. The next time the symptom shows up, one read of the feed says which side of the network to look at.

What the diff does not prove

The account above is the one the code supports. A few things it does not support.

The checkable item rows in ChecklistSection.tsx have the very same View versus Pressable swap at b7f346e. So the story that layer one "worked for the items but not the steps" cannot be explained by the code; either the items were tested by blurring before tapping Done, or the keyboard race happened to win those times. Nobody bisected.

Nothing tests any of the three layers. The one test file that imports EditableText uses it as a type to locate elements, not to exercise a flush. The structural fix was in the parent: render one element type in both modes and toggle disabled, and the unmount never happens. That fix was never tried. The swap is still in MemoFiche.tsx today, which means layer three is still load-bearing, not a leftover.

Four later commits touched EditableText.tsx (translations, a tint on editable fields, a keyboard type, an auto-focus flag). None of them touched the three effects, which are in the current file character for character.

And the keyboard dismiss is still there. Whether it ever decides an outcome anymore, with the other two layers in place, is something the repository cannot say.

The lesson I kept is narrower than "use refs in cleanups". When a React Native symptom is "nothing happened", ask which node was removed and by whom before asking which callback failed. The callback was fine. The node it was attached to had already been thrown away by a parent that, from its own point of view, was only changing a wrapper.

The card this editor lives in is at xneuronal.com.

Top comments (0)