diff --git a/packages/core/src/components/forms/asyncControllableInput.tsx b/packages/core/src/components/forms/asyncControllableInput.tsx index 1e04f551b88..3acc3055cb2 100644 --- a/packages/core/src/components/forms/asyncControllableInput.tsx +++ b/packages/core/src/components/forms/asyncControllableInput.tsx @@ -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, HTMLInputElement> { @@ -65,12 +65,18 @@ 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, @@ -78,6 +84,8 @@ export class AsyncControllableInput extends React.PureComponent< value: this.props.value, }; + private cancelPendingCompositionEnd: (() => void) | null = null; + public static getDerivedStateFromProps( nextProps: IAsyncControllableInputProps, nextState: IAsyncControllableInputState, @@ -134,17 +142,21 @@ export class AsyncControllableInput extends React.PureComponent< } private handleCompositionStart = (e: React.CompositionEvent) => { - 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) => { - 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); }; diff --git a/packages/core/test/forms/asyncControllableInputTests.tsx b/packages/core/test/forms/asyncControllableInputTests.tsx index 986ff6141d3..d12ca664ede 100644 --- a/packages/core/test/forms/asyncControllableInputTests.tsx +++ b/packages/core/test/forms/asyncControllableInputTests.tsx @@ -86,6 +86,27 @@ describe("", () => { assert.strictEqual(wrapper.find("input").prop("value"), "hi "); }); + it("external updates DO NOT flush with immediately ongoing compositions", async () => { + const wrapper = mount(); + 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(); const input = wrapper.find("input"); @@ -95,7 +116,11 @@ describe("", () => { 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();