diff --git a/apps/web/src/features/shared/textarea-autocomplete/index.tsx b/apps/web/src/features/shared/textarea-autocomplete/index.tsx index 97e35f1eac..c1d45eec56 100644 --- a/apps/web/src/features/shared/textarea-autocomplete/index.tsx +++ b/apps/web/src/features/shared/textarea-autocomplete/index.tsx @@ -1,9 +1,4 @@ -import React, { - forwardRef, - MutableRefObject, - useEffect, - useState -} from "react"; +import React, { forwardRef, MutableRefObject, useState } from "react"; import { useActiveAccount } from "@/core/hooks/use-active-account"; import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete"; import { lookupAccountsQueryOptions, searchPath } from "@ecency/sdk"; @@ -22,7 +17,6 @@ export const TextareaAutocomplete = forwardRef((props, const isMobile = useIsMobile(); const queryClient = useQueryClient(); - const [value, setValue] = useState(props.value); const [rows, setRows] = useState(props.minrows || 10); const [minrows, setMinrows] = useState(props.minrows || 10); const [maxrows, setMaxrows] = useState(props.maxrows || 20); @@ -35,9 +29,10 @@ export const TextareaAutocomplete = forwardRef((props, attrs.rows = isComment ? rows : rows; } - useEffect(() => { - setValue(props.value); - }, [props.value, value]); + // The textarea is fully controlled by the parent's `value` prop. Mirroring it + // into local state (and re-syncing it in an effect keyed on that local state) + // made every change ping-pong between the two and tripped React's + // "Maximum update depth exceeded" guard. const handleChange = (event: any) => { const isMobile = typeof window !== "undefined" && window.innerWidth < 570; @@ -60,7 +55,6 @@ export const TextareaAutocomplete = forwardRef((props, setRows(currentRows < maxrows ? currentRows : maxrows); } - setValue(event.target.value); props.onChange(event); }; @@ -75,7 +69,7 @@ export const TextareaAutocomplete = forwardRef((props, } }} loadingComponent={Loading} - value={value} + value={props.value ?? ""} placeholder={props.placeholder} onChange={handleChange} {...(isComment ? {} : { boundariesElement: ".body-input" })} diff --git a/apps/web/src/specs/features/shared/textarea-autocomplete-bulk-insert.spec.tsx b/apps/web/src/specs/features/shared/textarea-autocomplete-bulk-insert.spec.tsx new file mode 100644 index 0000000000..481f539808 --- /dev/null +++ b/apps/web/src/specs/features/shared/textarea-autocomplete-bulk-insert.spec.tsx @@ -0,0 +1,76 @@ +import React, { useEffect, useRef, useState } from "react"; +import { act, render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { insertOrReplace } from "@/utils/input-util"; + +// The autocomplete library is not mocked, so the component runs as shipped. +vi.mock("@/core/hooks/use-active-account", () => ({ + useActiveAccount: () => ({ activeUser: null }) +})); +vi.mock("@ecency/sdk", () => ({ lookupAccountsQueryOptions: vi.fn(), searchPath: vi.fn() })); +vi.mock("@/features/shared", () => ({ UserAvatar: () => null })); +vi.mock("@/utils", () => ({ useIsMobile: () => false })); +vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({}) })); + +import { TextareaAutocomplete } from "@/features/shared/textarea-autocomplete"; + +const placeholder = (i: number) => `![Uploading image-${i}.png]()\n\n`; + +// What the parent saw: every change it was told about and the text it holds. +const parent = { changes: 0, text: "" }; + +// A parent that owns the text, as the comment box and the post editor do, with +// a native paste listener that inserts one placeholder per pasted image the way +// the editor toolbar does. +function Editor({ images }: { images: number }) { + const [text, setText] = useState(""); + const ref = useRef(null); + parent.text = text; + + useEffect(() => { + const el = ref.current!; + const onPaste = () => { + for (let i = 0; i < images; i++) { + insertOrReplace(el as unknown as HTMLInputElement, placeholder(i)); + } + }; + el.addEventListener("paste", onPaste); + return () => el.removeEventListener("paste", onPaste); + }, [images]); + + return ( + ) => { + parent.changes += 1; + setText(e.target.value); + }} + ref={ref} + isComment={true} + minrows={3} + maxrows={100} + /> + ); +} + +describe("TextareaAutocomplete bulk insert", () => { + it("keeps every placeholder when many images are pasted at once", () => { + // React gives up after 50 nested updates. Each insert used to add roughly one, so + // the count has to be comfortably above that for the test to mean anything. + const images = 80; + parent.changes = 0; + parent.text = ""; + const { container } = render(); + const textarea = container.querySelector("textarea")!; + + act(() => { + textarea.dispatchEvent(new Event("paste", { bubbles: true })); + }); + + const expected = Array.from({ length: images }, (_, i) => placeholder(i)).join(""); + expect(parent.changes).toBe(images); + expect(parent.text).toBe(expected); + expect(textarea.value).toBe(expected); + }); +});