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] add AsyncControllableTextArea based on AsyncControllableInput & add async control option for TextArea component #6312

Merged
merged 13 commits into from
Aug 23, 2023
43 changes: 43 additions & 0 deletions packages/core/src/components/forms/asyncControllableTextArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/* !
* (c) Copyright 2023 Palantir Technologies Inc. All rights reserved.
*/

import * as React from "react";

import { useAsyncControllableValue } from "./useAsyncControllableValue";

export type IAsyncControllableTextAreaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;

/**
* A wrapper around the low-level <textarea> component which works around a React bug
* the same way <AsyncControllableInput> does.
*/
export const AsyncControllableTextArea = React.forwardRef<HTMLTextAreaElement, IAsyncControllableTextAreaProps>(
function _AsyncControllableTextArea(props, ref) {
const {
value: parentValue,
onChange: parentOnChange,
onCompositionStart: parentOnCompositionStart,
onCompositionEnd: parentOnCompositionEnd,
...restProps
} = props;

const { value, onChange, onCompositionStart, onCompositionEnd } = useAsyncControllableValue({
onChange: parentOnChange,
onCompositionEnd: parentOnCompositionEnd,
onCompositionStart: parentOnCompositionStart,
value: parentValue,
});

return (
<textarea
{...restProps}
value={value}
onChange={onChange}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
ref={ref}
/>
);
},
);
32 changes: 27 additions & 5 deletions packages/core/src/components/forms/textArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,18 @@ import * as React from "react";

import { AbstractPureComponent, Classes, refHandler, setRef } from "../../common";
import { DISPLAYNAME_PREFIX, IntentProps, Props } from "../../common/props";
import { AsyncControllableTextArea } from "./asyncControllableTextArea";

export interface TextAreaProps extends IntentProps, Props, React.TextareaHTMLAttributes<HTMLTextAreaElement> {
/**
* Set this to `true` if you will be controlling the `value` of this input with asynchronous updates.
* These may occur if you do not immediately call setState in a parent component with the value from
* the `onChange` handler, or if working with certain libraries like __redux-form__.
*
* @default false
*/
asyncControl?: boolean;

/**
* Whether the component should automatically resize vertically as a user types in the text input.
* This will disable manual resizing in the vertical dimension.
Expand Down Expand Up @@ -142,9 +152,19 @@ export class TextArea extends AbstractPureComponent<TextAreaProps, TextAreaState
}

public render() {
// eslint-disable-next-line deprecation/deprecation
const { autoResize, className, fill, growVertically, inputRef, intent, large, small, ...htmlProps } =
this.props;
const {
asyncControl,
autoResize,
className,
fill,
// eslint-disable-next-line deprecation/deprecation
growVertically,
inputRef,
intent,
large,
small,
...htmlProps
} = this.props;

const rootClasses = classNames(
Classes.INPUT,
Expand All @@ -170,13 +190,15 @@ export class TextArea extends AbstractPureComponent<TextAreaProps, TextAreaState
};
}

const TextAreaComponent = asyncControl ? AsyncControllableTextArea : "textarea";

return (
<textarea
<TextAreaComponent
{...htmlProps}
className={rootClasses}
onChange={this.handleChange}
ref={this.handleRef}
style={style}
ref={this.handleRef}
/>
);
}
Expand Down
133 changes: 133 additions & 0 deletions packages/core/src/components/forms/useAsyncControllableValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/* !
* (c) Copyright 2023 Palantir Technologies Inc. All rights reserved.
*/

import * as React from "react";

interface IUseAsyncControllableValueProps<E extends HTMLInputElement | HTMLTextAreaElement> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: same comment about avoiding the I prefix

value?: React.InputHTMLAttributes<E>["value"];
onChange?: React.ChangeEventHandler<E>;
onCompositionStart?: React.CompositionEventHandler<E>;
onCompositionEnd?: React.CompositionEventHandler<E>;
}

/**
* 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.
*/
export const ASYNC_CONTROLLABLE_VALUE_COMPOSITION_END_DELAY = 10;

