Skip to content

Commit 227dc18

Browse files
pratikbuildsTheGreatAxios
authored andcommitted
fix(prompt-input): forward refs on the add menu and selects
PromptInputAddMenu and the model and mode selects read ref from their props, which only React 19 passes to a function component; under React 18, inside the peer range, the ref was dropped with a warning. Wrap them, and the select they share, in forwardRef so a ref reaches each part's root element on both versions.
1 parent 2863f9f commit 227dc18

2 files changed

Lines changed: 86 additions & 44 deletions

File tree

‎src/ui/prompt-input-toolbar.test.tsx‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, spyOn, test } from "bun:test";
2-
import { act, type ReactNode, useState } from "react";
2+
import { act, createRef, type ReactNode, useState } from "react";
33
import { createRoot, type Root } from "react-dom/client";
44

55
import type { PromptToken } from "../lib/prompt-tokens.js";
@@ -613,6 +613,32 @@ describe("PromptInputButton", () => {
613613
});
614614
});
615615

616+
describe("refs", () => {
617+
test("the add menu and both selects hand their root to a ref", () => {
618+
const add = createRef<HTMLDivElement>();
619+
const model = createRef<HTMLDivElement>();
620+
const mode = createRef<HTMLDivElement>();
621+
const view = mount(
622+
<>
623+
<PromptInputAddMenu ref={add} items={menuItems([])} />
624+
<PromptInputModelSelect ref={model} models={MODELS} />
625+
<PromptInputModeSelect ref={mode} modes={MODES} />
626+
</>,
627+
);
628+
expect(add.current).toBe(
629+
view.query<HTMLDivElement>("[data-slot='prompt-input-add-menu']"),
630+
);
631+
expect(model.current).toBe(
632+
view.query<HTMLDivElement>("[data-slot='prompt-input-model-select']"),
633+
);
634+
expect(mode.current).toBe(
635+
view.query<HTMLDivElement>("[data-slot='prompt-input-mode-select']"),
636+
);
637+
view.unmount();
638+
expect(add.current).toBeNull();
639+
});
640+
});
641+
616642
describe("PromptInputContext", () => {
617643
test("offers a clear button only when it can be cleared", () => {
618644
const cleared: string[] = [];

‎src/ui/prompt-input-toolbar.tsx‎

Lines changed: 59 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
"use client";
22

33
import { Check, ChevronDown, Plus, Reply, X } from "lucide-react";
4-
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
4+
import {
5+
forwardRef,
6+
useEffect,
7+
useId,
8+
useLayoutEffect,
9+
useRef,
10+
useState,
11+
} from "react";
512
import type * as React from "react";
613

714
import { useControllableState } from "../hooks/use-controllable-state.js";
@@ -234,18 +241,23 @@ export type PromptInputAddMenuProps = Omit<
234241
* elsewhere in the enclosing `PromptInput` (the textarea, beside a menu the
235242
* host opened).
236243
*/
237-
export function PromptInputAddMenu({
238-
items,
239-
disabled: disabledProp,
240-
label = "Add",
241-
align = "start",
242-
open: openProp,
243-
defaultOpen = false,
244-
onOpenChange,
245-
className,
244+
export const PromptInputAddMenu = forwardRef<
245+
HTMLDivElement,
246+
PromptInputAddMenuProps
247+
>(function PromptInputAddMenu(
248+
{
249+
items,
250+
disabled: disabledProp,
251+
label = "Add",
252+
align = "start",
253+
open: openProp,
254+
defaultOpen = false,
255+
onOpenChange,
256+
className,
257+
...props
258+
},
246259
ref,
247-
...props
248-
}: PromptInputAddMenuProps) {
260+
) {
249261
const popover = useDismissablePopover<HTMLDivElement, HTMLButtonElement>({
250262
open: openProp,
251263
defaultOpen,
@@ -406,7 +418,7 @@ export function PromptInputAddMenu({
406418
) : null}
407419
</div>
408420
);
409-
}
421+
});
410422

411423
/** An option as the shared select draws it. */
412424
type SelectOption = {
@@ -474,22 +486,24 @@ type OptionSelectProps = SelectStateProps & {
474486
* `PromptInput` (the textarea, beside a list the host opened) closes it too
475487
* and leaves focus where it is.
476488
*/
477-
function OptionSelect({
478-
slot,
479-
label,
480-
options,
481-
value: valueProp,
482-
defaultValue,
483-
onValueChange,
484-
open: openProp,
485-
defaultOpen = false,
486-
onOpenChange,
487-
disabled: disabledProp,
488-
align = "start",
489-
className,
490-
ref,
491-
...props
492-
}: OptionSelectProps) {
489+
const OptionSelect = forwardRef(function OptionSelect(
490+
{
491+
slot,
492+
label,
493+
options,
494+
value: valueProp,
495+
defaultValue,
496+
onValueChange,
497+
open: openProp,
498+
defaultOpen = false,
499+
onOpenChange,
500+
disabled: disabledProp,
501+
align = "start",
502+
className,
503+
...props
504+
}: Omit<OptionSelectProps, "ref">,
505+
ref: React.ForwardedRef<HTMLDivElement>,
506+
) {
493507
// Undefined until something is chosen: the first option then stands in,
494508
// read from the current list so options that load late still get it.
495509
const [picked, setPicked] = useControllableState<string | undefined>({
@@ -702,7 +716,7 @@ function OptionSelect({
702716
) : null}
703717
</div>
704718
);
705-
}
719+
});
706720

707721
/** A model to choose from in `PromptInputModelSelect`. */
708722
export type PromptInputModelOption = {
@@ -728,15 +742,17 @@ export type PromptInputModelSelectProps = SelectStateProps & {
728742
* reasoning level, is a separate control, so `onValueChange` stays a single
729743
* model id.
730744
*/
731-
export function PromptInputModelSelect({
732-
models,
733-
label = "Model",
734-
align = "end",
735-
...props
736-
}: PromptInputModelSelectProps) {
745+
export const PromptInputModelSelect = forwardRef<
746+
HTMLDivElement,
747+
PromptInputModelSelectProps
748+
>(function PromptInputModelSelect(
749+
{ models, label = "Model", align = "end", ...props },
750+
ref,
751+
) {
737752
return (
738753
<OptionSelect
739754
{...props}
755+
ref={ref}
740756
slot="prompt-input-model-select"
741757
label={label}
742758
align={align}
@@ -749,7 +765,7 @@ export function PromptInputModelSelect({
749765
}))}
750766
/>
751767
);
752-
}
768+
});
753769

754770
/** A way of working, offered by `PromptInputModeSelect`. */
755771
export type PromptInputModeOption = {
@@ -766,14 +782,14 @@ export type PromptInputModeSelectProps = SelectStateProps & {
766782

767783
/** How the agent works this turn. The modes' labels and details are the
768784
* host's own strings; the trigger shows the chosen mode's label. */
769-
export function PromptInputModeSelect({
770-
modes,
771-
label = "Mode",
772-
...props
773-
}: PromptInputModeSelectProps) {
785+
export const PromptInputModeSelect = forwardRef<
786+
HTMLDivElement,
787+
PromptInputModeSelectProps
788+
>(function PromptInputModeSelect({ modes, label = "Mode", ...props }, ref) {
774789
return (
775790
<OptionSelect
776791
{...props}
792+
ref={ref}
777793
slot="prompt-input-mode-select"
778794
label={label}
779795
options={modes.map((mode) => ({
@@ -784,7 +800,7 @@ export function PromptInputModeSelect({
784800
}))}
785801
/>
786802
);
787-
}
803+
});
788804

789805
export type PromptInputFloatProps = React.ComponentProps<"div">;
790806

0 commit comments

Comments
 (0)