Skip to content

fix: stabilize trigger callbacks to prevent missed events on Android#261

Closed
mfazekas wants to merge 1 commit into
mainfrom
fix/issue-230-unstable-callback-triggers
Closed

fix: stabilize trigger callbacks to prevent missed events on Android#261
mfazekas wants to merge 1 commit into
mainfrom
fix/issue-230-unstable-callback-triggers

Conversation

@mfazekas
Copy link
Copy Markdown
Collaborator

@mfazekas mfazekas commented May 18, 2026

Fixes #230

Unstable onTrigger callback references cause useRiveTrigger (and all property hooks) to silently stop receiving events on Android after re-renders.

The callback identity is now stored in a useRef, so the native property lifecycle is decoupled from callback changes. The property is only disposed/recreated when viewModelInstance or path changes.

Root cause and reproducer

Root cause

useDisposableMemo disposes the old native property and creates a new one during the render phase (via ref mutation). When the callback identity changes (e.g. onTrigger without React Compiler memoization), options changes, which triggers this dispose/create cycle.

The problem: if a no-op state update (like setError(null) when error is already null) triggers a re-render that produces identical JSX output, React bails out — it runs the component function but skips the commit and effects. Since useEffect is skipped, the listener is never re-subscribed to the new property.

useDisposableMemo:  deps changed → DISPOSE old, CREATE new   ✅ runs (render phase)
useEffect:          deps changed → should re-subscribe...    ❌ skipped (bailout)

The property is orphaned — disposed old one is dead, new one has no listener. All subsequent trigger events are silently lost.

Reproducer

import { View, Text, Pressable } from 'react-native';
import {
  RiveView, useRiveFile, useRiveTrigger,
  useViewModelInstance, Fit,
} from '@rive-app/react-native';

export default function Issue230() {
  'use no memo'; // disable React Compiler → unstable callbacks

  const [triggerCount, setTriggerCount] = useState(0);
  const { riveFile } = useRiveFile(require('./rewards.riv'));
  const { instance } = useViewModelInstance(riveFile);

  // New reference every render — this is what triggers the bug
  const onTrigger = () => setTriggerCount((c) => c + 1);

  useRiveTrigger('Button/Pressed', instance, { onTrigger });

  return (
    <View>
      <Text>Triggers received: {triggerCount}</Text>
      {riveFile && instance && (
        <RiveView file={riveFile} fit={Fit.Contain} dataBind={instance}
          style={{ width: '100%', height: 200 }} />
      )}
    </View>
  );
}
// Tap the Rive button — first tap works, then triggers stop.
// On Android the trigger fires at the native level (chest opens)
// but the JS callback is never called after the bailout render.

Actual logcat output (before fix)

[useRiveProperty] R2 CREATED property for "Button/Pressed": true
[useRiveProperty] R2 EFFECT RUN — SUBSCRIBING listener ✓
[useRiveProperty] R3 DISPOSING property for "Button/Pressed"
[useRiveProperty] R3 CREATED property for "Button/Pressed": true
                  ← no R3 EFFECT — React bailed out, listener never subscribed

…230)

Store onPropertyEventOverride in a ref so callback identity changes don't
cause useDisposableMemo to dispose/recreate the native property during a
React bailout render where effects are skipped.
@mfazekas
Copy link
Copy Markdown
Collaborator Author

Closing in favor of #262 (separate trigger hook approach)

@mfazekas mfazekas closed this May 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android: unstable callback references can make useRiveTrigger and onEventReceived miss events

1 participant