refactor(team): viewport contract + observer root for ActivityTimeline
Second step of the virtualization plan. No virtualization yet. This PR makes IntersectionObserver-based visibility tracking correct inside scroll containers (sidebar, bottom-sheet), which is a prerequisite for virtualizing the timeline. - Introduces `TimelineViewport` — a grouped contract passed as a single `viewport` prop on `ActivityTimeline`. Holds `scrollElementRef`, `observerRoot`, `scrollMargin`, and `virtualizationEnabled`. - `MessageRowWithObserver` and `LeadThoughtsGroupRow` now create their `IntersectionObserver` with `root = observerRoot?.current ?? null` instead of defaulting to the document viewport. Unread marking now fires when rows enter their real scroll parent. - `MessagesPanel` resolves the active scroll owner from `position` (inline from parent ref, sidebar from `sidebarScrollRef`, bottom-sheet from `bottomSheetScrollRef`) and passes it into ActivityTimeline. - Tests: stubs `IntersectionObserver` to capture `options.root` and asserts null when no viewport is passed, and the provided element when `viewport.observerRoot` is set. `scrollMargin` and `virtualizationEnabled` are included in the contract but not consumed yet — they land in follow-up PRs (#4/#5).
This commit is contained in:
parent
fd9eb21f55
commit
d4f518e8c5
4 changed files with 221 additions and 8 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
areInboxMessagesEquivalentForRender,
|
areInboxMessagesEquivalentForRender,
|
||||||
|
|
@ -24,6 +24,34 @@ import { useNewItemKeys } from './useNewItemKeys';
|
||||||
import type { TimelineItem } from './LeadThoughtsGroup';
|
import type { TimelineItem } from './LeadThoughtsGroup';
|
||||||
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Viewport contract — describes the scroll container that hosts the timeline
|
||||||
|
* and how ActivityTimeline should report visibility against it. When omitted,
|
||||||
|
* ActivityTimeline falls back to the document viewport (current behavior).
|
||||||
|
*
|
||||||
|
* This contract is grouped intentionally so consumers pass a single coherent
|
||||||
|
* object rather than threading several refs and flags. Virtualizer wiring
|
||||||
|
* lands in a follow-up; for now only `observerRoot` has an observable effect.
|
||||||
|
*/
|
||||||
|
export interface TimelineViewport {
|
||||||
|
/** The element that actually scrolls. */
|
||||||
|
scrollElementRef: RefObject<HTMLElement | null>;
|
||||||
|
/**
|
||||||
|
* Root element for IntersectionObserver-based visibility tracking.
|
||||||
|
* Typically the same node as `scrollElementRef`, but left separate so
|
||||||
|
* future code can observe a more specific inner container when needed.
|
||||||
|
*/
|
||||||
|
observerRoot?: RefObject<HTMLElement | null>;
|
||||||
|
/**
|
||||||
|
* Distance from the scroll container's scroll origin to the timeline root,
|
||||||
|
* measured from the DOM. Zero in this release; used by the virtualizer in a
|
||||||
|
* follow-up change.
|
||||||
|
*/
|
||||||
|
scrollMargin?: number;
|
||||||
|
/** Enable virtualization (wired in a follow-up; ignored for now). */
|
||||||
|
virtualizationEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface ActivityTimelineProps {
|
interface ActivityTimelineProps {
|
||||||
messages: InboxMessage[];
|
messages: InboxMessage[];
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -66,6 +94,14 @@ interface ActivityTimelineProps {
|
||||||
onExpandItem?: (key: string) => void;
|
onExpandItem?: (key: string) => void;
|
||||||
/** Called when ExpandableContent is expanded via "Show more" in any ActivityItem. */
|
/** Called when ExpandableContent is expanded via "Show more" in any ActivityItem. */
|
||||||
onExpandContent?: () => void;
|
onExpandContent?: () => void;
|
||||||
|
/**
|
||||||
|
* Optional viewport contract. When provided, IntersectionObserver uses the
|
||||||
|
* passed `observerRoot` instead of the document viewport, which is required
|
||||||
|
* for correctness inside scrollable layouts (sidebar, bottom-sheet) where
|
||||||
|
* the row may be clipped by its scroll parent while still intersecting the
|
||||||
|
* page viewport.
|
||||||
|
*/
|
||||||
|
viewport?: TimelineViewport;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIEWPORT_THRESHOLD = 0.15;
|
const VIEWPORT_THRESHOLD = 0.15;
|
||||||
|
|
@ -141,6 +177,7 @@ const MessageRowWithObserver = ({
|
||||||
onExpand,
|
onExpand,
|
||||||
expandItemKey,
|
expandItemKey,
|
||||||
onExpandContent,
|
onExpandContent,
|
||||||
|
observerRoot,
|
||||||
}: {
|
}: {
|
||||||
message: InboxMessage;
|
message: InboxMessage;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -170,6 +207,7 @@ const MessageRowWithObserver = ({
|
||||||
onExpand?: (key: string) => void;
|
onExpand?: (key: string) => void;
|
||||||
expandItemKey?: string;
|
expandItemKey?: string;
|
||||||
onExpandContent?: () => void;
|
onExpandContent?: () => void;
|
||||||
|
observerRoot?: RefObject<HTMLElement | null>;
|
||||||
}): React.JSX.Element => {
|
}): React.JSX.Element => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const reportedRef = useRef(false);
|
const reportedRef = useRef(false);
|
||||||
|
|
@ -185,6 +223,10 @@ const MessageRowWithObserver = ({
|
||||||
if (!onVisible) return;
|
if (!onVisible) return;
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
// Resolve the observer root at effect-time. Falls back to the document
|
||||||
|
// viewport (null) when no root is provided — preserves pre-contract
|
||||||
|
// behavior for layouts without a known scroll owner.
|
||||||
|
const root = observerRoot?.current ?? null;
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
([entry]) => {
|
([entry]) => {
|
||||||
if (!entry?.isIntersecting) return;
|
if (!entry?.isIntersecting) return;
|
||||||
|
|
@ -195,11 +237,11 @@ const MessageRowWithObserver = ({
|
||||||
reportedRef.current = true;
|
reportedRef.current = true;
|
||||||
cb(msg);
|
cb(msg);
|
||||||
},
|
},
|
||||||
{ threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
|
{ root, threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
|
||||||
);
|
);
|
||||||
observer.observe(el);
|
observer.observe(el);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [onVisible]);
|
}, [onVisible, observerRoot]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedHeightReveal animate={isNew} containerRef={ref}>
|
<AnimatedHeightReveal animate={isNew} containerRef={ref}>
|
||||||
|
|
@ -265,6 +307,7 @@ const MemoizedMessageRowWithObserver = React.memo(
|
||||||
prev.onExpand === next.onExpand &&
|
prev.onExpand === next.onExpand &&
|
||||||
prev.expandItemKey === next.expandItemKey &&
|
prev.expandItemKey === next.expandItemKey &&
|
||||||
prev.onExpandContent === next.onExpandContent &&
|
prev.onExpandContent === next.onExpandContent &&
|
||||||
|
prev.observerRoot === next.observerRoot &&
|
||||||
areInboxMessagesEquivalentForRender(prev.message, next.message)
|
areInboxMessagesEquivalentForRender(prev.message, next.message)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -291,7 +334,9 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
||||||
onTeamClick,
|
onTeamClick,
|
||||||
onExpandItem,
|
onExpandItem,
|
||||||
onExpandContent,
|
onExpandContent,
|
||||||
|
viewport,
|
||||||
}: ActivityTimelineProps): React.JSX.Element {
|
}: ActivityTimelineProps): React.JSX.Element {
|
||||||
|
const observerRoot = viewport?.observerRoot ?? viewport?.scrollElementRef;
|
||||||
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
|
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const [compactHeader, setCompactHeader] = useState(false);
|
const [compactHeader, setCompactHeader] = useState(false);
|
||||||
|
|
@ -519,6 +564,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
||||||
leadContextUpdatedAt={pinnedCanBeLive ? leadContextUpdatedAt : undefined}
|
leadContextUpdatedAt={pinnedCanBeLive ? leadContextUpdatedAt : undefined}
|
||||||
isNew={newItemKeys.has(itemKey)}
|
isNew={newItemKeys.has(itemKey)}
|
||||||
onVisible={onMessageVisible}
|
onVisible={onMessageVisible}
|
||||||
|
observerRoot={observerRoot}
|
||||||
zebraShade={zebraShadeSet.has(0)}
|
zebraShade={zebraShadeSet.has(0)}
|
||||||
collapseMode={collapseProps.collapseMode}
|
collapseMode={collapseProps.collapseMode}
|
||||||
isCollapsed={collapseProps.isCollapsed}
|
isCollapsed={collapseProps.isCollapsed}
|
||||||
|
|
@ -579,6 +625,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
||||||
canBeLive={false}
|
canBeLive={false}
|
||||||
isNew={newItemKeys.has(itemKey)}
|
isNew={newItemKeys.has(itemKey)}
|
||||||
onVisible={onMessageVisible}
|
onVisible={onMessageVisible}
|
||||||
|
observerRoot={observerRoot}
|
||||||
zebraShade={zebraShadeSet.has(realIndex)}
|
zebraShade={zebraShadeSet.has(realIndex)}
|
||||||
collapseMode={collapseProps.collapseMode}
|
collapseMode={collapseProps.collapseMode}
|
||||||
isCollapsed={collapseProps.isCollapsed}
|
isCollapsed={collapseProps.isCollapsed}
|
||||||
|
|
@ -650,6 +697,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
||||||
onTeamClick={onTeamClick}
|
onTeamClick={onTeamClick}
|
||||||
onExpand={compactHeader ? onExpandItem : undefined}
|
onExpand={compactHeader ? onExpandItem : undefined}
|
||||||
expandItemKey={compactHeader ? messageKey : undefined}
|
expandItemKey={compactHeader ? messageKey : undefined}
|
||||||
|
observerRoot={observerRoot}
|
||||||
onExpandContent={onExpandContent}
|
onExpandContent={onExpandContent}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
type JSX,
|
type JSX,
|
||||||
memo,
|
memo,
|
||||||
|
type RefObject,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
|
|
@ -157,6 +158,14 @@ interface LeadThoughtsGroupRowProps {
|
||||||
memberColor?: string;
|
memberColor?: string;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
onVisible?: (message: InboxMessage) => void;
|
onVisible?: (message: InboxMessage) => void;
|
||||||
|
/**
|
||||||
|
* Root element for IntersectionObserver-based visibility tracking. When
|
||||||
|
* omitted, the observer falls back to the document viewport — correct for
|
||||||
|
* top-level renders, incorrect when the row is inside a scroll container
|
||||||
|
* (sidebar, bottom-sheet) that can clip the row while the document
|
||||||
|
* viewport still contains it.
|
||||||
|
*/
|
||||||
|
observerRoot?: RefObject<HTMLElement | null>;
|
||||||
/** When false, the live indicator is always off (for historical thought groups). */
|
/** When false, the live indicator is always off (for historical thought groups). */
|
||||||
canBeLive?: boolean;
|
canBeLive?: boolean;
|
||||||
/** Whether the owning team is currently alive. */
|
/** Whether the owning team is currently alive. */
|
||||||
|
|
@ -528,6 +537,7 @@ const LeadThoughtsGroupRowComponent = ({
|
||||||
memberColor,
|
memberColor,
|
||||||
isNew,
|
isNew,
|
||||||
onVisible,
|
onVisible,
|
||||||
|
observerRoot,
|
||||||
canBeLive,
|
canBeLive,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
|
|
@ -637,6 +647,9 @@ const LeadThoughtsGroupRowComponent = ({
|
||||||
if (!onVisible) return;
|
if (!onVisible) return;
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
// Resolve observer root at effect-time. Falls back to the document
|
||||||
|
// viewport when no root is provided — preserves pre-contract behavior.
|
||||||
|
const root = observerRoot?.current ?? null;
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
([entry]) => {
|
([entry]) => {
|
||||||
if (!entry?.isIntersecting) return;
|
if (!entry?.isIntersecting) return;
|
||||||
|
|
@ -647,11 +660,11 @@ const LeadThoughtsGroupRowComponent = ({
|
||||||
}
|
}
|
||||||
reportedCountRef.current = thoughts.length;
|
reportedCountRef.current = thoughts.length;
|
||||||
},
|
},
|
||||||
{ threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
|
{ root, threshold: VIEWPORT_THRESHOLD, rootMargin: '0px' }
|
||||||
);
|
);
|
||||||
observer.observe(el);
|
observer.observe(el);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [onVisible, thoughts]);
|
}, [onVisible, observerRoot, thoughts]);
|
||||||
|
|
||||||
const clearPendingScrollSync = useCallback(() => {
|
const clearPendingScrollSync = useCallback(() => {
|
||||||
if (scrollSyncFrameRef.current !== null) {
|
if (scrollSyncFrameRef.current !== null) {
|
||||||
|
|
@ -1134,5 +1147,6 @@ export const LeadThoughtsGroupRow = memo(
|
||||||
prev.compactHeader === next.compactHeader &&
|
prev.compactHeader === next.compactHeader &&
|
||||||
prev.onExpand === next.onExpand &&
|
prev.onExpand === next.onExpand &&
|
||||||
prev.expandItemKey === next.expandItemKey &&
|
prev.expandItemKey === next.expandItemKey &&
|
||||||
|
prev.observerRoot === next.observerRoot &&
|
||||||
areThoughtGroupsEquivalent(prev.group, next.group)
|
areThoughtGroupsEquivalent(prev.group, next.group)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { ActivityTimeline } from '../activity/ActivityTimeline';
|
import { ActivityTimeline, type TimelineViewport } from '../activity/ActivityTimeline';
|
||||||
import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup';
|
import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup';
|
||||||
import { MessageExpandDialog } from '../activity/MessageExpandDialog';
|
import { MessageExpandDialog } from '../activity/MessageExpandDialog';
|
||||||
import { CollapsibleTeamSection } from '../CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from '../CollapsibleTeamSection';
|
||||||
|
|
@ -183,6 +183,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
onReplyToMessage,
|
onReplyToMessage,
|
||||||
onRestartTeam,
|
onRestartTeam,
|
||||||
onTaskIdClick,
|
onTaskIdClick,
|
||||||
|
inlineScrollContainerRef,
|
||||||
}: MessagesPanelProps): React.JSX.Element {
|
}: MessagesPanelProps): React.JSX.Element {
|
||||||
const {
|
const {
|
||||||
sendTeamMessage,
|
sendTeamMessage,
|
||||||
|
|
@ -235,6 +236,28 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
// Held here so future viewport consumers (virtualization) can observe the
|
// Held here so future viewport consumers (virtualization) can observe the
|
||||||
// true scrolling element in bottom-sheet mode.
|
// true scrolling element in bottom-sheet mode.
|
||||||
const bottomSheetScrollRef = useRef<HTMLDivElement | null>(null);
|
const bottomSheetScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// Resolve the active scroll owner for the current layout. This is the
|
||||||
|
// ref that ActivityTimeline's IntersectionObserver will use as its root,
|
||||||
|
// so visibility is measured against the real scroll container rather
|
||||||
|
// than the document viewport. Virtualizer consumers will hook into the
|
||||||
|
// same ref in a follow-up change.
|
||||||
|
const activeScrollContainerRef =
|
||||||
|
position === 'inline'
|
||||||
|
? (inlineScrollContainerRef ?? null)
|
||||||
|
: position === 'sidebar'
|
||||||
|
? sidebarScrollRef
|
||||||
|
: bottomSheetScrollRef;
|
||||||
|
|
||||||
|
const activityTimelineViewport = useMemo<TimelineViewport | undefined>(() => {
|
||||||
|
if (!activeScrollContainerRef) return undefined;
|
||||||
|
return {
|
||||||
|
scrollElementRef: activeScrollContainerRef,
|
||||||
|
observerRoot: activeScrollContainerRef,
|
||||||
|
scrollMargin: 0,
|
||||||
|
virtualizationEnabled: false,
|
||||||
|
};
|
||||||
|
}, [activeScrollContainerRef]);
|
||||||
const handleExpandContent = useCallback(() => {
|
const handleExpandContent = useCallback(() => {
|
||||||
// no-op: user is reading expanded content, not composing
|
// no-op: user is reading expanded content, not composing
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -678,6 +701,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
onExpandItem={handleExpandItem}
|
onExpandItem={handleExpandItem}
|
||||||
onExpandContent={handleExpandContent}
|
onExpandContent={handleExpandContent}
|
||||||
|
viewport={activityTimelineViewport}
|
||||||
/>
|
/>
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div className="flex justify-center py-2">
|
<div className="flex justify-center py-2">
|
||||||
|
|
@ -863,6 +887,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
onExpandItem={handleExpandItem}
|
onExpandItem={handleExpandItem}
|
||||||
onExpandContent={handleExpandContent}
|
onExpandContent={handleExpandContent}
|
||||||
|
viewport={activityTimelineViewport}
|
||||||
/>
|
/>
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div className="flex justify-center py-2">
|
<div className="flex justify-center py-2">
|
||||||
|
|
@ -1150,6 +1175,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
onTaskIdClick={onTaskIdClick}
|
onTaskIdClick={onTaskIdClick}
|
||||||
onExpandItem={handleExpandItem}
|
onExpandItem={handleExpandItem}
|
||||||
onExpandContent={handleExpandContent}
|
onExpandContent={handleExpandContent}
|
||||||
|
viewport={activityTimelineViewport}
|
||||||
/>
|
/>
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div className="flex justify-center py-2">
|
<div className="flex justify-center py-2">
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,13 @@ vi.mock('@renderer/components/team/activity/ActivityItem', () => ({
|
||||||
|
|
||||||
vi.mock('@renderer/components/team/activity/AnimatedHeightReveal', () => ({
|
vi.mock('@renderer/components/team/activity/AnimatedHeightReveal', () => ({
|
||||||
ENTRY_REVEAL_ANIMATION_MS: 220,
|
ENTRY_REVEAL_ANIMATION_MS: 220,
|
||||||
AnimatedHeightReveal: ({ children }: { children: React.ReactNode }) =>
|
AnimatedHeightReveal: ({
|
||||||
React.createElement(React.Fragment, null, children),
|
children,
|
||||||
|
containerRef,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
containerRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
|
}) => React.createElement('div', { ref: containerRef }, children),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/team/activity/useNewItemKeys', () => ({
|
vi.mock('@renderer/components/team/activity/useNewItemKeys', () => ({
|
||||||
|
|
@ -285,3 +290,123 @@ describe('ActivityTimeline session separators', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ActivityTimeline viewport observerRoot', () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let capturedRoots: Array<Element | Document | null>;
|
||||||
|
let originalIntersectionObserver:
|
||||||
|
| typeof globalThis.IntersectionObserver
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
capturedRoots = [];
|
||||||
|
originalIntersectionObserver = globalThis.IntersectionObserver;
|
||||||
|
class FakeIntersectionObserver {
|
||||||
|
public readonly root: Element | Document | null;
|
||||||
|
public readonly rootMargin: string;
|
||||||
|
public readonly thresholds: ReadonlyArray<number>;
|
||||||
|
constructor(
|
||||||
|
_callback: IntersectionObserverCallback,
|
||||||
|
options?: IntersectionObserverInit
|
||||||
|
) {
|
||||||
|
this.root = options?.root ?? null;
|
||||||
|
this.rootMargin = options?.rootMargin ?? '0px';
|
||||||
|
this.thresholds = Array.isArray(options?.threshold)
|
||||||
|
? options.threshold
|
||||||
|
: typeof options?.threshold === 'number'
|
||||||
|
? [options.threshold]
|
||||||
|
: [0];
|
||||||
|
capturedRoots.push(this.root);
|
||||||
|
}
|
||||||
|
observe(): void {}
|
||||||
|
unobserve(): void {}
|
||||||
|
disconnect(): void {}
|
||||||
|
takeRecords(): IntersectionObserverEntry[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalIntersectionObserver) {
|
||||||
|
globalThis.IntersectionObserver = originalIntersectionObserver;
|
||||||
|
}
|
||||||
|
container.remove();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates IntersectionObservers with root=null when no viewport is passed', async () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const messages: InboxMessage[] = [
|
||||||
|
makeMessage({
|
||||||
|
messageId: 'msg-1',
|
||||||
|
text: 'hello',
|
||||||
|
from: 'alice',
|
||||||
|
source: 'inbox',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(ActivityTimeline, {
|
||||||
|
messages,
|
||||||
|
teamName: 'demo-team',
|
||||||
|
onMessageVisible: () => {},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(capturedRoots.length).toBeGreaterThan(0);
|
||||||
|
expect(capturedRoots.every((r) => r === null)).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates IntersectionObservers with the provided root when viewport.observerRoot is set', async () => {
|
||||||
|
const scrollHost = document.createElement('div');
|
||||||
|
document.body.appendChild(scrollHost);
|
||||||
|
const scrollRef = { current: scrollHost };
|
||||||
|
|
||||||
|
const root = createRoot(container);
|
||||||
|
const messages: InboxMessage[] = [
|
||||||
|
makeMessage({
|
||||||
|
messageId: 'msg-1',
|
||||||
|
text: 'hello',
|
||||||
|
from: 'alice',
|
||||||
|
source: 'inbox',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(ActivityTimeline, {
|
||||||
|
messages,
|
||||||
|
teamName: 'demo-team',
|
||||||
|
onMessageVisible: () => {},
|
||||||
|
viewport: {
|
||||||
|
scrollElementRef: scrollRef,
|
||||||
|
observerRoot: scrollRef,
|
||||||
|
scrollMargin: 0,
|
||||||
|
virtualizationEnabled: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(capturedRoots.length).toBeGreaterThan(0);
|
||||||
|
expect(capturedRoots.every((r) => r === scrollHost)).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
scrollHost.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue