Skip to content

Commit 4b4ae62

Browse files
rubennortemeta-codesync[bot]
authored andcommitted
Add Fantom test to show bug in event dispatching with stale props (#56131)
Summary: Pull Request resolved: #56131 Changelog: [internal] This adds a new Fantom test that highlights a bug in the Fabric implementation for event dispatching, where we're updating "currentProps" (used to determine listeners) in `cloneInstance`, which runs during render and should be side-effect free, instead of during commit. This same test running on React DOM shows a different (correct) behavior: https://gist.github.com/rubennorte/b002d3673b4d83a7181311a96084f9f0#file-staleeventhandlersfrominterruptedrender-test-js-L110 Reviewed By: lenaic Differential Revision: D97097866 fbshipit-source-id: 30f6a3e2749baa35cd0fe48712e735f08848583d
1 parent 8101fc7 commit 4b4ae62

1 file changed

Lines changed: 126 additions & 0 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
12+
13+
import type {HostInstance} from 'react-native';
14+
15+
import ensureInstance from '../../../src/private/__tests__/utilities/ensureInstance';
16+
import * as Fantom from '@react-native/fantom';
17+
import * as React from 'react';
18+
import {createRef, startTransition, useDeferredValue, useState} from 'react';
19+
import {View} from 'react-native';
20+
import {NativeEventCategory} from 'react-native/src/private/testing/fantom/specs/NativeFantom';
21+
import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement';
22+
23+
function ensureReactNativeElement(value: unknown): ReactNativeElement {
24+
return ensureInstance(value, ReactNativeElement);
25+
}
26+
27+
describe('stale event handlers from interrupted render', () => {
28+
// This test demonstrates a bug where canonical.currentProps (which stores
29+
// event handlers) is updated during completeWork (render phase), not during
30+
// commit. Since the canonical object is shared between the committed fiber
31+
// and work-in-progress fiber, this is an eager mutation. During concurrent
32+
// rendering, if a render is interrupted after a component's completeWork has
33+
// run but before commit, events dispatched at that point read stale
34+
// (never-committed) handlers instead of the last committed ones.
35+
//
36+
// The test uses sibling rendering order to exploit this:
37+
// 1. First sibling: a View with an onPointerUp handler that captures
38+
// deferredLabel. Its completeWork runs first, eagerly updating
39+
// canonical.currentProps with the in-progress handler.
40+
// 2. Second sibling: InterruptTrigger, which dispatches a discrete event on
41+
// the View during render. By this point, the View's completeWork has
42+
// already updated canonical.currentProps with the new (uncommitted) handler.
43+
it('calls stale handler from discarded render instead of committed handler', () => {
44+
const root = Fantom.createRoot();
45+
const viewRef = createRef<HostInstance>();
46+
const handlerCallLog: Array<string> = [];
47+
let shouldDispatchDuringRender = false;
48+
49+
function App({label}: {label: string}) {
50+
const deferredLabel = useDeferredValue(label);
51+
const [, setInterrupt] = useState(false);
52+
53+
return (
54+
<>
55+
<View
56+
ref={viewRef}
57+
onPointerUp={() => {
58+
handlerCallLog.push(deferredLabel);
59+
// Trigger a high-priority update to interrupt the deferred render.
60+
setInterrupt(prev => !prev);
61+
}}
62+
/>
63+
<InterruptTrigger label={label} deferredLabel={deferredLabel} />
64+
</>
65+
);
66+
}
67+
68+
// This component dispatches a discrete native event during render when
69+
// we're in the deferred re-render (deferredLabel has caught up to label).
70+
// By the time this component renders, the View sibling's completeWork has
71+
// already eagerly updated canonical.currentProps with the in-progress
72+
// (not-yet-committed) handler.
73+
function InterruptTrigger({
74+
label,
75+
deferredLabel,
76+
}: {
77+
label: string,
78+
deferredLabel: string,
79+
}) {
80+
if (shouldDispatchDuringRender && deferredLabel === label) {
81+
shouldDispatchDuringRender = false;
82+
const element = ensureReactNativeElement(viewRef.current);
83+
Fantom.dispatchNativeEvent(
84+
element,
85+
'onPointerUp',
86+
{x: 0, y: 0},
87+
{
88+
category: NativeEventCategory.Discrete,
89+
},
90+
);
91+
}
92+
return null;
93+
}
94+
95+
// Initial render: commits handler capturing deferredLabel="initial".
96+
Fantom.runTask(() => {
97+
root.render(<App label="initial" />);
98+
});
99+
100+
shouldDispatchDuringRender = true;
101+
102+
// startTransition triggers:
103+
// 1. First transition render: useDeferredValue("transition") returns
104+
// "initial" (deferred) → commits, handler still captures "initial".
105+
// 2. Deferred re-render: useDeferredValue("transition") returns
106+
// "transition" → View's completeWork eagerly updates
107+
// canonical.currentProps with handler capturing "transition" →
108+
// InterruptTrigger renders and dispatches discrete event →
109+
// The stale (uncommitted) handler is called, logging "transition" →
110+
// setState in the handler interrupts and discards the deferred render.
111+
Fantom.runTask(() => {
112+
startTransition(() => {
113+
root.render(<App label="transition" />);
114+
});
115+
});
116+
117+
// CORRECT behavior: the last committed handler (capturing "initial")
118+
// should be called, because the deferred render hasn't committed yet.
119+
// expect(handlerCallLog).toEqual(['initial']);
120+
121+
// ACTUAL (buggy) behavior: the stale handler from the interrupted
122+
// (discarded) render is called because canonical.currentProps is eagerly
123+
// updated during completeWork (render phase), before the commit.
124+
expect(handlerCallLog).toEqual(['transition']);
125+
});
126+
});

0 commit comments

Comments
 (0)