From 6a3d73755d721b5f1fbdb28529540e376c895a0c Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Mon, 2 Nov 2026 23:02:39 +0545 Subject: [PATCH] refactor: drop React 18 workarounds PanelPane passes `inert={!shown}` directly instead of a string `inert` that React 18 kept. ConversationItem relies on React 19 running the cleanup its ref callback returns, without the extra release ref for React 18's null-ref detach. Remove the test of that null-ref path and the test comments that justify forwardRef checks by React 18. Depends on #149, which narrows the peer range to React 19. --- src/ui/attachments.test.tsx | 4 +--- src/ui/conversation.test.tsx | 43 ------------------------------------ src/ui/conversation.tsx | 9 +------- src/ui/message.test.tsx | 2 -- src/ui/panel.tsx | 8 +------ src/ui/prompt-input.test.tsx | 2 -- 6 files changed, 3 insertions(+), 65 deletions(-) diff --git a/src/ui/attachments.test.tsx b/src/ui/attachments.test.tsx index 3a06d546..4ee76dce 100644 --- a/src/ui/attachments.test.tsx +++ b/src/ui/attachments.test.tsx @@ -134,9 +134,7 @@ describe("Attachments", () => { unmount(); }); - // React 18 hands a function component no `ref` unless it is wrapped in - // `forwardRef`; React 19, which these tests run on, hands it either way. - test("the list, an attachment and its preview take refs on React 18 too", () => { + test("the list, an attachment and its preview take refs", () => { for (const part of [Attachments, Attachment, AttachmentPreview]) { expect(part.$$typeof).toBe(Symbol.for("react.forward_ref")); } diff --git a/src/ui/conversation.test.tsx b/src/ui/conversation.test.tsx index acb1aa0a..ccc0f24b 100644 --- a/src/ui/conversation.test.tsx +++ b/src/ui/conversation.test.tsx @@ -308,47 +308,6 @@ describe("ConversationItem registry", () => { unsubscribe(); mounted.unmount(); }); - - test("a null ref drops the item, which is how React 18 detaches", async () => { - let api: ConversationContextValue | null = null; - function Probe() { - api = useConversation(); - return null; - } - const mounted = render( - - - a - - - , - ); - const element = - mounted.container.querySelector("[data-item-id='a']"); - const fiberKey = Object.keys(element ?? {}).find((key) => - key.startsWith("__reactFiber$"), - ); - const fiber = - element === null || fiberKey === undefined - ? undefined - : (element as HTMLElement & Record)[ - fiberKey - ]; - const ref = fiber?.ref; - if (typeof ref !== "function") throw new Error("expected the item ref"); - const items = () => { - if (api === null) throw new Error("expected the conversation API"); - return api.items; - }; - - act(() => { - ref(null); - }); - await act(() => Promise.resolve()); - expect(items().get("a")).toBeUndefined(); - expect(items().ids()).toEqual([]); - mounted.unmount(); - }); }); describe("ConversationScrollButton", () => { @@ -455,8 +414,6 @@ describe("ConversationContent", () => { }); test("the log, an item and the scroll button forward a host ref", async () => { - // React 18 drops `ref` from a function component's props: only a - // forwardRef component receives it there. for (const part of [ ConversationContent, ConversationItem, diff --git a/src/ui/conversation.tsx b/src/ui/conversation.tsx index a5db319d..e75bbee0 100644 --- a/src/ui/conversation.tsx +++ b/src/ui/conversation.tsx @@ -966,17 +966,10 @@ export const ConversationItem = forwardRef< ConversationItemProps >(function ConversationItem({ itemId, ...props }, ref) { const engine = useEngine("ConversationItem"); - // React 19 runs the cleanup `register` returns. React 18 ignores that - // return and detaches by calling the ref with null, so release there too. - const release = useRef<(() => void) | null>(null); const register = useCallback( (element: HTMLDivElement | null) => { - release.current?.(); - release.current = null; if (element === null) return; - const cleanup = engine.registry.register(itemId, element); - release.current = cleanup; - return cleanup; + return engine.registry.register(itemId, element); }, [engine, itemId], ); diff --git a/src/ui/message.test.tsx b/src/ui/message.test.tsx index 8e0a2026..c78f24e7 100644 --- a/src/ui/message.test.tsx +++ b/src/ui/message.test.tsx @@ -554,8 +554,6 @@ describe("MessageActions", () => { }); test("the overflow menu mounts its items only while open", () => { - // React 18 drops `ref` from a function component's props: only a - // forwardRef component receives it there. expect(MessageAction.$$typeof).toBe(Symbol.for("react.forward_ref")); const onPin = mock(() => {}); const mounted = render( diff --git a/src/ui/panel.tsx b/src/ui/panel.tsx index fe6c460f..7f138075 100644 --- a/src/ui/panel.tsx +++ b/src/ui/panel.tsx @@ -27,12 +27,6 @@ const isProduction = const DEFAULT_MIN_WIDTH_PX = 320; const DEFAULT_MAIN_MIN_WIDTH_PX = 440; -// React 18 does not know `inert` and drops a boolean for it, while React 19 -// reads it as a boolean. A non-empty string other than "true" or "false" -// reaches the DOM on both without a warning; the record type is wider than -// React 19's boolean-only `inert` to allow it. -const HIDDEN_PANE_PROPS: Record = { inert: "inert" }; - type PanelContextValue = { readonly panelId: string; readonly handleProps: ResizableHandleProps; @@ -420,7 +414,7 @@ export function PanelPane({ value, className, ...props }: PanelPaneProps) {
{ }); test("the textarea forwards a host ref", () => { - // React 18 drops `ref` from a function component's props: only a - // forwardRef component receives it there. expect(PromptInputTextarea.$$typeof).toBe(Symbol.for("react.forward_ref")); const ref = createRef(); const container = document.createElement("div");