diff --git a/src/app/Components/ArtworkGrids/GenericGrid.tsx b/src/app/Components/ArtworkGrids/GenericGrid.tsx index 2b5100c7965..c6d788ca5f1 100644 --- a/src/app/Components/ArtworkGrids/GenericGrid.tsx +++ b/src/app/Components/ArtworkGrids/GenericGrid.tsx @@ -27,6 +27,7 @@ interface Props { saleInfoTextStyle?: TextProps fitToFrame?: boolean onItemVisibilityChange?: (artworkID: string, index: number, visible: boolean) => void + useLiveVisibilityTracking?: boolean } type PropsForArtwork = Omit @@ -45,6 +46,7 @@ export const GenericGrid: React.FC = ({ trackingFlow, fitToFrame = false, onItemVisibilityChange, + useLiveVisibilityTracking, }) => { const space = useSpace() const artworks = useFragment(genericGridFragment, artworksProp) @@ -69,6 +71,7 @@ export const GenericGrid: React.FC = ({ trackingFlow={trackingFlow} fitToFrame={fitToFrame} onItemVisibilityChange={onItemVisibilityChange} + useLiveVisibilityTracking={useLiveVisibilityTracking} /> {isLoading ? : null} diff --git a/src/app/Components/ArtworkGrids/MasonryArtworkGridItem.tsx b/src/app/Components/ArtworkGrids/MasonryArtworkGridItem.tsx index fddf65d7556..ad8b02020ef 100644 --- a/src/app/Components/ArtworkGrids/MasonryArtworkGridItem.tsx +++ b/src/app/Components/ArtworkGrids/MasonryArtworkGridItem.tsx @@ -5,10 +5,12 @@ import ArtworkGridItem, { ArtworkProps, PriceOfferMessage, } from "app/Components/ArtworkGrids/ArtworkGridItem" +import { useGridItemVisibility } from "app/Components/ArtworkGrids/useGridItemVisibility" import { PartnerOffer } from "app/Scenes/Activity/components/PartnerOfferCreatedNotification" import { Sentinel } from "app/utils/Sentinel" import { NUM_COLUMNS_MASONRY } from "app/utils/masonryHelpers" -import { ViewProps } from "react-native" +import { useRef } from "react" +import { View, ViewProps } from "react-native" import { FragmentRefs } from "relay-runtime" interface Artwork { @@ -46,6 +48,13 @@ interface MasonryArtworkGridItemProps extends Omit { * Use for impression tracking in nested, non-scroll grids. */ onItemVisibilityChange?: (artworkInternalID: string, index: number, visible: boolean) => void + /** + * Whether this grid needs repeatable visibility detection (i.e. NWFY) — items that stay on + * screen across a live refresh re-fire onItemVisibilityChange, via useGridItemVisibility instead + * of the shared Sentinel, which only ever fires its first "true" transition (fine for one-shot + * consumers elsewhere). Leave undefined to keep the default Sentinel-based behavior. + */ + useLiveVisibilityTracking?: boolean } export const MasonryArtworkGridItem: React.FC = ({ @@ -63,6 +72,7 @@ export const MasonryArtworkGridItem: React.FC = ({ partnerOffer, priceOfferMessage, onItemVisibilityChange, + useLiveVisibilityTracking, ...rest }) => { const space = useSpace() @@ -73,9 +83,26 @@ export const MasonryArtworkGridItem: React.FC = ({ const imgWidth = numColumns === 1 ? width : width / numColumns - space(2) - space(1) const imgHeight = imgWidth / imgAspectRatio + const contentRef = useRef(null) + const usesGridItemVisibility = !!onItemVisibilityChange && !!useLiveVisibilityTracking + + // Measures the existing content Flex directly (via `ref` below) rather than an extra wrapping + // View — an earlier version wrapped the content in its own View to get a real height to measure, + // but that confused FlashList's masonry column-height measurement and pushed later items off + // screen. Called unconditionally (rules of hooks); it's a no-op unless `usesGridItemVisibility`. + useGridItemVisibility({ + ref: contentRef, + threshold: 0.5, + enabled: usesGridItemVisibility, + onVisibilityChange: (visible) => { + onItemVisibilityChange?.(item.internalID || item.id, index, visible) + }, + }) + return ( <> = ({ priceOfferMessage={priceOfferMessage} /> - {!!onItemVisibilityChange && ( + {!!onItemVisibilityChange && !usesGridItemVisibility && ( onItemVisibilityChange(item.internalID || item.id, index, visible)} diff --git a/src/app/Components/ArtworkGrids/MasonryInfiniteScrollArtworkGrid.tsx b/src/app/Components/ArtworkGrids/MasonryInfiniteScrollArtworkGrid.tsx index 11143499d7a..b0d9493fd2c 100644 --- a/src/app/Components/ArtworkGrids/MasonryInfiniteScrollArtworkGrid.tsx +++ b/src/app/Components/ArtworkGrids/MasonryInfiniteScrollArtworkGrid.tsx @@ -47,6 +47,7 @@ interface MasonryInfiniteScrollArtworkGridProps extends MasonryFlashListOmittedP trackingFlow?: string fitToFrame?: boolean onItemVisibilityChange?: (artworkID: string, index: number, visible: boolean) => void + useLiveVisibilityTracking?: boolean } /** @@ -91,6 +92,7 @@ export const MasonryInfiniteScrollArtworkGrid: React.FC { const space = useSpace() @@ -141,6 +143,7 @@ export const MasonryInfiniteScrollArtworkGrid: React.FC ), @@ -170,6 +173,7 @@ export const MasonryInfiniteScrollArtworkGrid: React.FC + /** The value indicates the minimum percentage of the container that must be visible (vertically or horizontally). A value of 1 means 100%, 0.7 means 70%, and so forth. The default value is 1 (100%). */ + threshold?: number + enabled: boolean + onVisibilityChange: (visible: boolean) => void +} + +/** + * Purpose-built alternative to the shared `Sentinel` for grid items that need repeatable + * visibility detection. `Sentinel`'s own polling effect has an empty dependency array, so its + * internal comparison value freezes at mount and it can only ever report one "true" transition for + * its entire lifetime — harmless for its other, fire-once consumers elsewhere in the app, but not + * for a rail that live-refreshes and needs to re-track items that are already on screen. + * + * Reports current visibility on every poll tick, unconditionally, rather than only on a change — + * so it doesn't need to be told when a live refresh happens at all. The caller's own "already + * tracked" guard (reset on refresh) is what decides whether a given report actually turns into an + * event; a redundant "still visible" report while that guard is set is just a harmless no-op. + * + * Implemented as a hook (rather than a component wrapping the item in an extra View, as an earlier + * version did) specifically so it doesn't change the rendered tree shape: the caller attaches + * `ref` directly to its existing content container. Wrapping the content in an additional View + * confused FlashList's masonry column-height measurement, pushing later items far off-screen. + */ +export const useGridItemVisibility = ({ + ref, + threshold = 1, + enabled, + onVisibilityChange, +}: UseGridItemVisibilityArgs) => { + const insets = useSafeAreaInsets() + const isFocused = useIsFocused() + + // Read via a ref so the poll below always calls the latest version, regardless of when its + // closure was created. + const onVisibilityChangeRef = useRef(onVisibilityChange) + onVisibilityChangeRef.current = onVisibilityChange + + const measure = useCallback( + (report: (visible: boolean) => void) => { + ref.current?.measure( + (_x: number, _y: number, width: number, height: number, pageX: number, pageY: number) => { + const window = Dimensions.get("window") + // The visible content area ends above the bottom tab bar, so an item sitting behind it + // shouldn't count toward the visible fraction — using the raw window height here let + // items near the bottom tab bar read as "50%+ visible" when most of that overlap was + // actually hidden behind the tab bar. + const viewportHeight = window.height - BOTTOM_TABS_HEIGHT - insets.bottom + + if (height <= 0 || width <= 0) { + report(false) + return + } + + // Overlap between the item's bounds and the viewport on each axis, as a fraction of the + // item's own size — symmetric at both edges, unlike a pair of one-sided inequalities + // (e.g. a hard `pageY >= 0` for the top combined with a thresholded check at the bottom + // only), which would under-report items that are mostly visible but clipped at the top. + const visibleHeight = Math.max( + 0, + Math.min(pageY + height, viewportHeight) - Math.max(pageY, 0) + ) + const visibleWidth = Math.max( + 0, + Math.min(pageX + width, window.width) - Math.max(pageX, 0) + ) + + const isVisible = visibleHeight >= height * threshold && visibleWidth >= width * threshold + + report(isVisible) + } + ) + }, + [threshold, ref, insets.bottom] + ) + + // Paused while Home isn't focused (e.g. a pushed artwork screen on top of it) — react-native- + // screens freezes the Home tree in that state without unmounting it, so this interval would + // otherwise keep firing a native measure() every second for as long as the user is elsewhere. + useEffect(() => { + if (!enabled || !isFocused) { + return + } + + const interval = setInterval(() => { + measure((visible) => onVisibilityChangeRef.current(visible)) + }, POLL_INTERVAL) + + return () => clearInterval(interval) + }, [enabled, isFocused, measure]) +} diff --git a/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx b/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx index de00fb6666f..04c0605f4f7 100644 --- a/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx +++ b/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx @@ -1,19 +1,30 @@ import { ContextModule } from "@artsy/cohesion" +import { useIsFocused } from "@react-navigation/native" import { HomeViewStore } from "app/Scenes/HomeView/HomeViewContext" import { useHomeViewTracking } from "app/Scenes/HomeView/hooks/useHomeViewTracking" import { Sentinel } from "app/utils/Sentinel" -import React, { useCallback } from "react" +import React, { useCallback, useEffect, useRef } from "react" +import { Dimensions, View } from "react-native" + +const POLL_INTERVAL = 1000 interface HomeViewSectionSentinelProps { contextModule: ContextModule sectionType?: string index: number + /** + * Whether this is one of the "live" rails (WTYL, NWFY) that refetch and re-fire tracking after + * an interaction. Only these need repeatable visibility tracking (see below) — every other + * section renders the shared `Sentinel` unchanged. + */ + isLiveRefreshRail?: boolean } export const HomeViewSectionSentinel: React.FC = ({ contextModule, sectionType, index, + isLiveRefreshRail = false, }) => { const { viewedSection } = useHomeViewTracking() const addTrackedSection = HomeViewStore.useStoreActions((actions) => actions.addTrackedSection) @@ -22,6 +33,9 @@ export const HomeViewSectionSentinel: React.FC = ( (actions) => actions.addTrackedSectionTypes ) + const markerRef = useRef(null) + const isFocused = useIsFocused() + const handleVisibilityChange = useCallback( (visible: boolean) => { if (visible && !trackedSections.includes(contextModule)) { @@ -32,8 +46,56 @@ export const HomeViewSectionSentinel: React.FC = ( } } }, - [contextModule, viewedSection, addTrackedSection, trackedSections] + [ + contextModule, + viewedSection, + addTrackedSection, + trackedSections, + index, + sectionType, + addTrackedSectionTypes, + ] ) + // Read via a ref inside the interval below so it always calls the latest version (the poll + // effect itself only depends on `isLiveRefreshRail`, not on this callback's own dependencies). + const handleVisibilityChangeRef = useRef(handleVisibilityChange) + handleVisibilityChangeRef.current = handleVisibilityChange + + // Live rails need to detect visibility repeatedly: on the original scroll-into-view, then again + // after every live refresh (once the parent resets the "tracked" guard). The shared `Sentinel` + // only ever fires its first "true" transition — a stale-closure bug in its own polling effect + // freezes its comparison value at mount, so it can never detect a later re-entry into the + // viewport. That's harmless for Sentinel's other, fire-once consumers elsewhere in the app, but + // not for these always-live sections, so we poll directly here instead, scoped to just this case. + // + // Reports current visibility on every tick, unconditionally, rather than only on a change — so a + // live refresh doesn't need to explicitly tell this component anything. `handleVisibilityChange`'s + // own "already tracked" guard (reset by the parent on refresh) is what decides whether a given + // report actually fires railViewed; a redundant "still visible" report is a harmless no-op. + // Paused while Home isn't focused (e.g. a pushed artwork screen on top of it) — react-native- + // screens freezes the Home tree in that state without unmounting it, so this interval would + // otherwise keep firing a native measure() every second for as long as the user is elsewhere. + useEffect(() => { + if (!isLiveRefreshRail || !isFocused) { + return + } + + const interval = setInterval(() => { + markerRef.current?.measure( + (_x: number, _y: number, _width: number, height: number, _pageX: number, pageY: number) => { + const windowHeight = Dimensions.get("window").height + handleVisibilityChangeRef.current(pageY + height > 0 && pageY < windowHeight) + } + ) + }, POLL_INTERVAL) + + return () => clearInterval(interval) + }, [isLiveRefreshRail, isFocused]) + + if (isLiveRefreshRail) { + return + } + return } diff --git a/src/app/Scenes/HomeView/HomeViewContext.ts b/src/app/Scenes/HomeView/HomeViewContext.ts index 3c00ee35c3b..4c6cafa110d 100644 --- a/src/app/Scenes/HomeView/HomeViewContext.ts +++ b/src/app/Scenes/HomeView/HomeViewContext.ts @@ -7,6 +7,7 @@ export interface HomeViewStoreModel { viewableSections: string[] liveRefetchKey: number addTrackedSection: Action + removeTrackedSection: Action addTrackedSectionTypes: Action addTrackedExperiment: Action setViewableSections: Action @@ -26,6 +27,12 @@ export const HomeViewStoreModel: HomeViewStoreModel = { } state.trackedSections.push(payload) }), + removeTrackedSection: action((state, payload) => { + if (!state.trackedSections.includes(payload)) { + return + } + state.trackedSections = state.trackedSections.filter((section) => section !== payload) + }), addTrackedSectionTypes: action((state, payload) => { if (state.trackedSectionTypes.includes(payload)) { return diff --git a/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworks.tsx b/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworks.tsx index eaa76d2bb18..c1e4cab4ffa 100644 --- a/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworks.tsx +++ b/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworks.tsx @@ -33,7 +33,7 @@ import { NoFallback, withSuspense } from "app/utils/hooks/withSuspense" import { isDislikeArtworksEnabledFor } from "app/utils/isDislikeArtworksEnabledFor" import { useMemoizedRandom } from "app/utils/placeholders" import { times } from "lodash" -import { memo, useEffect, useRef } from "react" +import { memo, useEffect, useState } from "react" import { fetchQuery, graphql, useFragment, useLazyLoadQuery } from "react-relay" interface HomeViewSectionArtworksProps extends FlexProps { @@ -64,9 +64,9 @@ export const HomeViewSectionArtworks: React.FC = ( const isRailInViewport = viewableSections.includes(section.internalID) - // Ref so the async `complete` callback reads current visibility, not the value at kick-off. - const isRailInViewportRef = useRef(isRailInViewport) - isRailInViewportRef.current = isRailInViewport + const removeTrackedSection = HomeViewStore.useStoreActions( + (actions) => actions.removeTrackedSection + ) const { onViewableItemsChanged, viewabilityConfig, resetTracking } = useItemsImpressionsTracking({ // It is important here to tell if the rail is visible or not, because the viewability config @@ -76,10 +76,15 @@ export const HomeViewSectionArtworks: React.FC = ( contextModule, }) + // Bumped when the forced refetch completes, so HomeViewSectionArtworksGrid clears its per-item + // "already tracked" guard (only relevant for the grid path — NWFY). + const [liveRefetchCompletionKey, setLiveRefetchCompletionKey] = useState(0) + // On a refetch bump, force-fetch this rail. This effect runs per-section, so every live rail // (recommended, new works for you, and any future ones in liveSectionIDs) refetches — in view - // or not — so no live rail goes stale. On complete, reset the impression guard and re-fire - // railViewed only if the rail is on screen. + // or not — so no live rail goes stale. On complete, reset both impression guards: the per-item + // guard (so items already tracked can re-fire itemViewed) and the once-ever section guard (so a + // later scroll-into-view re-fires railViewed too, not just an already-on-screen rail). useEffect(() => { if (!isLiveRefreshRail || liveRefetchKey === 0) { return @@ -93,10 +98,8 @@ export const HomeViewSectionArtworks: React.FC = ( ).subscribe({ complete: () => { resetTracking() - - if (isRailInViewportRef.current) { - tracking.viewedSection(contextModule, index) - } + removeTrackedSection(contextModule) + setLiveRefetchCompletionKey((key) => key + 1) }, error: (error: Error) => { console.error("Failed to refresh live artworks rail", error) @@ -182,6 +185,7 @@ export const HomeViewSectionArtworks: React.FC = ( onArtworkPress={handleOnGridArtworkPress} trackItemImpressions={section.trackItemImpressions} contextModule={contextModule} + refreshKey={isLiveRefreshRail ? liveRefetchCompletionKey : undefined} /> ) : ( = ( contextModule={contextModule} sectionType={section.__typename} index={index} + isLiveRefreshRail={isLiveRefreshRail} /> ) diff --git a/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworksGrid.tsx b/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworksGrid.tsx index 2e843997adf..af3f5e547a5 100644 --- a/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworksGrid.tsx +++ b/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworksGrid.tsx @@ -6,7 +6,7 @@ import GenericGrid from "app/Components/ArtworkGrids/GenericGrid" import HomeAnalytics from "app/Scenes/HomeView/helpers/homeAnalytics" import { RouterLink } from "app/system/navigation/RouterLink" import { useFeatureFlag } from "app/utils/hooks/useFeatureFlag" -import { useLayoutEffect, useRef, useState } from "react" +import { useEffect, useLayoutEffect, useRef, useState } from "react" import { View } from "react-native" import { useTracking } from "react-tracking" @@ -21,6 +21,12 @@ interface HomeViewSectionArtworksGridProps { ) => void trackItemImpressions?: boolean contextModule: ContextModule + /** + * Bumped by the parent every time a forced live-refresh of this section completes. Clears the + * per-item "already tracked" guard so items still on screen re-fire itemViewed for the fresh + * data, instead of waiting for a visibility transition that may never come. + */ + refreshKey?: number } export const HomeViewSectionArtworksGrid: React.FC = ({ @@ -30,6 +36,7 @@ export const HomeViewSectionArtworksGrid: React.FC { const [hasGridLaidOut, setHasGridLaidOut] = useState(false) const gridContainerRef = useRef(null) @@ -37,6 +44,14 @@ export const HomeViewSectionArtworksGrid: React.FC>(new Set()).current + useEffect(() => { + if (!refreshKey) { + return + } + + trackedGridItems.clear() + }, [refreshKey, trackedGridItems]) + useLayoutEffect(() => { gridContainerRef.current?.measureInWindow((_x, _y, _width, height) => { if (height > 0) { @@ -84,6 +99,7 @@ export const HomeViewSectionArtworksGrid: React.FC {hasGridLaidOut ? ( diff --git a/src/app/Scenes/HomeView/Sections/__tests__/HomeViewSectionArtworks.tests.tsx b/src/app/Scenes/HomeView/Sections/__tests__/HomeViewSectionArtworks.tests.tsx index 3606fa82cfd..0aadf4cbd56 100644 --- a/src/app/Scenes/HomeView/Sections/__tests__/HomeViewSectionArtworks.tests.tsx +++ b/src/app/Scenes/HomeView/Sections/__tests__/HomeViewSectionArtworks.tests.tsx @@ -19,6 +19,7 @@ import { Actions } from "easy-peasy" import { useEffect } from "react" import { FlatList } from "react-native" import { graphql } from "react-relay" +import { ReactTestInstance } from "react-test-renderer" import { MockPayloadGenerator } from "relay-test-utils" jest.mock("app/Scenes/HomeView/hooks/useLiveHomeViewSectionIDs", () => ({ @@ -46,6 +47,19 @@ const HomeViewStoreVisitor: React.FC = () => { return null } +// HomeViewSectionSentinel measures its own marker view directly (see justification in the PR) to +// decide if the section is on screen for the post-refresh railViewed re-fire — real native +// `.measure()` is a no-op in the RN jest environment, so tests stub it on the marker instance. +const mockSectionOnScreen = async (root: ReactTestInstance, onScreen: boolean) => { + const marker = await root.findByProps({ testID: "home-view-section-sentinel-marker" }) + ;(marker.instance as { measure: (...args: any[]) => void }).measure = (callback) => + callback(0, 0, 100, onScreen ? 100 : 50, 0, onScreen ? 0 : -500) +} + +// requestAnimationFrame is mocked as `setTimeout(cb, 0)` in the RN jest environment, so the +// refresh re-check effect's measurement runs on a real (if immediate) timer tick. +const flushRaf = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))) + describe("HomeViewSectionArtworks", () => { beforeEach(() => { __globalStoreTestUtils__?.injectFeatureFlags({ AREnableHidingDislikedArtworks: true }) @@ -425,12 +439,12 @@ describe("HomeViewSectionArtworks", () => { setLiveSectionIDs([]) }) - it("re-fires railViewed only after the live refresh completes", () => { - const { env } = renderWithRelay({ + it("re-fires railViewed only after the live refresh completes", async () => { + const { env, UNSAFE_root } = renderWithRelay({ HomeViewSectionArtworks: () => RECOMMENDED_SECTION, }) - homeViewStoreActions.setViewableSections(["home-view-section-recommended-artworks"]) + mockSectionOnScreen(UNSAFE_root, true) mockTrackEvent.mockClear() act(() => { @@ -450,6 +464,7 @@ describe("HomeViewSectionArtworks", () => { }) ) }) + await flushRaf() expect(mockTrackEvent).toHaveBeenCalledWith({ action: "railViewed", @@ -459,13 +474,13 @@ describe("HomeViewSectionArtworks", () => { }) }) - it("does not re-fire railViewed on refresh when the WTYL rail is off screen", () => { + it("does not re-fire railViewed on refresh when the WTYL rail is off screen", async () => { // The refresh-driven railViewed re-fire only happens while the rail is actually on screen. - const { env } = renderWithRelay({ + const { env, UNSAFE_root } = renderWithRelay({ HomeViewSectionArtworks: () => RECOMMENDED_SECTION, }) - homeViewStoreActions.setViewableSections([]) + mockSectionOnScreen(UNSAFE_root, false) mockTrackEvent.mockClear() act(() => { @@ -479,6 +494,7 @@ describe("HomeViewSectionArtworks", () => { }) ) }) + await flushRaf() // railViewed should not fire because the rail is off screen. expect(mockTrackEvent).not.toHaveBeenCalledWith( @@ -486,15 +502,15 @@ describe("HomeViewSectionArtworks", () => { ) }) - it("re-fires railViewed on every refresh while the rail is in view", () => { - const { env } = renderWithRelay({ + it("re-fires railViewed on every refresh while the rail is in view", async () => { + const { env, UNSAFE_root } = renderWithRelay({ HomeViewSectionArtworks: () => RECOMMENDED_SECTION, }) - homeViewStoreActions.setViewableSections(["home-view-section-recommended-artworks"]) + mockSectionOnScreen(UNSAFE_root, true) mockTrackEvent.mockClear() - const refresh = () => { + const refresh = async () => { act(() => { homeViewStoreActions.bumpLiveRefetchKey() }) @@ -505,11 +521,12 @@ describe("HomeViewSectionArtworks", () => { }) ) }) + await flushRaf() } // Two separate returns to home, both with the rail in view. - refresh() - refresh() + await refresh() + await refresh() const railViewedCalls = mockTrackEvent.mock.calls.filter( (call) => (call[0] as any)?.action === "railViewed" @@ -655,12 +672,12 @@ describe("HomeViewSectionArtworks", () => { setLiveSectionIDs([]) }) - it("re-fires railViewed only after the live refresh completes", () => { - const { env } = renderWithRelay({ + it("re-fires railViewed only after the live refresh completes", async () => { + const { env, UNSAFE_root } = renderWithRelay({ HomeViewSectionArtworks: () => NWFY_SECTION, }) - homeViewStoreActions.setViewableSections([NEW_WORKS_FOR_YOU_SECTION_ID]) + mockSectionOnScreen(UNSAFE_root, true) mockTrackEvent.mockClear() act(() => { @@ -679,6 +696,7 @@ describe("HomeViewSectionArtworks", () => { }) ) }) + await flushRaf() expect(mockTrackEvent).toHaveBeenCalledWith({ action: "railViewed",