Skip to content

Commit 9816d38

Browse files
authored
fix(document): reduce impact of React@17 workaround (#992)
1 parent 77a7fa8 commit 9816d38

File tree

5 files changed

+79
-63
lines changed

5 files changed

+79
-63
lines changed

src/document/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,7 @@ function prepareElement(el: Element) {
8080
export {
8181
getUIValue,
8282
setUIValue,
83-
startTrackValue,
84-
endTrackValue,
83+
commitValueAfterInput,
8584
clearInitialValue,
8685
} from './value'
8786
export {getUISelection, setUISelection} from './selection'

src/document/selection.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,12 @@ export function getUISelection(
145145
}
146146
}
147147

148+
export function hasUISelection(
149+
element: HTMLInputElement | HTMLTextAreaElement,
150+
) {
151+
return !!element[UISelection]
152+
}
153+
148154
/** Flag the IDL selection as clean. This does not change the selection. */
149155
export function setUISelectionClean(
150156
element: HTMLInputElement | HTMLTextAreaElement,

src/document/value.ts

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import {isElementType} from '../utils'
1+
import {getWindow, isElementType} from '../utils'
22
import {prepareInterceptor} from './interceptor'
3-
import {setUISelection} from './selection'
3+
import {hasUISelection, setUISelection} from './selection'
44

55
const UIValue = Symbol('Displayed value in UI')
66
const InitialValue = Symbol('Initial value to compare on blur')
@@ -12,6 +12,9 @@ type Value = {
1212
}
1313

1414
declare global {
15+
interface Window {
16+
REACT_VERSION?: number
17+
}
1518
interface Element {
1619
[UIValue]?: string
1720
[InitialValue]?: string
@@ -31,7 +34,7 @@ function valueInterceptor(
3134

3235
if (isUI) {
3336
this[UIValue] = String(v)
34-
setPreviousValue(this, String(this.value))
37+
startTrackValue(this)
3538
}
3639

3740
return {
@@ -102,19 +105,28 @@ export function getInitialValue(
102105
return element[InitialValue]
103106
}
104107

105-
function setPreviousValue(
106-
element: HTMLInputElement | HTMLTextAreaElement,
107-
v: string,
108-
) {
109-
element[TrackChanges] = {...element[TrackChanges], previousValue: v}
108+
// When the input event happens in the browser, React executes all event handlers
109+
// and if they change state of a controlled value, nothing happens.
110+
// But when we trigger the event handlers in test environment with React@17,
111+
// the changes are rolled back before the state update is applied.
112+
// This results in a reset cursor.
113+
// There might be a better way to work around if we figure out
114+
// why the batched update is executed differently in our test environment.
115+
116+
function isReact17Element(element: Element) {
117+
return (
118+
Object.getOwnPropertyNames(element).some(k => k.startsWith('__react')) &&
119+
getWindow(element).REACT_VERSION === 17
120+
)
110121
}
111122

112-
export function startTrackValue(
113-
element: HTMLInputElement | HTMLTextAreaElement,
114-
) {
123+
function startTrackValue(element: HTMLInputElement | HTMLTextAreaElement) {
124+
if (!isReact17Element(element)) {
125+
return
126+
}
127+
115128
element[TrackChanges] = {
116-
...element[TrackChanges],
117-
nextValue: String(element.value),
129+
previousValue: String(element.value),
118130
tracked: [],
119131
}
120132
}
@@ -125,38 +137,36 @@ function trackOrSetValue(
125137
) {
126138
element[TrackChanges]?.tracked?.push(v)
127139

128-
if (!element[TrackChanges]?.tracked) {
129-
setCleanValue(element, v)
140+
if (!element[TrackChanges]) {
141+
setUIValueClean(element)
142+
setUISelection(element, {focusOffset: v.length})
130143
}
131144
}
132145

133-
function setCleanValue(
146+
export function commitValueAfterInput(
134147
element: HTMLInputElement | HTMLTextAreaElement,
135-
v: string,
148+
cursorOffset: number,
136149
) {
137-
element[UIValue] = undefined
138-
139-
// Programmatically setting the value property
140-
// moves the cursor to the end of the input.
141-
setUISelection(element, {focusOffset: v.length})
142-
}
143-
144-
/**
145-
* @returns `true` if we recognize a React state reset and update
146-
*/
147-
export function endTrackValue(element: HTMLInputElement | HTMLTextAreaElement) {
148150
const changes = element[TrackChanges]
149151

150152
element[TrackChanges] = undefined
151153

154+
if (!changes?.tracked?.length) {
155+
return
156+
}
157+
152158
const isJustReactStateUpdate =
153-
changes?.tracked?.length === 2 &&
159+
changes.tracked.length === 2 &&
154160
changes.tracked[0] === changes.previousValue &&
155-
changes.tracked[1] === changes.nextValue
161+
changes.tracked[1] === element.value
156162

157-
if (changes?.tracked?.length && !isJustReactStateUpdate) {
158-
setCleanValue(element, changes.tracked[changes.tracked.length - 1])
163+
if (!isJustReactStateUpdate) {
164+
setUIValueClean(element)
159165
}
160166

161-
return isJustReactStateUpdate
167+
if (hasUISelection(element)) {
168+
setUISelection(element, {
169+
focusOffset: isJustReactStateUpdate ? cursorOffset : element.value.length,
170+
})
171+
}
162172
}

src/utils/edit/input.ts

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import {
22
clearInitialValue,
3-
endTrackValue,
3+
commitValueAfterInput,
44
getUIValue,
55
setUIValue,
6-
startTrackValue,
76
UISelectionRange,
87
} from '../../document'
98
import {dispatchUIEvent} from '../../event'
@@ -233,24 +232,9 @@ function commitInput(
233232
newOffset: number,
234233
inputInit: InputEventInit,
235234
) {
236-
// When the input event happens in the browser, React executes all event handlers
237-
// and if they change state of a controlled value, nothing happens.
238-
// But when we trigger the event handlers in test environment,
239-
// the changes are rolled back by React before the state update is applied.
240-
// Then the updated state is applied which results in a reset cursor.
241-
// There is probably a better way to work around if we figure out
242-
// why the batched update is executed differently in our test environment.
243-
startTrackValue(element)
244-
245235
dispatchUIEvent(config, element, 'input', inputInit)
246236

247-
if (endTrackValue(element as HTMLInputElement)) {
248-
setSelection({
249-
focusNode: element,
250-
anchorOffset: newOffset,
251-
focusOffset: newOffset,
252-
})
253-
}
237+
commitValueAfterInput(element, newOffset)
254238
}
255239

256240
function isValidNumberInput(value: string) {

tests/react/index.tsx

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import React, {useState} from 'react'
1+
import React, {useLayoutEffect, useRef, useState} from 'react'
22
import {render, screen, waitFor} from '@testing-library/react'
33
import userEvent from '#src'
4-
import {getUIValue} from '#src/document'
4+
import {getUISelection, getUIValue} from '#src/document'
55
import {addListeners} from '#testHelpers'
66

77
// Run twice to verify we handle this correctly no matter
@@ -62,17 +62,24 @@ test.each(['1.5', '1e5'])(
6262
},
6363
)
6464

65-
test('detect value change in event handler', async () => {
65+
test('detect value and selection change', async () => {
6666
function Input() {
67+
const el = useRef<HTMLInputElement>(null)
6768
const [val, setVal] = useState('')
6869

70+
useLayoutEffect(() => {
71+
if (val === 'ab') {
72+
el.current?.setSelectionRange(1, 1)
73+
}
74+
})
75+
6976
return (
7077
<input
71-
type="number"
78+
ref={el}
7279
value={val}
7380
onChange={e => {
74-
if (Number(e.target.value) == 12) {
75-
e.target.value = '34'
81+
if (e.target.value === 'acb') {
82+
e.target.value = 'def'
7683
}
7784
setVal(e.target.value)
7885
}}
@@ -81,11 +88,21 @@ test('detect value change in event handler', async () => {
8188
}
8289
render(<Input />)
8390
const user = userEvent.setup()
84-
screen.getByRole('spinbutton').focus()
91+
const element = screen.getByRole<HTMLInputElement>('textbox')
92+
element.focus()
93+
94+
await user.keyboard('ab')
95+
expect(getUIValue(element)).toBe('ab')
96+
expect(element).toHaveValue('ab')
97+
expect(getUISelection(element)).toHaveProperty('focusOffset', 1)
98+
99+
await user.keyboard('c')
100+
expect(getUIValue(element)).toBe('def')
101+
expect(element).toHaveValue('def')
85102

86-
await user.keyboard('125')
87-
expect(getUIValue(screen.getByRole('spinbutton'))).toBe('345')
88-
expect(screen.getByRole('spinbutton')).toHaveValue(345)
103+
await user.keyboard('g')
104+
expect(getUIValue(element)).toBe('defg')
105+
expect(element).toHaveValue('defg')
89106
})
90107

91108
test('trigger onChange SyntheticEvent on input', async () => {

0 commit comments

Comments
 (0)