Skip to content

Commit 17c14e6

Browse files
committed
Replay onChange events if there was a change to the value before hydration
We do this by simulating a real DOM event so that custom listeners get to observe it as well.
1 parent 3c829ce commit 17c14e6

File tree

6 files changed

+112
-39
lines changed

6 files changed

+112
-39
lines changed

packages/react-dom-bindings/src/client/ReactDOMInput.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
2222

2323
import type {ToStringValue} from './ToStringValue';
2424
import escapeSelectorAttributeValueInsideDoubleQuotes from './escapeSelectorAttributeValueInsideDoubleQuotes';
25+
import {queueChangeEvent} from '../events/ReactDOMEventReplaying';
2526

2627
let didWarnValueDefaultValue = false;
2728
let didWarnCheckedDefaultChecked = false;
@@ -371,8 +372,11 @@ export function hydrateInput(
371372
const changed = trackHydrated((node: any), initialValue, initialChecked);
372373
if (changed) {
373374
// If the current value is different, that suggests that the user
374-
// changed it before hydration.
375-
// TODO: Queue replay.
375+
// changed it before hydration. Queue a replay of the change event.
376+
// For radio buttons the change event only fires on the selected one.
377+
if (node.type !== 'radio' || node.checked) {
378+
queueChangeEvent(node);
379+
}
376380
}
377381
}
378382

packages/react-dom-bindings/src/client/ReactDOMSelect.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {getCurrentFiberOwnerNameInDevOrNull} from 'react-reconciler/src/ReactCur
1212

1313
import {getToStringValue, toString} from './ToStringValue';
1414
import isArray from 'shared/isArray';
15+
import {queueChangeEvent} from '../events/ReactDOMEventReplaying';
1516

1617
let didWarnValueDefaultValue;
1718

@@ -205,8 +206,8 @@ export function hydrateSelect(
205206
}
206207
if (changed) {
207208
// If the current selection is different than our initial that suggests that the user
208-
// changed it before hydration.
209-
// TODO: Queue replay.
209+
// changed it before hydration. Queue a replay of the change event.
210+
queueChangeEvent(node);
210211
}
211212
}
212213

packages/react-dom-bindings/src/client/ReactDOMTextarea.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {getToStringValue, toString} from './ToStringValue';
1414
import {disableTextareaChildren} from 'shared/ReactFeatureFlags';
1515

1616
import {track, trackHydrated} from './inputValueTracking';
17+
import {queueChangeEvent} from '../events/ReactDOMEventReplaying';
1718

1819
let didWarnValDefaultVal = false;
1920

@@ -166,8 +167,8 @@ export function hydrateTextarea(
166167
const changed = trackHydrated((node: any), stringValue, false);
167168
if (changed) {
168169
// If the current value is different, that suggests that the user
169-
// changed it before hydration.
170-
// TODO: Queue replay.
170+
// changed it before hydration. Queue a replay of the change event.
171+
queueChangeEvent(node);
171172
}
172173
}
173174

packages/react-dom-bindings/src/events/ReactDOMEventReplaying.js

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,11 @@ import {
5656
attemptHydrationAtCurrentPriority,
5757
} from 'react-reconciler/src/ReactFiberReconciler';
5858

