/* * Copyright 2020 Palantir Technologies, Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { mount } from "enzyme"; import { PureComponent } from "react"; import { assert, describe, expect, it, vi } from "@blueprintjs/test-commons/vitest"; // this component is not part of the public API, but we want to test its implementation in isolation import { sleep } from "../../common/test-utils"; import { ASYNC_CONTROLLABLE_VALUE_COMPOSITION_END_DELAY } from "../../hooks/useAsyncControllableValue"; import { AsyncControllableInput } from "./asyncControllableInput"; import { AsyncControllableTextArea } from "./asyncControllableTextArea"; /* * NOTE: AsyncControllableInput & AsyncControllableTextArea are very similar, so we test them together. * The only difference for now is that the AsyncControllableInput is implemented via class component & getDerivedStateFromProps * lifecycle while the AsyncControllableTextArea is implemented via wrapping a `useAsyncControllableValue` hook. */ describe("asyncControllable tests", () => { const tests = [ { COMPOSITION_END_DELAY: AsyncControllableInput.COMPOSITION_END_DELAY, Component: AsyncControllableInput, element: "input", type: "text", }, { COMPOSITION_END_DELAY: ASYNC_CONTROLLABLE_VALUE_COMPOSITION_END_DELAY, Component: AsyncControllableTextArea, element: "textarea", type: undefined, }, ]; tests.forEach(({ Component, element, type, COMPOSITION_END_DELAY }) => describe(element, () => { describe("uncontrolled mode", () => { it(`renders a ${element}`, () => { const handleChangeSpy = vi.fn(); const wrapper = mount(); assert.strictEqual(wrapper.childAt(0).type(), element); }); it("triggers onChange", () => { const handleChangeSpy = vi.fn(); const wrapper = mount(); const input = wrapper.find(element); input.simulate("change", { target: { value: "bye" } }); expect(handleChangeSpy).toHaveBeenCalledWith( expect.objectContaining({ target: expect.objectContaining({ value: "bye" }) }), ); }); }); describe("controlled mode", () => { it(`renders a ${element}`, () => { const wrapper = mount(); assert.strictEqual(wrapper.childAt(0).type(), element); }); it("accepts controlled update 'hi' -> 'bye'", () => { const wrapper = mount(); assert.strictEqual(wrapper.find(element).prop("value"), "hi"); wrapper.setProps({ value: "bye" }); assert.strictEqual(wrapper.find(element).prop("value"), "bye"); }); it("triggers onChange events during composition", () => { const handleChangeSpy = vi.fn(); const wrapper = mount(); const input = wrapper.find(element); 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: " ." }); expect(handleChangeSpy).toHaveBeenCalledTimes(2); }); it("external updates DO NOT override in-progress composition", async () => { const wrapper = mount(); const input = wrapper.find(element); 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(element).prop("value"), "hi "); }); it("external updates DO NOT flush with immediately ongoing compositions", async () => { const wrapper = mount(); const input = wrapper.find(element); 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 sleep(COMPOSITION_END_DELAY + 5); assert.strictEqual(wrapper.find(element).prop("value"), "hi "); }); it("external updates flush after composition ends", async () => { const wrapper = mount(); const input = wrapper.find(element); 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 sleep(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(element).prop("value"), "bye"); }); it("accepts async controlled update, optimistically rendering new value while waiting for update", async () => { class TestComponent extends PureComponent<{ initialValue: string }, { value: string }> { public state = { value: this.props.initialValue }; public render() { return ; } private handleChange = async (e: React.ChangeEvent) => { const newValue = e.target.value; await sleep(10); this.setState({ value: newValue }); }; } const wrapper = mount(); assert.strictEqual(wrapper.find(element).prop("value"), "hi"); wrapper.find(element).simulate("change", { target: { value: "hi " } }); wrapper.update(); assert.strictEqual( wrapper.find(Component).prop("value"), "hi", "local state should still have initial value", ); // but rendered input should optimistically show new value assert.strictEqual( wrapper.find(element).prop("value"), "hi ", `rendered <${element}> should optimistically show new value`, ); // after async delay, confirm the update await sleep(20); assert.strictEqual( wrapper.find(element).prop("value"), "hi ", `rendered <${element}> should still show new value`, ); return; }); }); }), ); });