1 | /* !
|
2 | * (c) Copyright 2023 Palantir Technologies Inc. All rights reserved.
|
3 | */
|
4 | import * as React from "react";
|
5 | /**
|
6 | * The amount of time (in milliseconds) which the input will wait after a compositionEnd event before
|
7 | * unlocking its state value for external updates via props. See `handleCompositionEnd` for more details.
|
8 | */
|
9 | export var ASYNC_CONTROLLABLE_VALUE_COMPOSITION_END_DELAY = 10;
|
10 | /*
|
11 | * A hook to workaround the following [React bug](https://github.com/facebook/react/issues/3926).
|
12 | * This bug is reproduced when an input receives CompositionEvents
|
13 | * (for example, through IME composition) and has its value prop updated asychronously.
|
14 | * This might happen if a component chooses to do async validation of a value
|
15 | * returned by the input's `onChange` callback.
|
16 | */
|
17 | export function useAsyncControllableValue(props) {
|
18 | var onCompositionStart = props.onCompositionStart, onCompositionEnd = props.onCompositionEnd, propValue = props.value, onChange = props.onChange;
|
19 | // The source of truth for the input value. This is not updated during IME composition.
|
20 | // It may be updated by a parent component.
|
21 | var _a = React.useState(propValue), value = _a[0], setValue = _a[1];
|
22 | // The latest input value, which updates during IME composition.
|
23 | var _b = React.useState(propValue), nextValue = _b[0], setNextValue = _b[1];
|
24 | // Whether we are in the middle of a composition event.
|
25 | var _c = React.useState(false), isComposing = _c[0], setIsComposing = _c[1];
|
26 | // Whether there is a pending update we are expecting from a parent component.
|
27 | var _d = React.useState(false), hasPendingUpdate = _d[0], setHasPendingUpdate = _d[1];
|
28 | var cancelPendingCompositionEnd = React.useRef();
|
29 | var handleCompositionStart = React.useCallback(function (event) {
|
30 | var _a;
|
31 | (_a = cancelPendingCompositionEnd.current) === null || _a === void 0 ? void 0 : _a.call(cancelPendingCompositionEnd);
|
32 | setIsComposing(true);
|
33 | onCompositionStart === null || onCompositionStart === void 0 ? void 0 : onCompositionStart(event);
|
34 | }, [onCompositionStart]);
|
35 | // creates a timeout which will set `isComposing` to false after a delay
|
36 | // returns a function which will cancel the timeout if called before it fires
|
37 | var createOnCancelPendingCompositionEnd = React.useCallback(function () {
|
38 | var timeoutId = window.setTimeout(function () { return setIsComposing(false); }, ASYNC_CONTROLLABLE_VALUE_COMPOSITION_END_DELAY);
|
39 | return function () { return window.clearTimeout(timeoutId); };
|
40 | }, []);
|
41 | var handleCompositionEnd = React.useCallback(function (event) {
|
42 | // In some non-latin languages, a keystroke can end a composition event and immediately afterwards start another.
|
43 | // This can lead to unexpected characters showing up in the text input. In order to circumvent this problem, we
|
44 | // use a timeout which creates a delay which merges the two composition events, creating a more natural and predictable UX.
|
45 | // `this.state.nextValue` will become "locked" (it cannot be overwritten by the `value` prop) until a delay (10ms) has
|
46 | // passed without a new composition event starting.
|
47 | cancelPendingCompositionEnd.current = createOnCancelPendingCompositionEnd();
|
48 | onCompositionEnd === null || onCompositionEnd === void 0 ? void 0 : onCompositionEnd(event);
|
49 | }, [createOnCancelPendingCompositionEnd, onCompositionEnd]);
|
50 | var handleChange = React.useCallback(function (event) {
|
51 | var targetValue = event.target.value;
|
52 | setNextValue(targetValue);
|
53 | onChange === null || onChange === void 0 ? void 0 : onChange(event);
|
54 | }, [onChange]);
|
55 | // don't derive anything from props if:
|
56 | // - in uncontrolled mode, OR
|
57 | // - currently composing, since we'll do that after composition ends
|
58 | var shouldDeriveFromProps = !(isComposing || propValue === undefined);
|
59 | if (shouldDeriveFromProps) {
|
60 | var userTriggeredUpdate = nextValue !== value;
|
61 | if (userTriggeredUpdate && propValue === nextValue) {
|
62 | // parent has processed and accepted our update
|
63 | setValue(propValue);
|
64 | setHasPendingUpdate(false);
|
65 | }
|
66 | else if (userTriggeredUpdate && propValue === value) {
|
67 | // we have sent the update to our parent, but it has not been processed yet. just wait.
|
68 | // DO NOT set nextValue here, since that will temporarily render a potentially stale controlled value,
|
69 | // causing the cursor to jump once the new value is accepted
|
70 | if (!hasPendingUpdate) {
|
71 | // make sure to setState only when necessary to avoid infinite loops
|
72 | setHasPendingUpdate(true);
|
73 | }
|
74 | }
|
75 | else if (userTriggeredUpdate && propValue !== value) {
|
76 | // accept controlled update overriding user action
|
77 | setValue(propValue);
|
78 | setNextValue(propValue);
|
79 | setHasPendingUpdate(false);
|
80 | }
|
81 | else if (!userTriggeredUpdate) {
|
82 | // accept controlled update, could be confirming or denying user action
|
83 | if (value !== propValue || hasPendingUpdate) {
|
84 | // make sure to setState only when necessary to avoid infinite loops
|
85 | setValue(propValue);
|
86 | setNextValue(propValue);
|
87 | setHasPendingUpdate(false);
|
88 | }
|
89 | }
|
90 | }
|
91 | return {
|
92 | onChange: handleChange,
|
93 | onCompositionEnd: handleCompositionEnd,
|
94 | onCompositionStart: handleCompositionStart,
|
95 | // render the pending value even if it is not confirmed by a parent's async controlled update
|
96 | // so that the cursor does not jump to the end of input as reported in
|
97 | // https://github.com/palantir/blueprint/issues/4298
|
98 | value: isComposing || hasPendingUpdate ? nextValue : value,
|
99 | };
|
100 | }
|
101 | //# sourceMappingURL=useAsyncControllableValue.js.map |
\ | No newline at end of file |