diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 120b36776089..3cc1649c57d0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -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); diff --git a/apps/web/src/components/chat/threadContextDrag.test.tsx b/apps/web/src/components/chat/threadContextDrag.test.tsx new file mode 100644 index 000000000000..1ad421d030ef --- /dev/null +++ b/apps/web/src/components/chat/threadContextDrag.test.tsx @@ -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>; +}) { + const targetRef = useRef(null); + useThreadContextDrop(targetRef, ENVIRONMENT_ID, draftKey, (refs, isActive) => { + if (!isActive()) return; + drafts.get(draftKey)?.push(...refs.map((ref) => ref.threadId)); + }); + return
; +}); + +describe("useThreadContextDrop", () => { + it("drops into the last rendered thread's draft when the memoized target is reused", async () => { + const drafts = new Map>([ + ["thread-a", []], + ["thread-b", []], + ]); + + await act(() => root.render()); + await act(() => root.render()); + + 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([]); + }); +}); diff --git a/apps/web/src/components/chat/threadContextDrag.ts b/apps/web/src/components/chat/threadContextDrag.ts index 040e5a920e25..69e474151148 100644 --- a/apps/web/src/components/chat/threadContextDrag.ts +++ b/apps/web/src/components/chat/threadContextDrag.ts @@ -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"; @@ -9,7 +11,10 @@ export function useThreadContextDrop( targetKey: string, onDrop: (refs: ReadonlyArray, 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; @@ -26,6 +31,7 @@ export function useThreadContextDrop( environmentId, targetKey, targetRef, + handleDrop, ]); }