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
7 changes: 5 additions & 2 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5763,8 +5763,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
},
});

const attachDroppedThreadFile = useEffectEvent((file: File) => addComposerAttachments([file]));
const importDroppedThread = useEffectEvent(
// Stable callbacks, not useEffectEvent: ChatComposer is memoized, and React
// 19.2 never refreshes an effect event's impl on memo fibers — stale closures
// here would import the dropped thread into the first mounted thread's draft.
const attachDroppedThreadFile = useStableCallback((file: File) => addComposerAttachments([file]));
const importDroppedThread = useStableCallback(
async (ref: ScopedThreadRef, isActive: () => boolean) => {
const targetKey = attachmentTargetKey;
pendingDraftWork.begin(targetKey);
Expand Down
68 changes: 68 additions & 0 deletions apps/web/src/components/chat/threadContextDrag.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// @vitest-environment jsdom

import { act, memo, useRef } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts";

import { THREAD_CONTEXT_DROP_EVENT, useThreadContextDrop } from "./threadContextDrag";

const ENVIRONMENT_ID = "environment-local" as EnvironmentId;

let root: Root;
let container: HTMLDivElement;

beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});

afterEach(async () => {
await act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});

// ChatComposer is memoized and reused across thread navigation; each render's
// drop callback must write into the draft of the thread it last rendered.
const ComposerDropTarget = memo(function ComposerDropTarget({
draftKey,
drafts,
}: {
draftKey: string;
drafts: Map<string, Array<ThreadId>>;
}) {
const targetRef = useRef<HTMLDivElement | null>(null);
useThreadContextDrop(targetRef, ENVIRONMENT_ID, draftKey, (refs, isActive) => {
if (!isActive()) return;
drafts.get(draftKey)?.push(...refs.map((ref) => ref.threadId));
});
return <div data-drop-target ref={targetRef} />;
});

describe("useThreadContextDrop", () => {
it("drops into the last rendered thread's draft when the memoized target is reused", async () => {
const drafts = new Map<string, Array<ThreadId>>([
["thread-a", []],
["thread-b", []],
]);

await act(() => root.render(<ComposerDropTarget draftKey="thread-a" drafts={drafts} />));
await act(() => root.render(<ComposerDropTarget draftKey="thread-b" drafts={drafts} />));

const dropped: ScopedThreadRef = {
environmentId: ENVIRONMENT_ID,
threadId: "thread-dropped" as ThreadId,
};
const target = container.querySelector("[data-drop-target]");
expect(target).not.toBeNull();
act(() => {
target!.dispatchEvent(new CustomEvent(THREAD_CONTEXT_DROP_EVENT, { detail: [dropped] }));
});

expect(drafts.get("thread-b")).toEqual(["thread-dropped"]);
expect(drafts.get("thread-a")).toEqual([]);
});
});
10 changes: 8 additions & 2 deletions apps/web/src/components/chat/threadContextDrag.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts";
import { type RefObject, useEffect, useEffectEvent, useSyncExternalStore } from "react";
import { type RefObject, useEffect, useSyncExternalStore } from "react";

import { useStableCallback } from "../../hooks/useStableCallback";

export const THREAD_CONTEXT_DROP_EVENT = "t3-thread-context-drop";

Expand All @@ -9,7 +11,10 @@ export function useThreadContextDrop(
targetKey: string,
onDrop: (refs: ReadonlyArray<ScopedThreadRef>, isActive: () => boolean) => void,
) {
const handleDrop = useEffectEvent(onDrop);
// useStableCallback, not useEffectEvent: callers are memoized (ChatComposer),
// and React 19.2 never refreshes an effect event's impl on memo fibers, which
// would leave this drop writing into the first mounted thread's draft.
const handleDrop = useStableCallback(onDrop);
useEffect(() => {
const target = targetRef.current;
if (!target) return;
Expand All @@ -26,6 +31,7 @@ export function useThreadContextDrop(
environmentId,
targetKey,
targetRef,
handleDrop,
]);
}

Expand Down
Loading