Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/composer-remount-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@intentface/chat": patch
---

- [Composer] Fix `Composer.Root` losing its state under StrictMode or a hidden `<Activity>`, which drew the placeholder over a controlled `value`.
2 changes: 1 addition & 1 deletion content/docs/primitives/composer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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." },
];

<PropsTable rows={rootProps} />
Expand Down
8 changes: 5 additions & 3 deletions packages/chat/src/composer/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Activity>), 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]);
Comment thread
rpvilo marked this conversation as resolved.

Expand Down
127 changes: 127 additions & 0 deletions packages/chat/tests/composer-remount.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <Activity> 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 (
<>
<Composer.Root onSubmit={() => {}}>
<Composer.Textarea aria-label="Message" value={value} onValueChange={setValue}>
<Composer.Placeholder placeholder="Ask a follow-up…" />
</Composer.Textarea>
</Composer.Root>
<button type="button" onClick={() => setValue("")}>
Clear
</button>
</>
);
};

describe("a controlled value under StrictMode", () => {
test("shows the text without the placeholder", () => {
render(
<StrictMode>
<ControlledComposer initial="Is memoising the list enough?" />
</StrictMode>,
);
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(
<StrictMode>
<ControlledComposer initial="Is memoising the list enough?" />
</StrictMode>,
);
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 (
<button
type="button"
onClick={() => attachments.add([new File(["x"], "notes.txt", { type: "text/plain" })])}
>
{`Attach (${attachments.items.length})`}
</button>
);
};

const HideableComposer = ({ visible }: { visible: boolean }) => (
<Activity mode={visible ? "visible" : "hidden"}>
<Composer.Root onSubmit={() => {}}>
<Composer.Textarea aria-label="Message" value="A draft">
<Composer.Placeholder placeholder="Ask anything" />
</Composer.Textarea>
<AttachmentProbe />
</Composer.Root>
</Activity>
);

describe("a composer hidden and shown again with <Activity>", () => {
test("keeps its text flag and its attachments", () => {
const { rerender } = render(<HideableComposer visible />);
act(() => screen.getByRole("button", { name: "Attach (0)" }).click());
expect(screen.getByRole("button", { name: "Attach (1)" })).toBeTruthy();

rerender(<HideableComposer visible={false} />);
rerender(<HideableComposer visible />);

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 }) => (
<Composer.Root store={store} onSubmit={() => {}}>
<Composer.Textarea aria-label="Message" />
<AttachmentProbe />
</Composer.Root>
);

test("releases its unsent attachments' blob URLs", () => {
const { unmount } = render(<AttachingComposer />);
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(<AttachingComposer store={store} />);
act(() => screen.getByRole("button", { name: "Attach (0)" }).click());
unmount();
expect(revoked).toHaveLength(0);
expect(store.getSnapshot().attachments.items).toHaveLength(1);
});
});
Loading