diff --git a/.changeset/composer-remount-state.md b/.changeset/composer-remount-state.md new file mode 100644 index 00000000..e3e58a64 --- /dev/null +++ b/.changeset/composer-remount-state.md @@ -0,0 +1,5 @@ +--- +"@intentface/chat": patch +--- + +- [Composer] Fix `Composer.Root` losing its state under StrictMode or a hidden ``, which drew the placeholder over a controlled `value`. diff --git a/content/docs/primitives/composer.mdx b/content/docs/primitives/composer.mdx index 4361f7de..4059be8a 100644 --- a/content/docs/primitives/composer.mdx +++ b/content/docs/primitives/composer.mdx @@ -222,7 +222,7 @@ export const rootProps = [ { name: "value", type: "ComposerSnapshot", description: "Controlled editor content." }, { name: "defaultValue", type: "ComposerSnapshot", description: "Uncontrolled initial editor content." }, { name: "onValueChange", type: "(snapshot: ComposerSnapshot) => void", description: "Fires on editor updates with a fresh snapshot." }, - { name: "store", type: "ComposerStore", default: "per-mount instance", description: "An explicit Composer.createStore() handle. A per-mount instance is reset on unmount; an explicit handle is not." }, + { name: "store", type: "ComposerStore", default: "per-mount instance", description: "An explicit Composer.createStore() handle, whose state outlives remounts. Without one, each mount creates its own store, which goes away with it." }, ]; diff --git a/packages/chat/src/composer/root.tsx b/packages/chat/src/composer/root.tsx index 737d01dc..9c7e8690 100644 --- a/packages/chat/src/composer/root.tsx +++ b/packages/chat/src/composer/root.tsx @@ -79,14 +79,16 @@ export const ComposerRoot = ({ const onSubmitRef = useAsRef(onSubmit); // Register this mount on the store: answers submit through this mount's - // onSubmit. Only a store this mount created gets reset on unmount — an - // explicit handle's state belongs to its owner and survives remounts. + // onSubmit. React also runs the cleanup without a real unmount (StrictMode, + // a hidden ), where the state lives on, so it only resets an owned + // store once React has removed the form — that happens on a real unmount only. useEffect(() => { + const form = formRef.current; store.submitAnswersRef.current = (answers) => onSubmitRef.current?.({ kind: "answers", answers }); return () => { store.submitAnswersRef.current = null; - if (ownsStore) store.reset(); + if (ownsStore && form && !form.isConnected) store.reset(); }; }, [store, ownsStore]); diff --git a/packages/chat/tests/composer-remount.test.tsx b/packages/chat/tests/composer-remount.test.tsx new file mode 100644 index 00000000..6ed2c7a3 --- /dev/null +++ b/packages/chat/tests/composer-remount.test.tsx @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act, render, screen } from "@testing-library/react"; +import { Activity, StrictMode, useState } from "react"; +import { Composer } from "../src/composer"; +import { type ComposerStore, useComposer } from "../src/composer/store"; + +// React runs effect cleanups without a real unmount: StrictMode's simulated +// unmount in development, and an hidden then shown again. The +// composer's state lives on through both, so nothing may be reset there +// (CHAT-41: the placeholder drew over a controlled value after one). + +const placeholder = () => document.querySelector("[data-composer-placeholder]"); + +const ControlledComposer = ({ initial }: { initial: string }) => { + const [value, setValue] = useState(initial); + return ( + <> + {}}> + + + + + + + ); +}; + +describe("a controlled value under StrictMode", () => { + test("shows the text without the placeholder", () => { + render( + + + , + ); + expect(screen.getByRole("textbox", { name: "Message" }).textContent).toBe( + "Is memoising the list enough?", + ); + expect(placeholder()).toBeNull(); + }); + + test("clearing the text brings the placeholder back", () => { + render( + + + , + ); + act(() => screen.getByRole("button", { name: "Clear" }).click()); + expect(placeholder()).not.toBeNull(); + }); +}); + +// Reads the store from inside the composer, and adds a file through it. +const AttachmentProbe = () => { + const attachments = useComposer((composer) => composer.attachments); + return ( + + ); +}; + +const HideableComposer = ({ visible }: { visible: boolean }) => ( + + {}}> + + + + + + +); + +describe("a composer hidden and shown again with ", () => { + test("keeps its text flag and its attachments", () => { + const { rerender } = render(); + act(() => screen.getByRole("button", { name: "Attach (0)" }).click()); + expect(screen.getByRole("button", { name: "Attach (1)" })).toBeTruthy(); + + rerender(); + rerender(); + + expect(screen.getByRole("button", { name: "Attach (1)" })).toBeTruthy(); + expect(placeholder()).toBeNull(); + }); +}); + +// A real unmount, unlike the cases above, abandons the attachments for good. +describe("a composer that really unmounts", () => { + const revokeObjectURL = URL.revokeObjectURL; + let revoked: string[] = []; + beforeEach(() => { + revoked = []; + URL.revokeObjectURL = (url) => void revoked.push(url); + }); + afterEach(() => { + URL.revokeObjectURL = revokeObjectURL; + }); + + const AttachingComposer = ({ store }: { store?: ComposerStore }) => ( + {}}> + + + + ); + + test("releases its unsent attachments' blob URLs", () => { + const { unmount } = render(); + act(() => screen.getByRole("button", { name: "Attach (0)" }).click()); + unmount(); + expect(revoked).toHaveLength(1); + expect(revoked[0]?.startsWith("blob:")).toBe(true); + }); + + test("leaves an explicit store's attachments to its owner", () => { + const store = Composer.createStore(); + const { unmount } = render(); + act(() => screen.getByRole("button", { name: "Attach (0)" }).click()); + unmount(); + expect(revoked).toHaveLength(0); + expect(store.getSnapshot().attachments.items).toHaveLength(1); + }); +});