Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(document): reduce impact of React@17 workaround #992

Merged
merged 2 commits into from
Jul 18, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/document/index.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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