From 7c3a8c880988d6754bef928e8336ff1cab82bc31 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Thu, 23 Jul 2026 18:53:00 +0200 Subject: [PATCH 1/8] chore: adjust reail_viewed impressions tracking for wtyl --- .../Components/HomeViewSectionSentinel.tsx | 45 +++++++++++++++++-- .../Sections/HomeViewSectionArtworks.tsx | 20 ++++----- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx b/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx index de00fb6666f..3a2267ec6bb 100644 --- a/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx +++ b/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx @@ -2,18 +2,25 @@ import { ContextModule } from "@artsy/cohesion" 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" interface HomeViewSectionSentinelProps { contextModule: ContextModule sectionType?: string index: number + /** + * Bumped by the parent every time a forced live-refresh of this section completes. We re-fire + * railViewed if the section is on screen at that moment. + */ + refreshKey?: number } export const HomeViewSectionSentinel: React.FC = ({ contextModule, sectionType, index, + refreshKey = 0, }) => { const { viewedSection } = useHomeViewTracking() const addTrackedSection = HomeViewStore.useStoreActions((actions) => actions.addTrackedSection) @@ -22,6 +29,8 @@ export const HomeViewSectionSentinel: React.FC = ( (actions) => actions.addTrackedSectionTypes ) + const markerRef = useRef(null) + const handleVisibilityChange = useCallback( (visible: boolean) => { if (visible && !trackedSections.includes(contextModule)) { @@ -32,8 +41,38 @@ export const HomeViewSectionSentinel: React.FC = ( } } }, - [contextModule, viewedSection, addTrackedSection, trackedSections] + [contextModule, viewedSection, addTrackedSection, trackedSections, index, sectionType] ) - return + // On a live refresh, re-fire railViewed if this section is on screen right now. We re-measure the + // section's actual position directly instead of relying on `viewableSections` (populated by the + // home list's own viewability callback), which can still hold a stale value from before + // navigating away right as the screen regains focus and the refetch completes. + useEffect(() => { + if (refreshKey === 0) { + return + } + + const raf = requestAnimationFrame(() => { + markerRef.current?.measure( + (_x: number, _y: number, _width: number, height: number, _pageX: number, pageY: number) => { + const windowHeight = Dimensions.get("window").height + const isOnScreen = pageY + height > 0 && pageY < windowHeight + + if (isOnScreen) { + viewedSection(contextModule, index) + } + } + ) + }) + + return () => cancelAnimationFrame(raf) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [refreshKey]) + + return ( + + + + ) } diff --git a/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworks.tsx b/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworks.tsx index eaa76d2bb18..8164028b5be 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,10 +64,6 @@ 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 { onViewableItemsChanged, viewabilityConfig, resetTracking } = useItemsImpressionsTracking({ // It is important here to tell if the rail is visible or not, because the viewability config // default behavior, doesn't take into account the fact that the rail could be not visible @@ -76,10 +72,14 @@ export const HomeViewSectionArtworks: React.FC = ( contextModule, }) + // Bumped when the forced refetch completes, so HomeViewSectionSentinel re-measures and re-fires + // railViewed for this section (see the effect there — it doesn't trust `viewableSections`, which + // can be stale right as the screen regains focus and the refetch completes). + 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 the impression guard. useEffect(() => { if (!isLiveRefreshRail || liveRefetchKey === 0) { return @@ -93,10 +93,7 @@ export const HomeViewSectionArtworks: React.FC = ( ).subscribe({ complete: () => { resetTracking() - - if (isRailInViewportRef.current) { - tracking.viewedSection(contextModule, index) - } + setLiveRefetchCompletionKey((key) => key + 1) }, error: (error: Error) => { console.error("Failed to refresh live artworks rail", error) @@ -205,6 +202,7 @@ export const HomeViewSectionArtworks: React.FC = ( contextModule={contextModule} sectionType={section.__typename} index={index} + refreshKey={liveRefetchCompletionKey} /> ) From 21d5a1c08f5ab22040a6b867ec74afb26fa180a8 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Thu, 23 Jul 2026 19:21:23 +0200 Subject: [PATCH 2/8] feat: track rail_viewed for live sections after pull to refresh --- .../Components/HomeViewSectionSentinel.tsx | 91 ++++++++++++++----- src/app/Scenes/HomeView/HomeViewContext.ts | 4 + .../Sections/HomeViewSectionArtworks.tsx | 15 ++- 3 files changed, 83 insertions(+), 27 deletions(-) diff --git a/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx b/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx index 3a2267ec6bb..0b35105a6b8 100644 --- a/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx +++ b/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx @@ -5,13 +5,21 @@ import { Sentinel } from "app/utils/Sentinel" 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 /** - * Bumped by the parent every time a forced live-refresh of this section completes. We re-fire - * railViewed if the section is on screen at that moment. + * 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 + /** + * Bumped by the parent every time a forced live-refresh of this section completes. Only used + * when `isLiveRefreshRail` is true. */ refreshKey?: number } @@ -20,6 +28,7 @@ export const HomeViewSectionSentinel: React.FC = ( contextModule, sectionType, index, + isLiveRefreshRail = false, refreshKey = 0, }) => { const { viewedSection } = useHomeViewTracking() @@ -30,6 +39,7 @@ export const HomeViewSectionSentinel: React.FC = ( ) const markerRef = useRef(null) + const isVisibleRef = useRef(false) const handleVisibilityChange = useCallback( (visible: boolean) => { @@ -44,35 +54,68 @@ export const HomeViewSectionSentinel: React.FC = ( [contextModule, viewedSection, addTrackedSection, trackedSections, index, sectionType] ) - // On a live refresh, re-fire railViewed if this section is on screen right now. We re-measure the - // section's actual position directly instead of relying on `viewableSections` (populated by the - // home list's own viewability callback), which can still hold a stale value from before - // navigating away right as the screen regains focus and the refetch completes. + // 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 + + const measure = useCallback((report: (visible: boolean) => void) => { + markerRef.current?.measure( + (_x: number, _y: number, _width: number, height: number, _pageX: number, pageY: number) => { + const windowHeight = Dimensions.get("window").height + report(pageY + height > 0 && pageY < windowHeight) + } + ) + }, []) + + // 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. useEffect(() => { - if (refreshKey === 0) { + if (!isLiveRefreshRail) { return } - const raf = requestAnimationFrame(() => { - markerRef.current?.measure( - (_x: number, _y: number, _width: number, height: number, _pageX: number, pageY: number) => { - const windowHeight = Dimensions.get("window").height - const isOnScreen = pageY + height > 0 && pageY < windowHeight - - if (isOnScreen) { - viewedSection(contextModule, index) - } + const interval = setInterval(() => { + measure((visible) => { + if (visible !== isVisibleRef.current) { + isVisibleRef.current = visible + handleVisibilityChangeRef.current(visible) } - ) + }) + }, POLL_INTERVAL) + + return () => clearInterval(interval) + }, [isLiveRefreshRail, measure]) + + // On a live refresh, re-check whether this section is on screen right now (the parent has already + // reset the "tracked" guard for us). We re-measure the section's actual position directly instead + // of relying on `viewableSections` (populated by the home list's own viewability callback), which + // can still hold a stale value from before navigating away right as the screen regains focus and + // the refetch completes. If the section isn't on screen at this exact moment (e.g. a pull-to- + // refresh triggered from the top of the list), this is a no-op — the guard reset means the poll + // above will pick it up on a later scroll-into-view instead. + useEffect(() => { + if (!isLiveRefreshRail || refreshKey === 0) { + return + } + + const raf = requestAnimationFrame(() => { + measure((visible) => { + isVisibleRef.current = visible + handleVisibilityChangeRef.current(visible) + }) }) return () => cancelAnimationFrame(raf) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [refreshKey]) + }, [isLiveRefreshRail, refreshKey, measure]) - return ( - - - - ) + if (isLiveRefreshRail) { + return + } + + return } diff --git a/src/app/Scenes/HomeView/HomeViewContext.ts b/src/app/Scenes/HomeView/HomeViewContext.ts index 3c00ee35c3b..231bee4aac3 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,9 @@ export const HomeViewStoreModel: HomeViewStoreModel = { } state.trackedSections.push(payload) }), + removeTrackedSection: action((state, payload) => { + 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 8164028b5be..d915bc4e38e 100644 --- a/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworks.tsx +++ b/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworks.tsx @@ -64,6 +64,10 @@ export const HomeViewSectionArtworks: React.FC = ( const isRailInViewport = viewableSections.includes(section.internalID) + 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 // default behavior, doesn't take into account the fact that the rail could be not visible @@ -73,13 +77,16 @@ export const HomeViewSectionArtworks: React.FC = ( }) // Bumped when the forced refetch completes, so HomeViewSectionSentinel re-measures and re-fires - // railViewed for this section (see the effect there — it doesn't trust `viewableSections`, which - // can be stale right as the screen regains focus and the refetch completes). + // railViewed if this section happens to be on screen at that exact moment (see the effect there — + // it doesn't trust `viewableSections`, which can be stale right as the screen regains focus and + // the refetch completes). 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. + // 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,6 +100,7 @@ export const HomeViewSectionArtworks: React.FC = ( ).subscribe({ complete: () => { resetTracking() + removeTrackedSection(contextModule) setLiveRefetchCompletionKey((key) => key + 1) }, error: (error: Error) => { @@ -202,6 +210,7 @@ export const HomeViewSectionArtworks: React.FC = ( contextModule={contextModule} sectionType={section.__typename} index={index} + isLiveRefreshRail={isLiveRefreshRail} refreshKey={liveRefetchCompletionKey} /> From ac61585eff3963a27b4da68da25f33568dd2f7ca Mon Sep 17 00:00:00 2001 From: dariakoko Date: Thu, 23 Jul 2026 20:38:11 +0200 Subject: [PATCH 3/8] feat: nwfy impressions tracking on refresh and pull to refresh --- .../Components/ArtworkGrids/GenericGrid.tsx | 3 + .../GridItemVisibilitySentinel.tsx | 96 +++++++++++++++++++ .../ArtworkGrids/MasonryArtworkGridItem.tsx | 34 +++++-- .../MasonryInfiniteScrollArtworkGrid.tsx | 4 + .../Sections/HomeViewSectionArtworks.tsx | 1 + .../Sections/HomeViewSectionArtworksGrid.tsx | 18 +++- 6 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 src/app/Components/ArtworkGrids/GridItemVisibilitySentinel.tsx diff --git a/src/app/Components/ArtworkGrids/GenericGrid.tsx b/src/app/Components/ArtworkGrids/GenericGrid.tsx index 2b5100c7965..a3223339eb1 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 + refreshKey?: number } type PropsForArtwork = Omit @@ -45,6 +46,7 @@ export const GenericGrid: React.FC = ({ trackingFlow, fitToFrame = false, onItemVisibilityChange, + refreshKey, }) => { const space = useSpace() const artworks = useFragment(genericGridFragment, artworksProp) @@ -69,6 +71,7 @@ export const GenericGrid: React.FC = ({ trackingFlow={trackingFlow} fitToFrame={fitToFrame} onItemVisibilityChange={onItemVisibilityChange} + refreshKey={refreshKey} /> {isLoading ? : null} diff --git a/src/app/Components/ArtworkGrids/GridItemVisibilitySentinel.tsx b/src/app/Components/ArtworkGrids/GridItemVisibilitySentinel.tsx new file mode 100644 index 00000000000..05812e4698a --- /dev/null +++ b/src/app/Components/ArtworkGrids/GridItemVisibilitySentinel.tsx @@ -0,0 +1,96 @@ +import { FC, ReactNode, useCallback, useEffect, useRef } from "react" +import { Dimensions, View } from "react-native" + +const POLL_INTERVAL = 1000 + +interface GridItemVisibilitySentinelProps { + children?: ReactNode + /** 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 + onVisibilityChange: (visible: boolean) => void + /** Bump to force an immediate re-check of this item's current visibility, e.g. after a live refresh where an already-visible item wouldn't otherwise produce a transition. */ + refreshKey?: number +} + +/** + * Purpose-built alternative to the shared `Sentinel` for grid items that need repeatable + * visibility detection (entering AND leaving the viewport, more than once per mount). `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. Kept as a separate component so `Sentinel` + * itself stays untouched for everyone else. + */ +export const GridItemVisibilitySentinel: FC = ({ + children, + threshold = 1, + onVisibilityChange, + refreshKey = 0, +}) => { + const viewRef = useRef(null) + const isVisibleRef = useRef(false) + + // Read via a ref so the poll/refresh effects below (which only depend on `measure`) always call + // the latest version, regardless of when their closures were created. + const onVisibilityChangeRef = useRef(onVisibilityChange) + onVisibilityChangeRef.current = onVisibilityChange + + const measure = useCallback( + (report: (visible: boolean) => void) => { + viewRef.current?.measure( + (_x: number, _y: number, width: number, height: number, pageX: number, pageY: number) => { + const window = Dimensions.get("window") + + const isVisible = + pageY + height != 0 && + pageY >= 0 && + pageY + height - height * (1 - threshold) <= window.height && + pageX + width > 0 && + pageX + width - width * (1 - threshold) <= window.width + + report(isVisible) + } + ) + }, + [threshold] + ) + + // Poll for visibility transitions, using a ref (not React state) as the comparison value so this + // correctly detects repeated entering/leaving of the viewport rather than freezing after the + // first transition. + useEffect(() => { + const interval = setInterval(() => { + measure((visible) => { + if (visible !== isVisibleRef.current) { + isVisibleRef.current = visible + onVisibilityChangeRef.current(visible) + } + }) + }, POLL_INTERVAL) + + return () => clearInterval(interval) + }, [measure]) + + // On a live refresh, re-report current visibility even if it hasn't changed (an item that stays + // on screen produces no transition for the poll above to catch). + useEffect(() => { + if (refreshKey === 0) { + return + } + + const raf = requestAnimationFrame(() => { + measure((visible) => { + isVisibleRef.current = visible + onVisibilityChangeRef.current(visible) + }) + }) + + return () => cancelAnimationFrame(raf) + }, [refreshKey, measure]) + + return ( + + {children} + + ) +} diff --git a/src/app/Components/ArtworkGrids/MasonryArtworkGridItem.tsx b/src/app/Components/ArtworkGrids/MasonryArtworkGridItem.tsx index fddf65d7556..f1f7236000b 100644 --- a/src/app/Components/ArtworkGrids/MasonryArtworkGridItem.tsx +++ b/src/app/Components/ArtworkGrids/MasonryArtworkGridItem.tsx @@ -5,6 +5,7 @@ import ArtworkGridItem, { ArtworkProps, PriceOfferMessage, } from "app/Components/ArtworkGrids/ArtworkGridItem" +import { GridItemVisibilitySentinel } from "app/Components/ArtworkGrids/GridItemVisibilitySentinel" import { PartnerOffer } from "app/Scenes/Activity/components/PartnerOfferCreatedNotification" import { Sentinel } from "app/utils/Sentinel" import { NUM_COLUMNS_MASONRY } from "app/utils/masonryHelpers" @@ -46,6 +47,15 @@ interface MasonryArtworkGridItemProps extends Omit { * Use for impression tracking in nested, non-scroll grids. */ onItemVisibilityChange?: (artworkInternalID: string, index: number, visible: boolean) => void + /** + * Bump to force this item to re-report its current visibility (e.g. after a live refresh). + * Only meaningful together with onItemVisibilityChange. When provided (including `0`), visibility + * is tracked via GridItemVisibilitySentinel, which supports repeat detection, instead of the + * shared Sentinel, which only ever fires its first "true" transition — fine for one-shot + * consumers elsewhere, but not for a grid that needs to re-track already-visible items after a + * refresh. Leave undefined to keep the default Sentinel-based behavior. + */ + refreshKey?: number } export const MasonryArtworkGridItem: React.FC = ({ @@ -63,6 +73,7 @@ export const MasonryArtworkGridItem: React.FC = ({ partnerOffer, priceOfferMessage, onItemVisibilityChange, + refreshKey, ...rest }) => { const space = useSpace() @@ -100,12 +111,23 @@ export const MasonryArtworkGridItem: React.FC = ({ priceOfferMessage={priceOfferMessage} /> - {!!onItemVisibilityChange && ( - onItemVisibilityChange(item.internalID || item.id, index, visible)} - /> - )} + {!!onItemVisibilityChange && + (refreshKey !== undefined ? ( + + onItemVisibilityChange(item.internalID || item.id, index, visible) + } + /> + ) : ( + + 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..df856c59aad 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 + refreshKey?: number } /** @@ -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 = ( onArtworkPress={handleOnGridArtworkPress} trackItemImpressions={section.trackItemImpressions} contextModule={contextModule} + refreshKey={isLiveRefreshRail ? liveRefetchCompletionKey : undefined} /> ) : ( 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 ? ( From c3ad85caec8aa4280079307f19fbc3a4f8c7b033 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Thu, 23 Jul 2026 20:41:42 +0200 Subject: [PATCH 4/8] fix: avoid unnecessary re-renders when clearing tracked section guard --- src/app/Scenes/HomeView/HomeViewContext.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/Scenes/HomeView/HomeViewContext.ts b/src/app/Scenes/HomeView/HomeViewContext.ts index 231bee4aac3..4c6cafa110d 100644 --- a/src/app/Scenes/HomeView/HomeViewContext.ts +++ b/src/app/Scenes/HomeView/HomeViewContext.ts @@ -28,6 +28,9 @@ 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) => { From ff2de451fd80b52fd7d4b4e0db3bc04cb817b165 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Thu, 23 Jul 2026 21:43:02 +0200 Subject: [PATCH 5/8] chore: write tests --- .../Components/HomeViewSectionSentinel.tsx | 12 ++++- .../HomeViewSectionArtworks.tests.tsx | 48 +++++++++++++------ 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx b/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx index 0b35105a6b8..98f2aeaa6b4 100644 --- a/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx +++ b/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx @@ -51,7 +51,15 @@ export const HomeViewSectionSentinel: React.FC = ( } } }, - [contextModule, viewedSection, addTrackedSection, trackedSections, index, sectionType] + [ + contextModule, + viewedSection, + addTrackedSection, + trackedSections, + index, + sectionType, + addTrackedSectionTypes, + ] ) // Read via a ref inside the interval below so it always calls the latest version (the poll @@ -114,7 +122,7 @@ export const HomeViewSectionSentinel: React.FC = ( }, [isLiveRefreshRail, refreshKey, measure]) if (isLiveRefreshRail) { - return + return } return 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", From fc8801d8319f71ae6d2be908504e9570a24e4c9b Mon Sep 17 00:00:00 2001 From: dariakoko Date: Fri, 24 Jul 2026 00:44:44 +0200 Subject: [PATCH 6/8] chore: refactor, address edje cases --- .../Components/ArtworkGrids/GenericGrid.tsx | 6 +- .../GridItemVisibilitySentinel.tsx | 96 ------------------- .../ArtworkGrids/MasonryArtworkGridItem.tsx | 59 ++++++------ .../MasonryInfiniteScrollArtworkGrid.tsx | 8 +- .../ArtworkGrids/useGridItemVisibility.ts | 82 ++++++++++++++++ .../Components/HomeViewSectionSentinel.tsx | 55 +++-------- .../Sections/HomeViewSectionArtworks.tsx | 7 +- .../Sections/HomeViewSectionArtworksGrid.tsx | 2 +- 8 files changed, 135 insertions(+), 180 deletions(-) delete mode 100644 src/app/Components/ArtworkGrids/GridItemVisibilitySentinel.tsx create mode 100644 src/app/Components/ArtworkGrids/useGridItemVisibility.ts diff --git a/src/app/Components/ArtworkGrids/GenericGrid.tsx b/src/app/Components/ArtworkGrids/GenericGrid.tsx index a3223339eb1..c6d788ca5f1 100644 --- a/src/app/Components/ArtworkGrids/GenericGrid.tsx +++ b/src/app/Components/ArtworkGrids/GenericGrid.tsx @@ -27,7 +27,7 @@ interface Props { saleInfoTextStyle?: TextProps fitToFrame?: boolean onItemVisibilityChange?: (artworkID: string, index: number, visible: boolean) => void - refreshKey?: number + useLiveVisibilityTracking?: boolean } type PropsForArtwork = Omit @@ -46,7 +46,7 @@ export const GenericGrid: React.FC = ({ trackingFlow, fitToFrame = false, onItemVisibilityChange, - refreshKey, + useLiveVisibilityTracking, }) => { const space = useSpace() const artworks = useFragment(genericGridFragment, artworksProp) @@ -71,7 +71,7 @@ export const GenericGrid: React.FC = ({ trackingFlow={trackingFlow} fitToFrame={fitToFrame} onItemVisibilityChange={onItemVisibilityChange} - refreshKey={refreshKey} + useLiveVisibilityTracking={useLiveVisibilityTracking} /> {isLoading ? : null} diff --git a/src/app/Components/ArtworkGrids/GridItemVisibilitySentinel.tsx b/src/app/Components/ArtworkGrids/GridItemVisibilitySentinel.tsx deleted file mode 100644 index 05812e4698a..00000000000 --- a/src/app/Components/ArtworkGrids/GridItemVisibilitySentinel.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { FC, ReactNode, useCallback, useEffect, useRef } from "react" -import { Dimensions, View } from "react-native" - -const POLL_INTERVAL = 1000 - -interface GridItemVisibilitySentinelProps { - children?: ReactNode - /** 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 - onVisibilityChange: (visible: boolean) => void - /** Bump to force an immediate re-check of this item's current visibility, e.g. after a live refresh where an already-visible item wouldn't otherwise produce a transition. */ - refreshKey?: number -} - -/** - * Purpose-built alternative to the shared `Sentinel` for grid items that need repeatable - * visibility detection (entering AND leaving the viewport, more than once per mount). `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. Kept as a separate component so `Sentinel` - * itself stays untouched for everyone else. - */ -export const GridItemVisibilitySentinel: FC = ({ - children, - threshold = 1, - onVisibilityChange, - refreshKey = 0, -}) => { - const viewRef = useRef(null) - const isVisibleRef = useRef(false) - - // Read via a ref so the poll/refresh effects below (which only depend on `measure`) always call - // the latest version, regardless of when their closures were created. - const onVisibilityChangeRef = useRef(onVisibilityChange) - onVisibilityChangeRef.current = onVisibilityChange - - const measure = useCallback( - (report: (visible: boolean) => void) => { - viewRef.current?.measure( - (_x: number, _y: number, width: number, height: number, pageX: number, pageY: number) => { - const window = Dimensions.get("window") - - const isVisible = - pageY + height != 0 && - pageY >= 0 && - pageY + height - height * (1 - threshold) <= window.height && - pageX + width > 0 && - pageX + width - width * (1 - threshold) <= window.width - - report(isVisible) - } - ) - }, - [threshold] - ) - - // Poll for visibility transitions, using a ref (not React state) as the comparison value so this - // correctly detects repeated entering/leaving of the viewport rather than freezing after the - // first transition. - useEffect(() => { - const interval = setInterval(() => { - measure((visible) => { - if (visible !== isVisibleRef.current) { - isVisibleRef.current = visible - onVisibilityChangeRef.current(visible) - } - }) - }, POLL_INTERVAL) - - return () => clearInterval(interval) - }, [measure]) - - // On a live refresh, re-report current visibility even if it hasn't changed (an item that stays - // on screen produces no transition for the poll above to catch). - useEffect(() => { - if (refreshKey === 0) { - return - } - - const raf = requestAnimationFrame(() => { - measure((visible) => { - isVisibleRef.current = visible - onVisibilityChangeRef.current(visible) - }) - }) - - return () => cancelAnimationFrame(raf) - }, [refreshKey, measure]) - - return ( - - {children} - - ) -} diff --git a/src/app/Components/ArtworkGrids/MasonryArtworkGridItem.tsx b/src/app/Components/ArtworkGrids/MasonryArtworkGridItem.tsx index f1f7236000b..ad8b02020ef 100644 --- a/src/app/Components/ArtworkGrids/MasonryArtworkGridItem.tsx +++ b/src/app/Components/ArtworkGrids/MasonryArtworkGridItem.tsx @@ -5,11 +5,12 @@ import ArtworkGridItem, { ArtworkProps, PriceOfferMessage, } from "app/Components/ArtworkGrids/ArtworkGridItem" -import { GridItemVisibilitySentinel } from "app/Components/ArtworkGrids/GridItemVisibilitySentinel" +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 { @@ -48,14 +49,12 @@ interface MasonryArtworkGridItemProps extends Omit { */ onItemVisibilityChange?: (artworkInternalID: string, index: number, visible: boolean) => void /** - * Bump to force this item to re-report its current visibility (e.g. after a live refresh). - * Only meaningful together with onItemVisibilityChange. When provided (including `0`), visibility - * is tracked via GridItemVisibilitySentinel, which supports repeat detection, instead of the - * shared Sentinel, which only ever fires its first "true" transition — fine for one-shot - * consumers elsewhere, but not for a grid that needs to re-track already-visible items after a - * refresh. Leave undefined to keep the default Sentinel-based behavior. + * 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. */ - refreshKey?: number + useLiveVisibilityTracking?: boolean } export const MasonryArtworkGridItem: React.FC = ({ @@ -73,7 +72,7 @@ export const MasonryArtworkGridItem: React.FC = ({ partnerOffer, priceOfferMessage, onItemVisibilityChange, - refreshKey, + useLiveVisibilityTracking, ...rest }) => { const space = useSpace() @@ -84,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 && - (refreshKey !== undefined ? ( - - onItemVisibilityChange(item.internalID || item.id, index, visible) - } - /> - ) : ( - - onItemVisibilityChange(item.internalID || item.id, index, visible) - } - /> - ))} + {!!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 df856c59aad..b0d9493fd2c 100644 --- a/src/app/Components/ArtworkGrids/MasonryInfiniteScrollArtworkGrid.tsx +++ b/src/app/Components/ArtworkGrids/MasonryInfiniteScrollArtworkGrid.tsx @@ -47,7 +47,7 @@ interface MasonryInfiniteScrollArtworkGridProps extends MasonryFlashListOmittedP trackingFlow?: string fitToFrame?: boolean onItemVisibilityChange?: (artworkID: string, index: number, visible: boolean) => void - refreshKey?: number + useLiveVisibilityTracking?: boolean } /** @@ -92,7 +92,7 @@ export const MasonryInfiniteScrollArtworkGrid: React.FC { const space = useSpace() @@ -143,7 +143,7 @@ export const MasonryInfiniteScrollArtworkGrid: React.FC ), @@ -173,7 +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() + + // 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 + + const isVisible = + pageY + height != 0 && + pageY >= 0 && + pageY + height - height * (1 - threshold) <= viewportHeight && + pageX + width > 0 && + pageX + width - width * (1 - threshold) <= window.width + + report(isVisible) + } + ) + }, + [threshold, ref, insets.bottom] + ) + + useEffect(() => { + if (!enabled) { + return + } + + const interval = setInterval(() => { + measure((visible) => onVisibilityChangeRef.current(visible)) + }, POLL_INTERVAL) + + return () => clearInterval(interval) + }, [enabled, measure]) +} diff --git a/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx b/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx index 98f2aeaa6b4..9f2fafcbc8a 100644 --- a/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx +++ b/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx @@ -17,11 +17,6 @@ interface HomeViewSectionSentinelProps { * section renders the shared `Sentinel` unchanged. */ isLiveRefreshRail?: boolean - /** - * Bumped by the parent every time a forced live-refresh of this section completes. Only used - * when `isLiveRefreshRail` is true. - */ - refreshKey?: number } export const HomeViewSectionSentinel: React.FC = ({ @@ -29,7 +24,6 @@ export const HomeViewSectionSentinel: React.FC = ( sectionType, index, isLiveRefreshRail = false, - refreshKey = 0, }) => { const { viewedSection } = useHomeViewTracking() const addTrackedSection = HomeViewStore.useStoreActions((actions) => actions.addTrackedSection) @@ -39,7 +33,6 @@ export const HomeViewSectionSentinel: React.FC = ( ) const markerRef = useRef(null) - const isVisibleRef = useRef(false) const handleVisibilityChange = useCallback( (visible: boolean) => { @@ -67,59 +60,33 @@ export const HomeViewSectionSentinel: React.FC = ( const handleVisibilityChangeRef = useRef(handleVisibilityChange) handleVisibilityChangeRef.current = handleVisibilityChange - const measure = useCallback((report: (visible: boolean) => void) => { - markerRef.current?.measure( - (_x: number, _y: number, _width: number, height: number, _pageX: number, pageY: number) => { - const windowHeight = Dimensions.get("window").height - report(pageY + height > 0 && pageY < windowHeight) - } - ) - }, []) - // 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. useEffect(() => { if (!isLiveRefreshRail) { return } const interval = setInterval(() => { - measure((visible) => { - if (visible !== isVisibleRef.current) { - isVisibleRef.current = visible - handleVisibilityChangeRef.current(visible) + 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, measure]) - - // On a live refresh, re-check whether this section is on screen right now (the parent has already - // reset the "tracked" guard for us). We re-measure the section's actual position directly instead - // of relying on `viewableSections` (populated by the home list's own viewability callback), which - // can still hold a stale value from before navigating away right as the screen regains focus and - // the refetch completes. If the section isn't on screen at this exact moment (e.g. a pull-to- - // refresh triggered from the top of the list), this is a no-op — the guard reset means the poll - // above will pick it up on a later scroll-into-view instead. - useEffect(() => { - if (!isLiveRefreshRail || refreshKey === 0) { - return - } - - const raf = requestAnimationFrame(() => { - measure((visible) => { - isVisibleRef.current = visible - handleVisibilityChangeRef.current(visible) - }) - }) - - return () => cancelAnimationFrame(raf) - }, [isLiveRefreshRail, refreshKey, measure]) + }, [isLiveRefreshRail]) if (isLiveRefreshRail) { return diff --git a/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworks.tsx b/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworks.tsx index ce217d877b0..c1e4cab4ffa 100644 --- a/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworks.tsx +++ b/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworks.tsx @@ -76,10 +76,8 @@ export const HomeViewSectionArtworks: React.FC = ( contextModule, }) - // Bumped when the forced refetch completes, so HomeViewSectionSentinel re-measures and re-fires - // railViewed if this section happens to be on screen at that exact moment (see the effect there — - // it doesn't trust `viewableSections`, which can be stale right as the screen regains focus and - // the refetch completes). + // 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 @@ -212,7 +210,6 @@ export const HomeViewSectionArtworks: React.FC = ( sectionType={section.__typename} index={index} isLiveRefreshRail={isLiveRefreshRail} - refreshKey={liveRefetchCompletionKey} /> ) diff --git a/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworksGrid.tsx b/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworksGrid.tsx index 4245875013c..af3f5e547a5 100644 --- a/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworksGrid.tsx +++ b/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworksGrid.tsx @@ -99,7 +99,7 @@ export const HomeViewSectionArtworksGrid: React.FC {hasGridLaidOut ? ( From 129227a6cbaa989b74163c99a2b2fa4da0d72b52 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Fri, 24 Jul 2026 01:00:35 +0200 Subject: [PATCH 7/8] fix: pause NWFY/WTYL visibility polling while Home isnt focused --- src/app/Components/ArtworkGrids/useGridItemVisibility.ts | 9 +++++++-- .../HomeView/Components/HomeViewSectionSentinel.tsx | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/app/Components/ArtworkGrids/useGridItemVisibility.ts b/src/app/Components/ArtworkGrids/useGridItemVisibility.ts index e26108522a6..938241f034b 100644 --- a/src/app/Components/ArtworkGrids/useGridItemVisibility.ts +++ b/src/app/Components/ArtworkGrids/useGridItemVisibility.ts @@ -1,3 +1,4 @@ +import { useIsFocused } from "@react-navigation/native" import { BOTTOM_TABS_HEIGHT } from "app/Navigation/AuthenticatedRoutes/Tabs" import { RefObject, useCallback, useEffect, useRef } from "react" import { Dimensions, View } from "react-native" @@ -37,6 +38,7 @@ export const useGridItemVisibility = ({ 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. @@ -68,8 +70,11 @@ export const useGridItemVisibility = ({ [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) { + if (!enabled || !isFocused) { return } @@ -78,5 +83,5 @@ export const useGridItemVisibility = ({ }, POLL_INTERVAL) return () => clearInterval(interval) - }, [enabled, measure]) + }, [enabled, isFocused, measure]) } diff --git a/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx b/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx index 9f2fafcbc8a..04c0605f4f7 100644 --- a/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx +++ b/src/app/Scenes/HomeView/Components/HomeViewSectionSentinel.tsx @@ -1,4 +1,5 @@ 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" @@ -33,6 +34,7 @@ export const HomeViewSectionSentinel: React.FC = ( ) const markerRef = useRef(null) + const isFocused = useIsFocused() const handleVisibilityChange = useCallback( (visible: boolean) => { @@ -71,8 +73,11 @@ export const HomeViewSectionSentinel: React.FC = ( // 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) { + if (!isLiveRefreshRail || !isFocused) { return } @@ -86,7 +91,7 @@ export const HomeViewSectionSentinel: React.FC = ( }, POLL_INTERVAL) return () => clearInterval(interval) - }, [isLiveRefreshRail]) + }, [isLiveRefreshRail, isFocused]) if (isLiveRefreshRail) { return From aef72b666f9fe41c9ab7ea13ef2b7adc3af74169 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Fri, 24 Jul 2026 01:12:45 +0200 Subject: [PATCH 8/8] fix: apply the threshold symmetrically to the top of grid item on nwfy --- .../ArtworkGrids/useGridItemVisibility.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/app/Components/ArtworkGrids/useGridItemVisibility.ts b/src/app/Components/ArtworkGrids/useGridItemVisibility.ts index 938241f034b..52aabc01714 100644 --- a/src/app/Components/ArtworkGrids/useGridItemVisibility.ts +++ b/src/app/Components/ArtworkGrids/useGridItemVisibility.ts @@ -56,12 +56,25 @@ export const useGridItemVisibility = ({ // actually hidden behind the tab bar. const viewportHeight = window.height - BOTTOM_TABS_HEIGHT - insets.bottom - const isVisible = - pageY + height != 0 && - pageY >= 0 && - pageY + height - height * (1 - threshold) <= viewportHeight && - pageX + width > 0 && - pageX + width - width * (1 - threshold) <= window.width + 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) }