From 20bf6416d08188337c2ff777b3b5e113e4db5d6d Mon Sep 17 00:00:00 2001 From: Rasmus Viloma Date: Tue, 13 Oct 2026 01:00:27 +0300 Subject: [PATCH 1/2] fix(composer): don't reset the store when effects disconnect (CHAT-41) Composer.Root reset a store it owned in its effect cleanup. React runs that cleanup without a real unmount too, for StrictMode's simulated unmount in development and for an hidden and shown again, and the component's state survives both. The reset wiped the snapshot while the editor kept its doc: the placeholder drew over text set through a controlled value at mount (the controlled value matched the doc, so nothing recommitted hasContent), an open command list fell out of sync, and under attachments vanished and ask-user progress reset. The cleanup now only undoes what its effect set up. A store the composer creates goes away with it on a real unmount. Trade-off: unsent attachments of a composer that truly unmounts keep their blob URLs until the page unloads, since React gives no signal that tells a real unmount from a hidden one. This replaces the ticket's proposed re-seed on attach: with nothing wiped, there is nothing to re-seed. Co-Authored-By: Claude Opus 5.5 --- .changeset/composer-remount-state.md | 13 +++ content/docs/primitives/composer.mdx | 2 +- packages/chat/src/composer/root.tsx | 15 ++-- packages/chat/tests/composer-remount.test.tsx | 90 +++++++++++++++++++ 4 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 .changeset/composer-remount-state.md create mode 100644 packages/chat/tests/composer-remount.test.tsx diff --git a/.changeset/composer-remount-state.md b/.changeset/composer-remount-state.md new file mode 100644 index 00000000..5e61e482 --- /dev/null +++ b/.changeset/composer-remount-state.md @@ -0,0 +1,13 @@ +--- +"@intentface/chat": patch +--- + +`Composer.Root` no longer resets its store when React disconnects its effects without a real unmount. + +React runs effect cleanups for StrictMode's simulated unmount in development, and for an `` that's hidden and shown again; the component's state survives both. `Composer.Root` reset its own store in that cleanup, which wiped live state while the editor kept its text: + +- **The placeholder** drew over text set through a controlled `value` at mount. +- **An open command list** fell out of sync. +- **Under ``,** attachments vanished (their previews' blob URLs revoked) and ask-user progress reset. + +The cleanup now only undoes what its effect set up. A store the composer creates for itself goes away with it on a real unmount; an explicit `Composer.createStore()` handle behaves as before. One trade-off: unsent attachments of a composer that truly unmounts keep their blob URLs until the page unloads, since React gives no signal that tells a real unmount from a hidden one. 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..f74d4def 100644 --- a/packages/chat/src/composer/root.tsx +++ b/packages/chat/src/composer/root.tsx @@ -68,10 +68,10 @@ export const ComposerRoot = ({ // Resolved once at mount (lazy-init ref, not reactive state): an explicit // handle, or an instance this mount creates and owns. Swapping the store // prop after mount is not supported. - const { store, ownsStore } = useRefWithInit(() => { + const store = useRefWithInit(() => { const resolved = storeProp ?? createComposerStore(); resolved.listboxId = listboxId; - return { store: resolved, ownsStore: !storeProp }; + return resolved; }).current; const formRef = useRef(null); @@ -79,16 +79,19 @@ 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. The cleanup undoes exactly that and nothing more — never a + // store.reset(). React runs it without a real unmount too (StrictMode's + // simulated one, an hidden and shown again), and the component's + // state lives on: resetting the snapshot there wiped hasContent, the command + // popup, attachments and ask-user progress while the editor kept its text. + // A store this mount owns goes away with it on a real unmount. useEffect(() => { store.submitAnswersRef.current = (answers) => onSubmitRef.current?.({ kind: "answers", answers }); return () => { store.submitAnswersRef.current = null; - if (ownsStore) store.reset(); }; - }, [store, ownsStore]); + }, [store]); // Prop → store bridges. Actions and refs on the snapshot are identity-stable, // so reading them here without subscribing is safe. diff --git a/packages/chat/tests/composer-remount.test.tsx b/packages/chat/tests/composer-remount.test.tsx new file mode 100644 index 00000000..7eaa1fba --- /dev/null +++ b/packages/chat/tests/composer-remount.test.tsx @@ -0,0 +1,90 @@ +import { 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 { 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(); + }); +}); From 5fd8b819e0d4051ca4eab2d8e5fb647e791e9d5b Mon Sep 17 00:00:00 2001 From: Rasmus Viloma Date: Wed, 14 Oct 2026 01:52:14 +0300 Subject: [PATCH 2/2] fix(composer): release an owned store's attachments on a real unmount --- .changeset/composer-remount-state.md | 10 +---- packages/chat/src/composer/root.tsx | 17 ++++---- packages/chat/tests/composer-remount.test.tsx | 41 ++++++++++++++++++- 3 files changed, 48 insertions(+), 20 deletions(-) diff --git a/.changeset/composer-remount-state.md b/.changeset/composer-remount-state.md index 5e61e482..e3e58a64 100644 --- a/.changeset/composer-remount-state.md +++ b/.changeset/composer-remount-state.md @@ -2,12 +2,4 @@ "@intentface/chat": patch --- -`Composer.Root` no longer resets its store when React disconnects its effects without a real unmount. - -React runs effect cleanups for StrictMode's simulated unmount in development, and for an `` that's hidden and shown again; the component's state survives both. `Composer.Root` reset its own store in that cleanup, which wiped live state while the editor kept its text: - -- **The placeholder** drew over text set through a controlled `value` at mount. -- **An open command list** fell out of sync. -- **Under ``,** attachments vanished (their previews' blob URLs revoked) and ask-user progress reset. - -The cleanup now only undoes what its effect set up. A store the composer creates for itself goes away with it on a real unmount; an explicit `Composer.createStore()` handle behaves as before. One trade-off: unsent attachments of a composer that truly unmounts keep their blob URLs until the page unloads, since React gives no signal that tells a real unmount from a hidden one. +- [Composer] Fix `Composer.Root` losing its state under StrictMode or a hidden ``, which drew the placeholder over a controlled `value`. diff --git a/packages/chat/src/composer/root.tsx b/packages/chat/src/composer/root.tsx index f74d4def..9c7e8690 100644 --- a/packages/chat/src/composer/root.tsx +++ b/packages/chat/src/composer/root.tsx @@ -68,10 +68,10 @@ export const ComposerRoot = ({ // Resolved once at mount (lazy-init ref, not reactive state): an explicit // handle, or an instance this mount creates and owns. Swapping the store // prop after mount is not supported. - const store = useRefWithInit(() => { + const { store, ownsStore } = useRefWithInit(() => { const resolved = storeProp ?? createComposerStore(); resolved.listboxId = listboxId; - return resolved; + return { store: resolved, ownsStore: !storeProp }; }).current; const formRef = useRef(null); @@ -79,19 +79,18 @@ export const ComposerRoot = ({ const onSubmitRef = useAsRef(onSubmit); // Register this mount on the store: answers submit through this mount's - // onSubmit. The cleanup undoes exactly that and nothing more — never a - // store.reset(). React runs it without a real unmount too (StrictMode's - // simulated one, an hidden and shown again), and the component's - // state lives on: resetting the snapshot there wiped hasContent, the command - // popup, attachments and ask-user progress while the editor kept its text. - // A store this mount owns goes away with it on a real unmount. + // 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 && form && !form.isConnected) store.reset(); }; - }, [store]); + }, [store, ownsStore]); // Prop → store bridges. Actions and refs on the snapshot are identity-stable, // so reading them here without subscribing is safe. diff --git a/packages/chat/tests/composer-remount.test.tsx b/packages/chat/tests/composer-remount.test.tsx index 7eaa1fba..6ed2c7a3 100644 --- a/packages/chat/tests/composer-remount.test.tsx +++ b/packages/chat/tests/composer-remount.test.tsx @@ -1,8 +1,8 @@ -import { describe, expect, test } from "bun:test"; +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 { useComposer } from "../src/composer/store"; +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 @@ -88,3 +88,40 @@ describe("a composer hidden and shown again with ", () => { 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); + }); +});