/*
* A hook to workaround the following [React bug](https://github.com/facebook/react/issues/3926).
* This bug is reproduced when an input receives CompositionEvents
* (for example, through IME composition) and has its value prop updated asychronously.
* This might happen if a component chooses to do async validation of a value
* returned by the input's `onChange` callback.
*/
export function useAsyncControllableValue<E extends HTMLInputElement | HTMLTextAreaElement>(
props: IUseAsyncControllableValueProps<E>,
) {
const { onCompositionStart, onCompositionEnd, value: propValue, onChange } = props;

// The source of truth for the input value. This is not updated during IME composition.
// It may be updated by a parent component.
const [value, setValue] = React.useState(propValue);

// The latest input value, which updates during IME composition.
const [nextValue, setNextValue] = React.useState(propValue);

// Whether we are in the middle of a composition event.
const [isComposing, setIsComposing] = React.useState(false);

// Whether there is a pending update we are expecting from a parent component.
const [hasPendingUpdate, setHasPendingUpdate] = React.useState(false);

const cancelPendingCompositionEnd = React.useRef<() => void>();

const handleCompositionStart: React.CompositionEventHandler<E> = React.useCallback(
event => {
cancelPendingCompositionEnd.current?.();
setIsComposing(true);
onCompositionStart?.(event);
},
[onCompositionStart],
);

// creates a timeout which will set `isComposing` to false after a delay
// returns a function which will cancel the timeout if called before it fires
const createOnCancelPendingCompositionEnd = React.useCallback(() => {
const timeoutId = window.setTimeout(
() => setIsComposing(false),
ASYNC_CONTROLLABLE_VALUE_COMPOSITION_END_DELAY,
);
return () => window.clearTimeout(timeoutId);
}, []);

const handleCompositionEnd: React.CompositionEventHandler<E> = React.useCallback(
event => {
// 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.
cancelPendingCompositionEnd.current = createOnCancelPendingCompositionEnd();
onCompositionEnd?.(event);
},
[createOnCancelPendingCompositionEnd, onCompositionEnd],
);

const handleChange: React.ChangeEventHandler<E> = React.useCallback(
event => {
const { value: targetValue } = event.target;
setNextValue(targetValue);
onChange?.(event);
},
[onChange],
);

// don't derive anything from props if:
// - in uncontrolled mode, OR
// - currently composing, since we'll do that after composition ends
const shouldDeriveFromProps = !(isComposing || propValue === undefined);

if (shouldDeriveFromProps) {
const userTriggeredUpdate = nextValue !== value;

if (userTriggeredUpdate && propValue === nextValue) {
// parent has processed and accepted our update
setValue(propValue);
setHasPendingUpdate(false);
} else if (userTriggeredUpdate && propValue === value) {
// we have sent the update to our parent, but it has not been processed yet. just wait.
// DO NOT set nextValue here, since that will temporarily render a potentially stale controlled value,
// causing the cursor to jump once the new value is accepted
if (!hasPendingUpdate) {
// make sure to setState only when necessary to avoid infinite loops
setHasPendingUpdate(true);
}
} else if (userTriggeredUpdate && propValue !== value) {
// accept controlled update overriding user action
setValue(propValue);
setNextValue(propValue);
setHasPendingUpdate(false);
} else if (!userTriggeredUpdate) {
// accept controlled update, could be confirming or denying user action
if (value !== propValue || hasPendingUpdate) {
// make sure to setState only when necessary to avoid infinite loops
setValue(propValue);
setNextValue(propValue);
setHasPendingUpdate(false);
}
}
}

return {
onChange: handleChange,
onCompositionEnd: handleCompositionEnd,
onCompositionStart: handleCompositionStart,
// render the pending value even if it is not confirmed by a parent's async controlled update
// so that the cursor does not jump to the end of input as reported in
// https://github.com/palantir/blueprint/issues/4298
value: isComposing || hasPendingUpdate ? nextValue : value,
};
}
148 changes: 148 additions & 0 deletions packages/core/test/forms/asyncControllableInputTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { spy } from "sinon";

// this component is not part of the public API, but we want to test its implementation in isolation
import { AsyncControllableInput } from "../../src/components/forms/asyncControllableInput";
import { AsyncControllableTextArea } from "../../src/components/forms/asyncControllableTextArea";
import { ASYNC_CONTROLLABLE_VALUE_COMPOSITION_END_DELAY } from "../../src/components/forms/useAsyncControllableValue";
import { sleep } from "../utils";