59+
import {enableHydrationChangeEvent} from 'shared/ReactFeatureFlags';
60+
5961
// TODO: Upgrade this definition once we're on a newer version of Flow that
6062
// has this definition built-in.
61-
type PointerEvent = Event & {
63+
type PointerEventType = Event & {
6264
pointerId: number,
6365
relatedTarget: EventTarget | null,
6466
...
@@ -84,6 +86,8 @@ const queuedPointers: Map<number, QueuedReplayableEvent> = new Map();
8486
const queuedPointerCaptures: Map<number, QueuedReplayableEvent> = new Map();
8587
// We could consider replaying selectionchange and touchmoves too.
8688

89+
const queuedChangeEventTargets: Array<EventTarget> = [];
90+
8791
type QueuedHydrationTarget = {
8892
blockedOn: null | Container | ActivityInstance | SuspenseInstance,
8993
target: Node,
@@ -164,13 +168,13 @@ export function clearIfContinuousEvent(
164168
break;
165169
case 'pointerover':
166170
case 'pointerout': {
167-
const pointerId = ((nativeEvent: any): PointerEvent).pointerId;
171+
const pointerId = ((nativeEvent: any): PointerEventType).pointerId;
168172
queuedPointers.delete(pointerId);
169173
break;
170174
}
171175
case 'gotpointercapture':
172176
case 'lostpointercapture': {
173-
const pointerId = ((nativeEvent: any): PointerEvent).pointerId;
177+
const pointerId = ((nativeEvent: any): PointerEventType).pointerId;
174178
queuedPointerCaptures.delete(pointerId);
175179
break;
176180
}
@@ -268,7 +272,7 @@ export function queueIfContinuousEvent(
268272
return true;
269273
}
270274
case 'pointerover': {
271-
const pointerEvent = ((nativeEvent: any): PointerEvent);
275+
const pointerEvent = ((nativeEvent: any): PointerEventType);
272276
const pointerId = pointerEvent.pointerId;
273277
queuedPointers.set(
274278
pointerId,
@@ -284,7 +288,7 @@ export function queueIfContinuousEvent(
284288
return true;
285289
}
286290
case 'gotpointercapture': {
287-
const pointerEvent = ((nativeEvent: any): PointerEvent);
291+
const pointerEvent = ((nativeEvent: any): PointerEventType);
288292
const pointerId = pointerEvent.pointerId;
289293
queuedPointerCaptures.set(
290294
pointerId,
@@ -421,6 +425,31 @@ function attemptReplayContinuousQueuedEventInMap(
421425
}
422426
}
423427

428+
function replayChangeEvent(target: EventTarget): void {
429+
// Dispatch a fake "change" event for the input.
430+
const element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement =
431+
(target: any);
432+
if (element.nodeName === 'INPUT') {
433+
if (element.type === 'checkbox' || element.type === 'radio') {
434+
// Checkboxes always fire a click event regardless of how the change was made.
435+
const EventCtr =
436+
typeof PointerEvent === 'function' ? PointerEvent : Event;
437+
target.dispatchEvent(new EventCtr('click', {bubbles: true}));
438+
// For checkboxes the input event uses the Event constructor instead of InputEvent.
439+
target.dispatchEvent(new Event('input', {bubbles: true}));
440+
} else {
441+
if (typeof InputEvent === 'function') {
442+
target.dispatchEvent(new InputEvent('input', {bubbles: true}));
443+
}
444+
}
445+
} else if (element.nodeName === 'TEXTAREA') {
446+
if (typeof InputEvent === 'function') {
447+
target.dispatchEvent(new InputEvent('input', {bubbles: true}));
448+
}
449+
}
450+
target.dispatchEvent(new Event('change', {bubbles: true}));
451+
}
452+
424453
function replayUnblockedEvents() {
425454
hasScheduledReplayAttempt = false;
426455
// Replay any continuous events.
@@ -435,6 +464,22 @@ function replayUnblockedEvents() {
435464
}
436465
queuedPointers.forEach(attemptReplayContinuousQueuedEventInMap);
437466
queuedPointerCaptures.forEach(attemptReplayContinuousQueuedEventInMap);
467+
if (enableHydrationChangeEvent) {
468+
for (let i = 0; i < queuedChangeEventTargets.length; i++) {
469+
replayChangeEvent(queuedChangeEventTargets[i]);
470+
}
471+
queuedChangeEventTargets.length = 0;
472+
}
473+
}
474+
475+
export function queueChangeEvent(target: EventTarget): void {
476+
if (enableHydrationChangeEvent) {
477+
queuedChangeEventTargets.push(target);
478+
if (!hasScheduledReplayAttempt) {
479+
hasScheduledReplayAttempt = true;
480+
scheduleCallback(NormalPriority, replayUnblockedEvents);
481+
}
482+
}
438483
}
439484

440485
function scheduleCallbackIfUnblocked(

packages/react-dom/src/__tests__/ReactDOMInput-test.js

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1536,10 +1536,14 @@ describe('ReactDOMInput', () => {
15361536
ReactDOMClient.hydrateRoot(container, <App />);
15371537
});
15381538

1539-
// Currently, we don't fire onChange when hydrating
1540-
assertLog([]);
1541-
// Strangely, we leave `b` checked even though we rendered A with
1542-
// checked={true} and B with checked={false}. Arguably this is a bug.
1539+
if (gate(flags => flags.enableHydrationChangeEvent)) {
1540+
// We replayed the click since the value changed before hydration.
1541+
assertLog(['click b']);
1542+
} else {
1543+
assertLog([]);
1544+
// Strangely, we leave `b` checked even though we rendered A with
1545+
// checked={true} and B with checked={false}. Arguably this is a bug.
1546+
}
15431547
expect(a.checked).toBe(false);
15441548
expect(b.checked).toBe(true);
15451549
expect(c.checked).toBe(false);
@@ -1554,22 +1558,35 @@ describe('ReactDOMInput', () => {
15541558
dispatchEventOnNode(c, 'click');
15551559
});
15561560

1557-
// then since C's onClick doesn't set state, A becomes rechecked.
15581561
assertLog(['click c']);
1559-
expect(a.checked).toBe(true);
1560-
expect(b.checked).toBe(false);
1561-
expect(c.checked).toBe(false);
1562+
if (gate(flags => flags.enableHydrationChangeEvent)) {
1563+
// then since C's onClick doesn't set state, B becomes rechecked.
1564+
expect(a.checked).toBe(false);
1565+
expect(b.checked).toBe(true);
1566+
expect(c.checked).toBe(false);
1567+
} else {
1568+
// then since C's onClick doesn't set state, A becomes rechecked
1569+
// since in this branch we didn't replay to select B.
1570+
expect(a.checked).toBe(true);
1571+
expect(b.checked).toBe(false);
1572+
expect(c.checked).toBe(false);
1573+
}
15621574
expect(isCheckedDirty(a)).toBe(true);
15631575
expect(isCheckedDirty(b)).toBe(true);
15641576
expect(isCheckedDirty(c)).toBe(true);
15651577
assertInputTrackingIsCurrent(container);
15661578

1567-
// And we can also change to B properly after hydration.
15681579
await act(async () => {
15691580
setUntrackedChecked.call(b, true);
15701581
dispatchEventOnNode(b, 'click');
15711582
});
1572-
assertLog(['click b']);
1583+
if (gate(flags => flags.enableHydrationChangeEvent)) {
1584+
// Since we already had this selected, this doesn't trigger a change again.
1585+
assertLog([]);
1586+
} else {
1587+
// And we can also change to B properly after hydration.
1588+
assertLog(['click b']);
1589+
}
15731590
expect(a.checked).toBe(false);
15741591
expect(b.checked).toBe(true);
15751592
expect(c.checked).toBe(false);
@@ -1628,8 +1645,12 @@ describe('ReactDOMInput', () => {
16281645
ReactDOMClient.hydrateRoot(container, <App />);
16291646
});
16301647

1631-
// Currently, we don't fire onChange when hydrating
1632-
assertLog([]);
1648+
if (gate(flags => flags.enableHydrationChangeEvent)) {
1649+
// We replayed the click since the value changed before hydration.
1650+
assertLog(['click b']);
1651+
} else {
1652+
assertLog([]);
1653+
}
16331654
expect(a.checked).toBe(false);
16341655
expect(b.checked).toBe(true);
16351656
expect(c.checked).toBe(false);

packages/react-dom/src/__tests__/ReactDOMServerIntegrationUserInteraction-test.js

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -278,10 +278,9 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
278278
await testUserInteractionBeforeClientRender(
279279
<ControlledInput onChange={() => changeCount++} />,
280280
);
281-
// note that there's a strong argument to be made that the DOM revival
282-
// algorithm should notice that the user has changed the value and fire
283-
// an onChange. however, it does not now, so that's what this tests.
284-
expect(changeCount).toBe(0);
281+
expect(changeCount).toBe(
282+
gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
283+
);
285284
});
286285

287286
it('should not blow away user-interaction on successful reconnect to an uncontrolled range input', () =>
@@ -302,7 +301,9 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
302301
'0.25',
303302
'1',
304303
);
305-
expect(changeCount).toBe(0);
304+
expect(changeCount).toBe(
305+
gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
306+
);
306307
});
307308

308309
it('should not blow away user-entered text on successful reconnect to an uncontrolled checkbox', () =>
@@ -321,24 +322,22 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
321322
false,
322323
'checked',
323324
);
324-
expect(changeCount).toBe(0);
325+
expect(changeCount).toBe(
326+
gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
327+
);
325328
});
326329

327-
// skipping this test because React 15 does the wrong thing. it blows
328-
// away the user's typing in the textarea.
329-
// eslint-disable-next-line jest/no-disabled-tests
330-
it.skip('should not blow away user-entered text on successful reconnect to an uncontrolled textarea', () =>
330+
// @gate enableHydrationChangeEvent
331+
it('should not blow away user-entered text on successful reconnect to an uncontrolled textarea', () =>
331332
testUserInteractionBeforeClientRender(<textarea defaultValue="Hello" />));
332333

333-
// skipping this test because React 15 does the wrong thing. it blows
334-
// away the user's typing in the textarea.
335-
// eslint-disable-next-line jest/no-disabled-tests
336-
it.skip('should not blow away user-entered text on successful reconnect to a controlled textarea', async () => {
334+
// @gate enableHydrationChangeEvent
335+
it('should not blow away user-entered text on successful reconnect to a controlled textarea', async () => {
337336
let changeCount = 0;
338337
await testUserInteractionBeforeClientRender(
339338
<ControlledTextArea onChange={() => changeCount++} />,
340339
);
341-
expect(changeCount).toBe(0);
340+
expect(changeCount).toBe(1);
342341
});
343342

344343
it('should not blow away user-selected value on successful reconnect to an uncontrolled select', () =>
@@ -358,7 +357,9 @@ describe('ReactDOMServerIntegrationUserInteraction', () => {
358357
await testUserInteractionBeforeClientRender(
359358
<ControlledSelect onChange={() => changeCount++} />,
360359
);
361-
expect(changeCount).toBe(0);
360+
expect(changeCount).toBe(
361+
gate(flags => flags.enableHydrationChangeEvent) ? 1 : 0,
362+
);
362363
});
363364
});
364365
});

0 commit comments

Comments
 (0)