From 0e216564516aa550ba9bd0ae10cb05108beeed04 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 16 Mar 2026 14:54:13 -0400 Subject: [PATCH 1/3] fix(url-preview): replace IntersectionObserver with scroll/resize measurement for arrows The old code used IntersectionObserver anchors to determine whether scroll arrows should be shown. This caused false positives in two scenarios: 1. When a UrlPreviewCard renders null (fetch error), the empty anchor div was sometimes not detected as intersecting, leaving a stale arrow visible. 2. When loading (Spinner makes card 400px wide), the IO would mark the front anchor as not visible, showing the arrow; after load failure the arrow stayed stuck. Replace with direct scrollWidth/clientWidth measurement via ResizeObserver (fires on container and content resize) plus a scroll event listener. Arrows now only appear when the horizontal scroll area actually has overflowed content. Fixes #220 --- .../components/url-preview/UrlPreviewCard.tsx | 68 +++++++------------ 1 file changed, 26 insertions(+), 42 deletions(-) diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 5fc3ee61..86c4698a 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -3,10 +3,6 @@ import { IPreviewUrlResponse } from '$types/matrix-sdk'; import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { useMatrixClient } from '$hooks/useMatrixClient'; -import { - getIntersectionObserverEntry, - useIntersectionObserver, -} from '$hooks/useIntersectionObserver'; import { mxcUrlToHttp, downloadMedia } from '$utils/matrix'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import * as css from './UrlPreviewCard.css'; @@ -242,43 +238,34 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number; mediaType?: s export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => { const scrollRef = useRef(null); - const backAnchorRef = useRef(null); - const frontAnchorRef = useRef(null); - const [backVisible, setBackVisible] = useState(true); - const [frontVisible, setFrontVisible] = useState(true); + const innerBoxRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); - const intersectionObserver = useIntersectionObserver( - useCallback((entries) => { - const backAnchor = backAnchorRef.current; - const frontAnchor = frontAnchorRef.current; - const backEntry = backAnchor && getIntersectionObserverEntry(backAnchor, entries); - const frontEntry = frontAnchor && getIntersectionObserverEntry(frontAnchor, entries); - if (backEntry) { - setBackVisible(backEntry.isIntersecting); - } - if (frontEntry) { - setFrontVisible(frontEntry.isIntersecting); - } - }, []), - useCallback( - () => ({ - root: scrollRef.current, - rootMargin: '10px', - }), - [] - ) - ); + const updateArrows = useCallback(() => { + const scroll = scrollRef.current; + if (!scroll) return; + const { scrollLeft, scrollWidth, clientWidth } = scroll; + setCanScrollLeft(scrollLeft > 1); + setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 1); + }, []); useEffect(() => { - const backAnchor = backAnchorRef.current; - const frontAnchor = frontAnchorRef.current; - if (backAnchor) intersectionObserver?.observe(backAnchor); - if (frontAnchor) intersectionObserver?.observe(frontAnchor); + const scroll = scrollRef.current; + if (!scroll) return; + + updateArrows(); + scroll.addEventListener('scroll', updateArrows, { passive: true }); + + const resizeObserver = new ResizeObserver(updateArrows); + resizeObserver.observe(scroll); + if (innerBoxRef.current) resizeObserver.observe(innerBoxRef.current); + return () => { - if (backAnchor) intersectionObserver?.unobserve(backAnchor); - if (frontAnchor) intersectionObserver?.unobserve(frontAnchor); + scroll.removeEventListener('scroll', updateArrows); + resizeObserver.disconnect(); }; - }, [intersectionObserver]); + }, [updateArrows]); const handleScrollBack = () => { const scroll = scrollRef.current; @@ -308,8 +295,7 @@ export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => { > -
- {!backVisible && ( + {canScrollLeft && ( <>
(({ children, ...props }, ref) => { )} - + {children} - - {!frontVisible && ( + {canScrollRight && ( <>
(({ children, ...props }, ref) => { )} -
From 293dee7b348044e8b56e17a44c317d4b70bc9d27 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 16 Mar 2026 15:16:54 -0400 Subject: [PATCH 2/3] fix(lint): use return undefined for consistent-return in useEffect --- src/app/components/url-preview/UrlPreviewCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 86c4698a..947cd67b 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -252,7 +252,7 @@ export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => { useEffect(() => { const scroll = scrollRef.current; - if (!scroll) return; + if (!scroll) return undefined; updateArrows(); scroll.addEventListener('scroll', updateArrows, { passive: true }); From f7afb8da2658fbd5672757a485e48712b04ab847 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 16 Mar 2026 15:25:11 -0400 Subject: [PATCH 3/3] chore: add changeset --- .changeset/fix-url-preview-scroll-arrows.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-url-preview-scroll-arrows.md diff --git a/.changeset/fix-url-preview-scroll-arrows.md b/.changeset/fix-url-preview-scroll-arrows.md new file mode 100644 index 00000000..d5e24158 --- /dev/null +++ b/.changeset/fix-url-preview-scroll-arrows.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix URL preview scroll arrows appearing when there is no content to scroll