Skip to content

Commit

Permalink
fix(document): reduce impact of React@17 workaround (#992)
Browse files Browse the repository at this point in the history
  • Loading branch information
ph-fritsche committed Jul 18, 2022
1 parent 77a7fa8 commit 9816d38
Show file tree
Hide file tree
Showing 5 changed files with 79 additions and 63 deletions.
3 changes: 1 addition & 2 deletions src/document/index.ts
Expand Up @@ -80,8 +80,7 @@ function prepareElement(el: Element) {
export {
getUIValue,
setUIValue,
startTrackValue,
endTrackValue,
commitValueAfterInput,
clearInitialValue,
} from './value'
export {getUISelection, setUISelection} from './selection'
Expand Down
6 changes: 6 additions & 0 deletions src/document/selection.ts
Expand Up @@ -145,6 +145,12 @@ export function getUISelection(
}
}

export function hasUISelection(
element: HTMLInputElement | HTMLTextAreaElement,
) {
return !!element[UISelection]
}

/** Flag the IDL selection as clean. This does not change the selection. */
export function setUISelectionClean(
element: HTMLInputElement | HTMLTextAreaElement,
Expand Down
76 changes: 43 additions & 33 deletions src/document/value.ts
@@ -1,6 +1,6 @@
import {isElementType} from '../utils'
import {getWindow, isElementType} from '../utils'
import {prepareInterceptor} from './interceptor'
import {setUISelection} from './selection'
import {hasUISelection, setUISelection} from './selection'

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

declare global {
interface Window {
REACT_VERSION?: number
}
interface Element {
[UIValue]?: string
[InitialValue]?: string
Expand All @@ -31,7 +34,7 @@ function valueInterceptor(

if (isUI) {
this[UIValue] = String(v)
setPreviousValue(this, String(this.value))
startTrackValue(this)
}

return {
Expand Down Expand Up @@ -102,19 +105,28 @@ export function getInitialValue(
return element[InitialValue]
}

function setPreviousValue(
element: HTMLInputElement | HTMLTextAreaElement,
v: string,
) {
element[TrackChanges] = {...element[TrackChanges], previousValue: v}
// When the input event happens in the browser, React executes all event handlers
// and if they change state of a controlled value, nothing happens.
// But when we trigger the event handlers in test environment with React@17,
// the changes are rolled back before the state update is applied.
// This results in a reset cursor.
// There might be a better way to work around if we figure out
// why the batched update is executed differently in our test environment.

function isReact17Element(element: Element) {
return (
Object.getOwnPropertyNames(element).some(k => k.startsWith('__react')) &&
getWindow(element).REACT_VERSION === 17
)
}

export function startTrackValue(
element: HTMLInputElement | HTMLTextAreaElement,
) {
function startTrackValue(element: HTMLInputElement | HTMLTextAreaElement) {
if (!isReact17Element(element)) {
return
}

element[TrackChanges] = {
...element[TrackChanges],
nextValue: String(element.value),
previousValue: String(element.value),
tracked: [],
}
}
Expand All @@ -125,38 +137,36 @@ function trackOrSetValue(
) {
element[TrackChanges]?.tracked?.push(v)

if (!element[TrackChanges]?.tracked) {
setCleanValue(element, v)
if (!element[TrackChanges]) {
setUIValueClean(element)
setUISelection(element, {focusOffset: v.length})
}
}

function setCleanValue(
export function commitValueAfterInput(
element: HTMLInputElement | HTMLTextAreaElement,
v: string,
cursorOffset: number,
) {
element[UIValue] = undefined

// Programmatically setting the value property
// moves the cursor to the end of the input.
setUISelection(element, {focusOffset: v.length})
}

/**
* @returns `true` if we recognize a React state reset and update
*/
export function endTrackValue(element: HTMLInputElement | HTMLTextAreaElement) {
const changes = element[TrackChanges]

element[TrackChanges] = undefined

if (!changes?.tracked?.length) {
return
}

const isJustReactStateUpdate =
changes?.tracked?.length === 2 &&
changes.tracked.length === 2 &&
changes.tracked[0] === changes.previousValue &&
changes.tracked[1] === changes.nextValue
changes.tracked[1] === element.value

if (changes?.tracked?.length && !isJustReactStateUpdate) {
setCleanValue(element, changes.tracked[changes.tracked.length - 1])
if (!isJustReactStateUpdate) {
setUIValueClean(element)
}

return isJustReactStateUpdate
if (hasUISelection(element)) {
setUISelection(element, {
focusOffset: isJustReactStateUpdate ? cursorOffset : element.value.length,
})
}
}
20 changes: 2 additions & 18 deletions src/utils/edit/input.ts
@@ -1,9 +1,8 @@
import {
clearInitialValue,
endTrackValue,
commitValueAfterInput,
getUIValue,
setUIValue,
startTrackValue,
UISelectionRange,
} from '../../document'
import {dispatchUIEvent} from '../../event'
Expand Down Expand Up @@ -233,24 +232,9 @@ function commitInput(
newOffset: number,
inputInit: InputEventInit,
) {
// When the input event happens in the browser, React executes all event handlers
// and if they change state of a controlled value, nothing happens.
// But when we trigger the event handlers in test environment,
// the changes are rolled back by React before the state update is applied.
// Then the updated state is applied which results in a reset cursor.
// There is probably a better way to work around if we figure out
// why the batched update is executed differently in our test environment.
startTrackValue(element)

dispatchUIEvent(config, element, 'input', inputInit)

if (endTrackValue(element as HTMLInputElement)) {
setSelection({
focusNode: element,
anchorOffset: newOffset,
focusOffset: newOffset,
})
}
commitValueAfterInput(element, newOffset)
}

function isValidNumberInput(value: string) {
Expand Down
37 changes: 27 additions & 10 deletions tests/react/index.tsx
@@ -1,7 +1,7 @@
import React, {useState} from 'react'
import React, {useLayoutEffect, useRef, useState} from 'react'
import {render, screen, waitFor} from '@testing-library/react'
import userEvent from '#src'
import {getUIValue} from '#src/document'
import {getUISelection, getUIValue} from '#src/document'
import {addListeners} from '#testHelpers'

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

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

useLayoutEffect(() => {
if (val === 'ab') {
el.current?.setSelectionRange(1, 1)
}
})

return (
<input
type="number"
ref={el}
value={val}
onChange={e => {
if (Number(e.target.value) == 12) {
e.target.value = '34'
if (e.target.value === 'acb') {
e.target.value = 'def'
}
setVal(e.target.value)
}}
Expand All @@ -81,11 +88,21 @@ test('detect value change in event handler', async () => {
}
render(<Input />)
const user = userEvent.setup()
screen.getByRole('spinbutton').focus()
const element = screen.getByRole<HTMLInputElement>('textbox')
element.focus()

await user.keyboard('ab')
expect(getUIValue(element)).toBe('ab')
expect(element).toHaveValue('ab')
expect(getUISelection(element)).toHaveProperty('focusOffset', 1)

await user.keyboard('c')
expect(getUIValue(element)).toBe('def')
expect(element).toHaveValue('def')

await user.keyboard('125')
expect(getUIValue(screen.getByRole('spinbutton'))).toBe('345')
expect(screen.getByRole('spinbutton')).toHaveValue(345)
await user.keyboard('g')
expect(getUIValue(element)).toBe('defg')
expect(element).toHaveValue('defg')
})

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

0 comments on commit 9816d38

Please sign in to comment.