describe("<AsyncControllableInput>", () => {
Expand Down Expand Up @@ -170,3 +172,149 @@ describe("<AsyncControllableInput>", () => {
});
});
});

describe("<AsyncControllableTextArea>", () => {
describe("uncontrolled mode", () => {
it("renders a textarea", () => {
const handleChangeSpy = spy();
const wrapper = mount(<AsyncControllableTextArea defaultValue="hi" onChange={handleChangeSpy} />);
assert.strictEqual(wrapper.childAt(0).type(), "textarea");
});

it("triggers onChange", () => {
const handleChangeSpy = spy();
const wrapper = mount(<AsyncControllableTextArea defaultValue="hi" onChange={handleChangeSpy} />);
const input = wrapper.find("textarea");
input.simulate("change", { target: { value: "bye" } });
const simulatedEvent: React.ChangeEvent<HTMLTextAreaElement> = handleChangeSpy.getCall(0).lastArg;
assert.strictEqual(simulatedEvent.target.value, "bye");
});
});

describe("controlled mode", () => {
it("renders a textarea", () => {
const wrapper = mount(<AsyncControllableTextArea value="hi" />);
assert.strictEqual(wrapper.childAt(0).type(), "textarea");
});

it("accepts controlled update 'hi' -> 'bye'", () => {
const wrapper = mount(<AsyncControllableTextArea value="hi" />);
assert.strictEqual(wrapper.find("textarea").prop("value"), "hi");
wrapper.setProps({ value: "bye" });
assert.strictEqual(wrapper.find("textarea").prop("value"), "bye");
});

it("triggers onChange events during composition", () => {
const handleChangeSpy = spy();
const wrapper = mount(<AsyncControllableTextArea value="hi" onChange={handleChangeSpy} />);
const input = wrapper.find("textarea");

input.simulate("compositionstart", { data: "" });
input.simulate("compositionupdate", { data: " " });
// some browsers trigger this change event during composition, so we test to ensure that our wrapper component does too
input.simulate("change", { target: { value: "hi " } });
input.simulate("compositionupdate", { data: " ." });
input.simulate("change", { target: { value: "hi ." } });
input.simulate("compositionend", { data: " ." });

assert.strictEqual(handleChangeSpy.callCount, 2);
});

it("external updates DO NOT override in-progress composition", async () => {
const wrapper = mount(<AsyncControllableTextArea value="hi" />);
const input = wrapper.find("textarea");

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

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

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

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

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), ASYNC_CONTROLLABLE_VALUE_COMPOSITION_END_DELAY + 5),
);

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

it("external updates flush after composition ends", async () => {
const wrapper = mount(<AsyncControllableTextArea value="hi" />);
const input = wrapper.find("textarea");

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

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

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

assert.strictEqual(wrapper.find("textarea").prop("value"), "bye");
});

it("accepts async controlled update, optimistically rendering new value while waiting for update", async () => {
class TestComponent extends React.PureComponent<{ initialValue: string }, { value: string }> {
public state = { value: this.props.initialValue };

public render() {
return <AsyncControllableTextArea value={this.state.value} onChange={this.handleChange} />;
}

private handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
window.setTimeout(() => this.setState({ value: newValue }), 10);
};
}

const wrapper = mount(<TestComponent initialValue="hi" />);
assert.strictEqual(wrapper.find("textarea").prop("value"), "hi");

wrapper.find("textarea").simulate("change", { target: { value: "hi " } });
wrapper.update();

assert.strictEqual(
wrapper.find(AsyncControllableTextArea).prop("value"),
"hi",
"local state should still have initial value",
);
// but rendered input should optimistically show new value
assert.strictEqual(
wrapper.find("textarea").prop("value"),
"hi ",
"rendered <input> should optimistically show new value",
);

// after async delay, confirm the update
await sleep(20);
assert.strictEqual(
wrapper.find("textarea").prop("value"),
"hi ",
"rendered <textarea> should still show new value",
);
return;
});
});
});