fix(app): scope near-viewport loading to the surrounding scroller (#1863)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:33:39 +02:00
1 parent 943b948de7
commit 15f4417c68
4 files changed
+265 -222

No files matched your search

@@ -9,6 +9,7 @@ import {JUMP_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors
import styles from '@app/features/messaging/components/pages/MessageListPage.module.css'; import styles from '@app/features/messaging/components/pages/MessageListPage.module.css';
import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation'; import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation';
import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy'; import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy';
import {NearViewportSurfaceContext} from '@app/features/messaging/hooks/useNearViewport';
import type {Message} from '@app/features/messaging/models/MessagingMessage'; import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {focusChannelTextareaAfterNavigation} from '@app/features/messaging/utils/ChannelTextareaFocusUtils'; import {focusChannelTextareaAfterNavigation} from '@app/features/messaging/utils/ChannelTextareaFocusUtils';
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator'; import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
@@ -20,7 +21,7 @@ import {useLingui} from '@lingui/react/macro';
import {FlagCheckeredIcon, SparkleIcon} from '@phosphor-icons/react'; import {FlagCheckeredIcon, SparkleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import type React from 'react'; import type React from 'react';
import {useRef} from 'react'; import {useMemo, useRef} from 'react';
const YOU_VE_REACHED_THE_END_DESCRIPTOR = msg({ const YOU_VE_REACHED_THE_END_DESCRIPTOR = msg({
message: "You've reached the end", message: "You've reached the end",
@@ -51,6 +52,7 @@ export const MessageListPage = observer(
}: MessageListPageProps) => { }: MessageListPageProps) => {
const {i18n} = useLingui(); const {i18n} = useLingui();
const scrollerRef = useRef<ScrollerHandle | null>(null); const scrollerRef = useRef<ScrollerHandle | null>(null);
const resolveListPageScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(messages); const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(messages);
const leftContent = ( const leftContent = (
<div className={styles.header} data-flx="messaging.message-list-page.header"> <div className={styles.header} data-flx="messaging.message-list-page.header">
@@ -73,77 +75,79 @@ export const MessageListPage = observer(
/> />
<div className={styles.content} data-flx="messaging.message-list-page.content"> <div className={styles.content} data-flx="messaging.message-list-page.content">
{messages.length > 0 ? ( {messages.length > 0 ? (
<Scroller <NearViewportSurfaceContext.Provider value={resolveListPageScrollSurface}>
className={styles.scroller} <Scroller
key="message-list-page-scroller" className={styles.scroller}
ref={scrollerRef} key="message-list-page-scroller"
onCopy={onCopySelectedMessages} ref={scrollerRef}
data-message-selection-root="true" onCopy={onCopySelectedMessages}
data-flx="messaging.message-list-page.scroller" data-message-selection-root="true"
> data-flx="messaging.message-list-page.scroller"
{messages.map((message) => { >
const channel = Channels.getChannel(message.channelId); {messages.map((message) => {
if (!channel) { const channel = Channels.getChannel(message.channelId);
if (renderMissingMessage) { if (!channel) {
return renderMissingMessage(message); if (renderMissingMessage) {
return renderMissingMessage(message);
}
return null;
} }
return null; return (
} <div
return ( key={message.id}
<div className={previewStyles.previewCard}
key={message.id} data-message-id={message.id}
className={previewStyles.previewCard} data-is-group-start="true"
data-message-id={message.id} data-flx="messaging.message-list-page.div"
data-is-group-start="true" >
data-flx="messaging.message-list-page.div" <MessageComponent
> message={message}
<MessageComponent channel={channel}
message={message} previewContext={MessagePreviewContext.LIST_POPOUT}
channel={channel} data-flx="messaging.message-list-page.message-component"
previewContext={MessagePreviewContext.LIST_POPOUT} />
data-flx="messaging.message-list-page.message-component" <div className={previewStyles.actionButtons} data-flx="messaging.message-list-page.div--2">
<button
type="button"
className={previewStyles.actionButton}
onClick={() => {
const path = channel.guildId
? Routes.guildChannel(channel.guildId, channel.id)
: Routes.dmChannel(channel.id);
RouterUtils.transitionTo(path);
goToMessage(message.channelId, message.id);
focusChannelTextareaAfterNavigation(message.channelId);
}}
data-flx="messaging.message-list-page.button.transition-to"
>
{i18n._(JUMP_DESCRIPTOR)}
</button>
{renderActionButtons(message)}
</div>
</div>
);
})}
<div className={styles.endState} data-flx="messaging.message-list-page.end-state">
<div className={styles.endStateContent} data-flx="messaging.message-list-page.end-state-content">
<FlagCheckeredIcon
className={styles.endStateIcon}
data-flx="messaging.message-list-page.end-state-icon"
/> />
<div className={previewStyles.actionButtons} data-flx="messaging.message-list-page.div--2"> <div className={styles.endStateText} data-flx="messaging.message-list-page.end-state-text">
<button <h3 className={styles.endStateTitle} data-flx="messaging.message-list-page.end-state-title">
type="button" {i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
className={previewStyles.actionButton} </h3>
onClick={() => { <p
const path = channel.guildId className={styles.endStateDescription}
? Routes.guildChannel(channel.guildId, channel.id) data-flx="messaging.message-list-page.end-state-description"
: Routes.dmChannel(channel.id);
RouterUtils.transitionTo(path);
goToMessage(message.channelId, message.id);
focusChannelTextareaAfterNavigation(message.channelId);
}}
data-flx="messaging.message-list-page.button.transition-to"
> >
{i18n._(JUMP_DESCRIPTOR)} {endStateDescription}
</button> </p>
{renderActionButtons(message)}
</div> </div>
</div> </div>
);
})}
<div className={styles.endState} data-flx="messaging.message-list-page.end-state">
<div className={styles.endStateContent} data-flx="messaging.message-list-page.end-state-content">
<FlagCheckeredIcon
className={styles.endStateIcon}
data-flx="messaging.message-list-page.end-state-icon"
/>
<div className={styles.endStateText} data-flx="messaging.message-list-page.end-state-text">
<h3 className={styles.endStateTitle} data-flx="messaging.message-list-page.end-state-title">
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
</h3>
<p
className={styles.endStateDescription}
data-flx="messaging.message-list-page.end-state-description"
>
{endStateDescription}
</p>
</div>
</div> </div>
</div> </Scroller>
</Scroller> </NearViewportSurfaceContext.Provider>
) : ( ) : (
<div className={styles.emptyState} data-flx="messaging.message-list-page.empty-state"> <div className={styles.emptyState} data-flx="messaging.message-list-page.empty-state">
<div className={styles.emptyStateContent} data-flx="messaging.message-list-page.empty-state-content"> <div className={styles.emptyStateContent} data-flx="messaging.message-list-page.empty-state-content">
@@ -18,6 +18,7 @@ import headerStyles from '@app/features/messaging/components/popouts/InboxMessag
import styles from '@app/features/messaging/components/popouts/RecentMentionsContent.module.css'; import styles from '@app/features/messaging/components/popouts/RecentMentionsContent.module.css';
import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation'; import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation';
import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy'; import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy';
import {NearViewportSurfaceContext} from '@app/features/messaging/hooks/useNearViewport';
import type {Message as MessagingMessage} from '@app/features/messaging/models/MessagingMessage'; import type {Message as MessagingMessage} from '@app/features/messaging/models/MessagingMessage';
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator'; import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
import * as RecentMentionCommands from '@app/features/notification/commands/RecentMentionCommands'; import * as RecentMentionCommands from '@app/features/notification/commands/RecentMentionCommands';
@@ -217,6 +218,7 @@ export const RecentMentionsContent = observer(
const isLoadingMore = MentionFeed.getIsLoadingMore(); const isLoadingMore = MentionFeed.getIsLoadingMore();
const filterButtonRef = useRef<HTMLButtonElement>(null); const filterButtonRef = useRef<HTMLButtonElement>(null);
const scrollerRef = useRef<ScrollerHandle | null>(null); const scrollerRef = useRef<ScrollerHandle | null>(null);
const resolveMentionsScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
const {isOpen: isFilterMenuOpen, withTracking} = useContextMenuTrigger(); const {isOpen: isFilterMenuOpen, withTracking} = useContextMenuTrigger();
const accessibleMentions = MentionFeed.getAccessibleMentions(); const accessibleMentions = MentionFeed.getAccessibleMentions();
const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(accessibleMentions); const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(accessibleMentions);
@@ -354,64 +356,69 @@ export const RecentMentionsContent = observer(
); );
} }
return ( return (
<Scroller <NearViewportSurfaceContext.Provider value={resolveMentionsScrollSurface}>
className={styles.scroller} <Scroller
onScroll={handleScroll} className={styles.scroller}
key="recent-mentions-scroller" onScroll={handleScroll}
ref={scrollerRef} key="recent-mentions-scroller"
onCopy={onCopySelectedMessages} ref={scrollerRef}
data-message-selection-root="true" onCopy={onCopySelectedMessages}
data-flx="messaging.recent-mentions-content.scroller" data-message-selection-root="true"
> data-flx="messaging.recent-mentions-content.scroller"
{groupedMentions.map((group) => { >
return ( {groupedMentions.map((group) => {
<section return (
key={group.key} <section
className={styles.guildGroup} key={group.key}
aria-label={group.label} className={styles.guildGroup}
data-flx="messaging.recent-mentions-content.guild-group" aria-label={group.label}
> data-flx="messaging.recent-mentions-content.guild-group"
{group.mentions.map((message) => { >
const channel = Channels.getChannel(message.channelId); {group.mentions.map((message) => {
if (!channel) return null; const channel = Channels.getChannel(message.channelId);
return ( if (!channel) return null;
<MentionMessageCard return (
key={message.id} <MentionMessageCard
message={message} key={message.id}
channel={channel} message={message}
onJump={handleJumpToMessage} channel={channel}
onRemove={handleRemoveMention} onJump={handleJumpToMessage}
data-flx="messaging.recent-mentions-content.mention-message-card" onRemove={handleRemoveMention}
/> data-flx="messaging.recent-mentions-content.mention-message-card"
); />
})} );
</section> })}
); </section>
})} );
{isLoadingMore && ( })}
<div className={previewStyles.loadingState} data-flx="messaging.recent-mentions-content.div--7"> {isLoadingMore && (
<Spinner data-flx="messaging.recent-mentions-content.spinner--2" /> <div className={previewStyles.loadingState} data-flx="messaging.recent-mentions-content.div--7">
</div> <Spinner data-flx="messaging.recent-mentions-content.spinner--2" />
)} </div>
{!hasMore && !isLoadingMore && ( )}
<div className={previewStyles.endState} data-flx="messaging.recent-mentions-content.div--8"> {!hasMore && !isLoadingMore && (
<div className={previewStyles.endStateContent} data-flx="messaging.recent-mentions-content.div--9"> <div className={previewStyles.endState} data-flx="messaging.recent-mentions-content.div--8">
<FlagCheckeredIcon <div className={previewStyles.endStateContent} data-flx="messaging.recent-mentions-content.div--9">
className={previewStyles.endStateIcon} <FlagCheckeredIcon
data-flx="messaging.recent-mentions-content.flag-checkered-icon" className={previewStyles.endStateIcon}
/> data-flx="messaging.recent-mentions-content.flag-checkered-icon"
<div className={previewStyles.endStateTextContainer} data-flx="messaging.recent-mentions-content.div--10"> />
<h3 className={previewStyles.endStateTitle} data-flx="messaging.recent-mentions-content.h3--2"> <div
<Trans>You've reached the end</Trans> className={previewStyles.endStateTextContainer}
</h3> data-flx="messaging.recent-mentions-content.div--10"
<p className={previewStyles.endStateDescription} data-flx="messaging.recent-mentions-content.p--2"> >
<Trans>You've seen all your recent mentions. More will appear here soon.</Trans> <h3 className={previewStyles.endStateTitle} data-flx="messaging.recent-mentions-content.h3--2">
</p> <Trans>You've reached the end</Trans>
</h3>
<p className={previewStyles.endStateDescription} data-flx="messaging.recent-mentions-content.p--2">
<Trans>You've seen all your recent mentions. More will appear here soon.</Trans>
</p>
</div>
</div> </div>
</div> </div>
</div> )}
)} </Scroller>
</Scroller> </NearViewportSurfaceContext.Provider>
); );
}, },
); );
@@ -13,6 +13,7 @@ import headerStyles from '@app/features/messaging/components/popouts/InboxMessag
import styles from '@app/features/messaging/components/popouts/SavedMessagesContent.module.css'; import styles from '@app/features/messaging/components/popouts/SavedMessagesContent.module.css';
import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation'; import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation';
import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy'; import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy';
import {NearViewportSurfaceContext} from '@app/features/messaging/hooks/useNearViewport';
import SavedMessages from '@app/features/messaging/state/SavedMessages'; import SavedMessages from '@app/features/messaging/state/SavedMessages';
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator'; import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller'; import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
@@ -24,7 +25,7 @@ import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro'; import {useLingui} from '@lingui/react/macro';
import {FlagCheckeredIcon, SparkleIcon, XIcon} from '@phosphor-icons/react'; import {FlagCheckeredIcon, SparkleIcon, XIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite'; import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useRef} from 'react'; import {useCallback, useEffect, useMemo, useRef} from 'react';
const NO_BOOKMARKS_DESCRIPTOR = msg({ const NO_BOOKMARKS_DESCRIPTOR = msg({
message: 'No bookmarks', message: 'No bookmarks',
@@ -49,6 +50,7 @@ export const SavedMessagesContent = observer(() => {
const {i18n} = useLingui(); const {i18n} = useLingui();
const {savedMessages, missingSavedMessages, fetched} = SavedMessages; const {savedMessages, missingSavedMessages, fetched} = SavedMessages;
const scrollerRef = useRef<ScrollerHandle | null>(null); const scrollerRef = useRef<ScrollerHandle | null>(null);
const resolveSavedScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(savedMessages); const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(savedMessages);
const renderMissingSavedMessage = useCallback( const renderMissingSavedMessage = useCallback(
(entryId: string) => ( (entryId: string) => (
@@ -105,99 +107,105 @@ export const SavedMessagesContent = observer(() => {
); );
} }
return ( return (
<Scroller <NearViewportSurfaceContext.Provider value={resolveSavedScrollSurface}>
className={styles.scroller} <Scroller
key="saved-messages-scroller" className={styles.scroller}
ref={scrollerRef} key="saved-messages-scroller"
onCopy={onCopySelectedMessages} ref={scrollerRef}
data-message-selection-root="true" onCopy={onCopySelectedMessages}
data-flx="messaging.saved-messages-content.scroller" data-message-selection-root="true"
> data-flx="messaging.saved-messages-content.scroller"
{missingSavedMessages.map((entry) => renderMissingSavedMessage(entry.id))} >
{savedMessages.map((message) => { {missingSavedMessages.map((entry) => renderMissingSavedMessage(entry.id))}
const channel = Channels.getChannel(message.channelId); {savedMessages.map((message) => {
if (!channel) { const channel = Channels.getChannel(message.channelId);
return renderMissingSavedMessage(message.id); if (!channel) {
} return renderMissingSavedMessage(message.id);
return ( }
<div key={message.id} className={styles.messageCard} data-flx="messaging.saved-messages-content.message-card"> return (
<InboxMessageHeader <div
channel={channel} key={message.id}
onClick={() => handleJumpToMessage(message.channelId, message.id)} className={styles.messageCard}
rightActions={ data-flx="messaging.saved-messages-content.message-card"
<Tooltip >
text={i18n._(REMOVE_BOOKMARK_DESCRIPTOR)} <InboxMessageHeader
position="top" channel={channel}
data-flx="messaging.saved-messages-content.tooltip" onClick={() => handleJumpToMessage(message.channelId, message.id)}
> rightActions={
<FocusRing offset={-2} data-flx="messaging.saved-messages-content.focus-ring"> <Tooltip
text={i18n._(REMOVE_BOOKMARK_DESCRIPTOR)}
position="top"
data-flx="messaging.saved-messages-content.tooltip"
>
<FocusRing offset={-2} data-flx="messaging.saved-messages-content.focus-ring">
<button
type="button"
className={headerStyles.headerIconButton}
onClick={() => SavedMessageCommands.remove(i18n, message.id)}
aria-label={i18n._(REMOVE_BOOKMARK_DESCRIPTOR)}
data-flx="messaging.saved-messages-content.button.remove"
>
<XIcon
weight="bold"
className={headerStyles.headerIcon}
data-flx="messaging.saved-messages-content.x-icon"
/>
</button>
</FocusRing>
</Tooltip>
}
data-flx="messaging.saved-messages-content.inbox-message-header.jump-to-message"
/>
<div
className={previewStyles.previewCard}
data-message-id={message.id}
data-is-group-start="true"
data-flx="messaging.saved-messages-content.div--5"
>
<Message
message={message}
channel={channel}
previewContext={MessagePreviewContext.LIST_POPOUT}
behaviorOverrides={readonlyBehaviorOverrides}
suppressMessageActions
onHeadingActivate={() => handleJumpToMessage(message.channelId, message.id)}
data-flx="messaging.saved-messages-content.message"
/>
<div className={previewStyles.actionButtons} data-flx="messaging.saved-messages-content.div--6">
<FocusRing offset={-2} data-flx="messaging.saved-messages-content.focus-ring--2">
<button <button
type="button" type="button"
className={headerStyles.headerIconButton} className={previewStyles.actionButton}
onClick={() => SavedMessageCommands.remove(i18n, message.id)} onClick={() => {
aria-label={i18n._(REMOVE_BOOKMARK_DESCRIPTOR)} handleJumpToMessage(message.channelId, message.id);
data-flx="messaging.saved-messages-content.button.remove" }}
data-flx="messaging.saved-messages-content.button.jump-to-message"
> >
<XIcon {i18n._(JUMP_DESCRIPTOR)}
weight="bold"
className={headerStyles.headerIcon}
data-flx="messaging.saved-messages-content.x-icon"
/>
</button> </button>
</FocusRing> </FocusRing>
</Tooltip> </div>
}
data-flx="messaging.saved-messages-content.inbox-message-header.jump-to-message"
/>
<div
className={previewStyles.previewCard}
data-message-id={message.id}
data-is-group-start="true"
data-flx="messaging.saved-messages-content.div--5"
>
<Message
message={message}
channel={channel}
previewContext={MessagePreviewContext.LIST_POPOUT}
behaviorOverrides={readonlyBehaviorOverrides}
suppressMessageActions
onHeadingActivate={() => handleJumpToMessage(message.channelId, message.id)}
data-flx="messaging.saved-messages-content.message"
/>
<div className={previewStyles.actionButtons} data-flx="messaging.saved-messages-content.div--6">
<FocusRing offset={-2} data-flx="messaging.saved-messages-content.focus-ring--2">
<button
type="button"
className={previewStyles.actionButton}
onClick={() => {
handleJumpToMessage(message.channelId, message.id);
}}
data-flx="messaging.saved-messages-content.button.jump-to-message"
>
{i18n._(JUMP_DESCRIPTOR)}
</button>
</FocusRing>
</div> </div>
</div> </div>
</div> );
); })}
})} <div className={previewStyles.endState} data-flx="messaging.saved-messages-content.div--7">
<div className={previewStyles.endState} data-flx="messaging.saved-messages-content.div--7"> <div className={previewStyles.endStateContent} data-flx="messaging.saved-messages-content.div--8">
<div className={previewStyles.endStateContent} data-flx="messaging.saved-messages-content.div--8"> <FlagCheckeredIcon
<FlagCheckeredIcon className={previewStyles.endStateIcon}
className={previewStyles.endStateIcon} data-flx="messaging.saved-messages-content.flag-checkered-icon"
data-flx="messaging.saved-messages-content.flag-checkered-icon" />
/> <div className={previewStyles.endStateTextContainer} data-flx="messaging.saved-messages-content.div--9">
<div className={previewStyles.endStateTextContainer} data-flx="messaging.saved-messages-content.div--9"> <h3 className={previewStyles.endStateTitle} data-flx="messaging.saved-messages-content.h3--2">
<h3 className={previewStyles.endStateTitle} data-flx="messaging.saved-messages-content.h3--2"> {i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)} </h3>
</h3> <p className={previewStyles.endStateDescription} data-flx="messaging.saved-messages-content.p--2">
<p className={previewStyles.endStateDescription} data-flx="messaging.saved-messages-content.p--2"> {i18n._(THERE_S_NOTHING_MORE_TO_SEE_HERE_DESCRIPTOR)}
{i18n._(THERE_S_NOTHING_MORE_TO_SEE_HERE_DESCRIPTOR)} </p>
</p> </div>
</div> </div>
</div> </div>
</div> </Scroller>
</Scroller> </NearViewportSurfaceContext.Provider>
); );
}); });
@@ -2,14 +2,27 @@
import {observeIntersection} from '@app/features/platform/utils/SharedIntersectionObserver'; import {observeIntersection} from '@app/features/platform/utils/SharedIntersectionObserver';
import {LRUCache} from 'lru-cache'; import {LRUCache} from 'lru-cache';
import {useCallback, useEffect, useState} from 'react'; import {createContext, useCallback, useContext, useEffect, useState} from 'react';
const DEFAULT_ROOT_MARGIN = '900px 0px'; const DEFAULT_ROOT_MARGIN = '300px';
const REMEMBERED_VIEWPORT_KEY_LIMIT = 1000; const REMEMBERED_VIEWPORT_KEY_LIMIT = 1000;
const rememberedViewportKeys = new LRUCache<string, true>({ const rememberedViewportKeys = new LRUCache<string, true>({
max: REMEMBERED_VIEWPORT_KEY_LIMIT, max: REMEMBERED_VIEWPORT_KEY_LIMIT,
}); });
export type ScrollSurfaceResolver = () => Element | null;
const resolveNoScrollSurface: ScrollSurfaceResolver = () => null;
export const NearViewportSurfaceContext = createContext<ScrollSurfaceResolver>(resolveNoScrollSurface);
export function resolveObserverRoot(resolve: ScrollSurfaceResolver, element: Element): Element | null {
const surface = resolve();
if (surface == null || surface === element) return null;
if (!surface.contains(element)) return null;
return surface;
}
interface UseNearViewportOptions { interface UseNearViewportOptions {
disabled?: boolean; disabled?: boolean;
rememberKey?: string | null; rememberKey?: string | null;
@@ -23,6 +36,7 @@ export function useNearViewport<T extends Element>({
rootMargin = DEFAULT_ROOT_MARGIN, rootMargin = DEFAULT_ROOT_MARGIN,
threshold = 0, threshold = 0,
}: UseNearViewportOptions = {}): {ref: (node: T | null) => void; isNearViewport: boolean} { }: UseNearViewportOptions = {}): {ref: (node: T | null) => void; isNearViewport: boolean} {
const resolveScrollSurface = useContext(NearViewportSurfaceContext);
const loadImmediately = disabled || typeof IntersectionObserver === 'undefined'; const loadImmediately = disabled || typeof IntersectionObserver === 'undefined';
const wasRemembered = rememberKey ? rememberedViewportKeys.has(rememberKey) : false; const wasRemembered = rememberKey ? rememberedViewportKeys.has(rememberKey) : false;
const [element, setElement] = useState<T | null>(null); const [element, setElement] = useState<T | null>(null);
@@ -36,24 +50,34 @@ export function useNearViewport<T extends Element>({
} }
}, [disabled]); }, [disabled]);
useEffect(() => { useEffect(() => {
if (isNearViewport && rememberKey) { if (!isNearViewport || !rememberKey) return;
rememberedViewportKeys.set(rememberKey, true); if (disabled || typeof IntersectionObserver === 'undefined') return;
} rememberedViewportKeys.set(rememberKey, true);
}, [isNearViewport, rememberKey]); }, [disabled, isNearViewport, rememberKey]);
useEffect(() => { useEffect(() => {
if (disabled || isNearViewport || !element) return undefined; if (disabled || isNearViewport || !element) return undefined;
return observeIntersection( if (rememberKey && rememberedViewportKeys.has(rememberKey)) {
setIsNearViewport(true);
return undefined;
}
let release: (() => void) | null = null;
const stopObserving = () => {
release?.();
release = null;
};
release = observeIntersection(
element, element,
(entry) => { (entry) => {
if (entry.isIntersecting || entry.intersectionRatio > 0) { if (!entry.isIntersecting && entry.intersectionRatio <= 0) return;
if (rememberKey) { stopObserving();
rememberedViewportKeys.set(rememberKey, true); if (rememberKey) {
} rememberedViewportKeys.set(rememberKey, true);
setIsNearViewport(true);
} }
setIsNearViewport(true);
}, },
{rootMargin, threshold}, {root: resolveObserverRoot(resolveScrollSurface, element), rootMargin, threshold},
); );
}, [disabled, element, isNearViewport, rememberKey, rootMargin, threshold]); return stopObserving;
}, [disabled, element, isNearViewport, rememberKey, resolveScrollSurface, rootMargin, threshold]);
return {ref, isNearViewport}; return {ref, isNearViewport};
} }