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
119 changes: 38 additions & 81 deletions src/components/ui/message-scroller-primitive/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ import type {
MessageScrollerProviderProps,
MessageScrollerViewportProps,
} from "./types";
import { useMessageScrollerController } from "./use-message-scroller-controller";
import { composeRefs, useLatest } from "./utils";
import { useMessageScroller } from "./use-message-scroller";

const MessageScrollerContext = React.createContext<MessageScrollerContextValue | null>(null);

Expand All @@ -25,44 +24,31 @@ function useMessageScrollerContext() {
return context;
}

function applyRef<T>(ref: React.Ref<T> | undefined, value: T | null) {
if (typeof ref === "function") {
ref(value);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a consumer passes a React 19 callback ref with a cleanup return to MessageScrollerViewport or MessageScrollerContent, applyRef discards the return value. Because the wrapper refs are the refs React sees, React never runs that cleanup on unmount or ref replacement; preserve and compose the returned cleanup while clearing the scroller element.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/components/ui/message-scroller-primitive/components.tsx, line 29:

<comment>When a consumer passes a React 19 callback ref with a cleanup return to `MessageScrollerViewport` or `MessageScrollerContent`, `applyRef` discards the return value. Because the wrapper refs are the refs React sees, React never runs that cleanup on unmount or ref replacement; preserve and compose the returned cleanup while clearing the scroller element.</comment>

<file context>
@@ -25,44 +24,31 @@ function useMessageScrollerContext() {
 
+function applyRef<T>(ref: React.Ref<T> | undefined, value: T | null) {
+	if (typeof ref === "function") {
+		ref(value);
+	} else if (ref) {
+		ref.current = value;
</file context>

} else if (ref) {
ref.current = value;
}
}

function MessageScrollerProvider({
appendedAnchorScrollBehavior,
autoScroll = false,
children,
defaultScrollPosition = "end",
scrollEdgeThreshold,
scrollPreviousItemPeek,
scrollMargin,
}: MessageScrollerProviderProps) {
const { context } = useMessageScrollerController({
const context = useMessageScroller({
appendedAnchorScrollBehavior,
autoScroll,
defaultScrollPosition,
scrollEdgeThreshold,
scrollPreviousItemPeek,
scrollMargin,
});

return (
<MessageScrollerContext.Provider value={context}>{children}</MessageScrollerContext.Provider>
);
}

function MessageScroller({ children, ref, ...props }: MessageScrollerProps) {
const { setRootElement } = useMessageScrollerContext();
const setRootRef = React.useCallback(
(element: HTMLDivElement | null) => {
setRootElement(element);
composeRefs(ref)?.(element);
},
[ref, setRootElement],
);

return (
<div {...props} ref={setRootRef}>
{children}
</div>
);
function MessageScroller(props: MessageScrollerProps) {
return <div {...props} />;
}

function MessageScrollerViewport({
Expand All @@ -73,30 +59,18 @@ function MessageScrollerViewport({
onScroll,
onTouchMove,
onWheel,
preserveScrollOnPrepend = true,
ref,
role,
tabIndex,
...props
}: MessageScrollerViewportProps) {
const {
handleResize,
preserveScrollOnPrependRef,
setViewportElement,
syncAfterScroll,
userScrollIntent,
viewportRef,
} = useMessageScrollerContext();
const { handleResize, setViewportElement, syncAfterScroll, userScrollIntent, viewportRef } =
useMessageScrollerContext();
const pointerScrollIntentRef = React.useRef(false);

React.useLayoutEffect(() => {
preserveScrollOnPrependRef.current = preserveScrollOnPrepend;
}, [preserveScrollOnPrepend, preserveScrollOnPrependRef]);

const setViewportRef = React.useCallback(
(element: HTMLDivElement | null) => {
setViewportElement(element);
composeRefs(ref)?.(element);
applyRef(ref, element);
},
[ref, setViewportElement],
);
Expand Down Expand Up @@ -181,12 +155,11 @@ function MessageScrollerContent({
const { handleContentChange, handleResize, setContentElement, setSpacerElement } =
useMessageScrollerContext();
const contentRef = React.useRef<HTMLDivElement | null>(null);

const setContentRef = React.useCallback(
(element: HTMLDivElement | null) => {
contentRef.current = element;
setContentElement(element);
composeRefs(ref)?.(element);
applyRef(ref, element);
},
[ref, setContentElement],
);
Expand All @@ -204,9 +177,7 @@ function MessageScrollerContent({
return;
}

const observer = new MutationObserver(() => {
handleContentChange();
});
const observer = new MutationObserver(handleContentChange);

observer.observe(content, { childList: true });

Expand Down Expand Up @@ -265,57 +236,43 @@ function MessageScrollerItem({
function MessageScrollerButton({
behavior = "smooth",
children,
direction = "end",
onClick,
tabIndex,
type = "button",
...props
}: MessageScrollerButtonProps) {
const { scrollToEnd, scrollToStart, stateStore } = useMessageScrollerContext();
const onClickRef = useLatest(onClick);
const subscribe = React.useCallback(
(listener: () => void) => stateStore.subscribe(listener),
[stateStore],
);
const getSnapshot = React.useCallback(() => {
const state = stateStore.getSnapshot();

return direction === "start" ? state.start : state.end;
}, [direction, stateStore]);
const isActive = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);

const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
if (!isActive) {
return;
}

onClickRef.current?.(event);

if (!event.defaultPrevented) {
event.currentTarget.blur();

if (direction === "start") {
scrollToStart({ behavior });
} else {
scrollToEnd({ behavior });
}
}
},
[behavior, direction, isActive, onClickRef, scrollToEnd, scrollToStart],
const { getCanScrollToEnd, scrollToEnd, subscribeCanScrollToEnd } = useMessageScrollerContext();
const isActive = React.useSyncExternalStore(
subscribeCanScrollToEnd,
getCanScrollToEnd,
getCanScrollToEnd,
);

function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
if (!isActive) {
return;
}

onClick?.(event);

if (event.defaultPrevented) {
return;
}

event.currentTarget.blur();
scrollToEnd({ behavior });
}

return (
<button
{...props}
type={type}
inert={!isActive}
tabIndex={isActive ? tabIndex : -1}
data-active={isActive ? "true" : "false"}
data-direction={direction}
onClick={handleClick}
>
{children ?? <span>Scroll to {direction}</span>}
{children ?? <span>Scroll to end</span>}
</button>
);
}
Expand Down
Loading