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

[core] fix(AsyncControllableInput): handle compound composition events #5165

Merged
merged 3 commits into from
Mar 16, 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
30 changes: 21 additions & 9 deletions packages/core/src/components/forms/asyncControllableInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import * as React from "react";
import { polyfill } from "react-lifecycles-compat";

import { DISPLAYNAME_PREFIX } from "../../common/props";
import { AbstractPureComponent2, DISPLAYNAME_PREFIX } from "../../common";

export interface IAsyncControllableInputProps
extends React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> {
Expand Down Expand Up @@ -65,19 +65,27 @@ export interface IAsyncControllableInputState {
* Note: this component does not apply any Blueprint-specific styling.
*/
@polyfill
export class AsyncControllableInput extends React.PureComponent<
export class AsyncControllableInput extends AbstractPureComponent2<
IAsyncControllableInputProps,
IAsyncControllableInputState
> {
public static displayName = `${DISPLAYNAME_PREFIX}.AsyncControllableInput`;

/**
* The amount of time (in milliseconds) which the input will wait after a compositionEnd event before
* unlocking its state value for external updates via props. See `handleCompositionEnd` for more details.
*/
public static COMPOSITION_END_DELAY = 10;

public state: IAsyncControllableInputState = {
hasPendingUpdate: false,
isComposing: false,
nextValue: this.props.value,
value: this.props.value,
};

private cancelPendingCompositionEnd: (() => void) | null = null;

public static getDerivedStateFromProps(
nextProps: IAsyncControllableInputProps,
nextState: IAsyncControllableInputState,
Expand Down Expand Up @@ -134,17 +142,21 @@ export class AsyncControllableInput extends React.PureComponent<
}

private handleCompositionStart = (e: React.CompositionEvent<HTMLInputElement>) => {
this.setState({
isComposing: true,
// Make sure that localValue matches externalValue, in case externalValue
// has changed since the last onChange event.
nextValue: this.state.value,
});
this.cancelPendingCompositionEnd?.();
this.setState({ isComposing: true });
this.props.onCompositionStart?.(e);
};

private handleCompositionEnd = (e: React.CompositionEvent<HTMLInputElement>) => {
this.setState({ isComposing: false });
// In some non-latin languages, a keystroke can end a composition event and immediately afterwards start another.
// This can lead to unexpected characters showing up in the text input. In order to circumvent this problem, we
// use a timeout which creates a delay which merges the two composition events, creating a more natural and predictable UX.
// `this.state.nextValue` will become "locked" (it cannot be overwritten by the `value` prop) until a delay (10ms) has
// passed without a new composition event starting.
this.cancelPendingCompositionEnd = this.setTimeout(
() => this.setState({ isComposing: false }),
AsyncControllableInput.COMPOSITION_END_DELAY,
);
this.props.onCompositionEnd?.(e);
};

Expand Down
27 changes: 26 additions & 1 deletion packages/core/test/forms/asyncControllableInputTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,27 @@ describe("<AsyncControllableInput>", () => {
assert.strictEqual(wrapper.find("input").prop("value"), "hi ");
});

it("external updates DO NOT flush with immediately ongoing compositions", async () => {
const wrapper = mount(<AsyncControllableInput type="text" value="hi" />);
const input = wrapper.find("input");

input.simulate("compositionstart", { data: "" });
input.simulate("compositionupdate", { data: " " });
input.simulate("change", { target: { value: "hi " } });

wrapper.setProps({ value: "bye" }).update();

input.simulate("compositionend", { data: " " });
input.simulate("compositionstart", { data: "" });

// Wait for the composition ending delay to pass
await new Promise(resolve =>
setTimeout(() => resolve(null), AsyncControllableInput.COMPOSITION_END_DELAY + 5),
);

assert.strictEqual(wrapper.find("input").prop("value"), "hi ");
});

it("external updates flush after composition ends", async () => {
const wrapper = mount(<AsyncControllableInput type="text" value="hi" />);
const input = wrapper.find("input");
Expand All @@ -95,7 +116,11 @@ describe("<AsyncControllableInput>", () => {
input.simulate("change", { target: { value: "hi " } });
input.simulate("compositionend", { data: " " });

await Promise.resolve();
// Wait for the composition ending delay to pass
await new Promise(resolve =>
setTimeout(() => resolve(null), AsyncControllableInput.COMPOSITION_END_DELAY + 5),
);

// we are "rejecting" the composition here by supplying a different controlled value
wrapper.setProps({ value: "bye" }).update();

Expand Down