Просмотр исходного кода

Neo: stop full feed refetch on timeline socket events; fix event date hydration mismatch (#5811)

* Neo: stop refetching every loaded feed page on timeline socket events

The bx_timeline_0 channel carries every post of the site, and browse lists
refetched all loaded pages on each added/deleted event, on every client at once.
Now useUniListQuery handles the channel (listenTimeline): a deleted row is
dropped from the cache without a request, and added events are batched into one
jittered first-page request merged on top of the cache. Repeated ids at offset
page boundaries are deduplicated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: format event date ranges with the hydration-safe locale

formatDateInterval read the module-level locale, which switches to the device
locale as soon as any component hydrates, so event cards hydrating later
rendered a different date order than SSR (hydration mismatch). It now takes the
locale from useDateLocaleTag() in event cards and covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: keep other-device own posts live; don't let a stale first page restore deleted rows

- Drop the author/performer skip on timeline `added`: `EVENTS.feed` only
  reaches the tab that posted, so the same account on another device lost
  its only live update. The merge already ignores rows that are cached.
- Remember ids deleted via the socket and filter them out of a first page
  that was fetched before the deletion arrived.
- Mark the jitter `Math.random()` as not security-sensitive (Sonar S2245).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
RomanL 1 неделя назад
Родитель
Сommit
06030abe2d

+ 1 - 12
neo/packages/app/components/elements/browse-list.js

@@ -24,7 +24,6 @@ import { Platform } from 'react-native'
 import UniList from 'app/ui/atoms/unilist'
 import { Text } from 'app/design/typography'
 import { getSkeletonForList } from 'app/lib/skeleton-helpers'
-import { subscribe } from 'app/ui/atoms/socket'
 import { useCurrentUser } from 'app/context/user'
 import { NeoButton } from 'app/design/controls'
 import { useWindowHeight } from 'app/context/measure';
@@ -145,6 +144,7 @@ export default function Browse(props) {
         getNextPageParam: startPerPageNextPageParam,
         listenPageReload: true,
         listenFeed: props.data?.unit === 'feed',
+        listenTimeline: props.data?.unit === 'feed',
     })
 
     let sSkeleton = data.module ? data.module : data.unit
@@ -185,17 +185,6 @@ export default function Browse(props) {
         [sSkeleton, layout, renderItem]
     )
 
-    useEffect(() => {
-        if (props.data?.unit !== 'feed') return;
-
-        const offAdded = subscribe('bx_timeline_0', 'added', refetch);
-        const offDeleted = subscribe('bx_timeline_0', 'deleted', refetch);
-        
-        return () => {
-            offAdded();
-            offDeleted();
-        };
-    }, [refetch, props.data?.unit])    
 
     // Bottom-tab reselect (same event as Conductor / Browse): scroll to top, or reload if already at top.
     useEffect(() => {

+ 1 - 13
neo/packages/app/components/elements/browse.js

@@ -28,7 +28,6 @@ import { responsiveClasses } from 'app/lib/responsive-classes'
 import { Text } from 'app/design/typography'
 import { getSkeletonForList } from 'app/lib/skeleton-helpers'
 import { useTranslation } from 'react-i18next'
-import { subscribe } from 'app/ui/atoms/socket'
 import { useCurrentUser } from 'app/context/user'
 import { layoutForList } from 'app/customization/functions'
 import Galery from 'app/ui/molecules/content/gallery'
@@ -181,6 +180,7 @@ export default function Browse(props) {
         refetchOnReconnect: updateMode != 'none',
         listenPageReload: true,
         listenFeed: props.data?.unit === 'feed',
+        listenTimeline: props.data?.unit === 'feed',
         canFetchNext: () => isOnePage != true && props.extraProps?.limit != true,
     })
 
@@ -223,18 +223,6 @@ export default function Browse(props) {
     )
 
 
-    useEffect(() => {
-        if (props.data?.unit !== 'feed') return;
-
-        const offAdded = subscribe('bx_timeline_0', 'added', refetch);
-        const offDeleted = subscribe('bx_timeline_0', 'deleted', refetch);
-
-        return () => {
-            offAdded();
-            offDeleted();
-        };
-    }, [refetch, props.data?.unit])
-
     // Bottom-tab reselect (same event as Conductor): scroll to top, or reload if already at top.
     useEffect(() => {
         if (props.data?.unit !== 'feed' || isOneLine || isOnePage) return;

+ 2 - 1
neo/packages/app/components/elements/covers/cover-morph-body.js

@@ -54,7 +54,7 @@ export function CoverMorphBody({
 }) {
     const { t } = useTranslation()
     const { currentUser } = useCurrentUser()
-    useDateLocaleTag()
+    const dateLocaleTag = useDateLocaleTag()
     const isDesktop = useIsDesktop()
     const breakpointName = useBreakpointName()
     const sizing = getCoverMorphSizing(isDesktop)
@@ -139,6 +139,7 @@ export function CoverMorphBody({
                 profileData.info?.date_start,
                 profileData.info?.date_end,
                 t,
+                dateLocaleTag,
             )}
         </Text>
     )

+ 3 - 2
neo/packages/app/components/units/content-list/bx-events.js

@@ -1,4 +1,4 @@
-import { formatDateInterval } from 'app/lib/util'
+import { formatDateInterval, useDateLocaleTag } from 'app/lib/util'
 import { Text } from 'app/design/typography'
 import { View, Row } from 'app/design/view'
 import { useTranslation } from 'react-i18next';
@@ -15,6 +15,7 @@ import {
 
 export default function Unit({ data, unitType, module }) {
     const { t } = useTranslation();
+    const dateLocaleTag = useDateLocaleTag();
     const { primaryMenuItem, secondaryMenuItem } = useUnitActions({
         unitType,
         data,
@@ -44,7 +45,7 @@ export default function Unit({ data, unitType, module }) {
                                     numberOfLines={1}
                                     className="text-muted-foreground text-xs uppercase font-semibold tracking-tight"
                                 >
-                                    {formatDateInterval(data.date_start, data.date_end, t)}
+                                    {formatDateInterval(data.date_start, data.date_end, t, dateLocaleTag)}
                                 </Text>
                             ) : null}
                         </Skeleton>

+ 62 - 1
neo/packages/app/lib/browse-query/helpers.ts

@@ -114,7 +114,68 @@ export const prependItemToUniListQueryCache = (queryClient: QueryClient, queryKe
     })
 }
 
-export const removeItemFromUniListQueryCache = (queryClient: QueryClient, queryKey: unknown[], id: number | string) => {
+/**
+ * Put a freshly fetched first page on top of the cached pages: the fresh rows
+ * (in server order, pinned rows included), then the old first-page rows that
+ * fell out of it. Later pages stay; rows they now repeat are dropped by
+ * `dedupeUniListItems`. Returns false (cache untouched) when every fresh row
+ * is already cached.
+ */
+export const mergeFirstPageIntoUniListQueryCache = (queryClient: QueryClient, queryKey: unknown[], freshPage: any): boolean => {
+    const freshItems: any[] = freshPage?.data ?? []
+    if (!freshItems.length || !queryClient) return false
+
+    let merged = false
+    queryClient.setQueryData(queryKey, (old: any) => {
+        if (!old?.pages?.length) return old
+
+        const cachedIds = new Set<string>()
+        for (const page of old.pages) {
+            for (const item of page.data ?? []) cachedIds.add(String(item.id))
+        }
+        if (freshItems.every((item) => cachedIds.has(String(item.id)))) return old
+
+        const freshIds = new Set(freshItems.map((item) => String(item.id)))
+        const [firstPage, ...restPages] = old.pages
+        merged = true
+        return {
+            ...old,
+            pages: [
+                {
+                    ...firstPage,
+                    data: [
+                        ...freshItems,
+                        ...(firstPage.data ?? []).filter((item: any) => !freshIds.has(String(item.id))),
+                    ],
+                },
+                ...restPages,
+            ],
+        }
+    })
+    return merged
+}
+
+/**
+ * Drop repeated ids, keeping the first. Offset paging repeats rows at a page
+ * boundary after rows were added on top since the previous page was loaded.
+ */
+export const dedupeUniListItems = (items: any[]) => {
+    const seen = new Set<string>()
+    let hasDuplicates = false
+    const result = items.filter((item) => {
+        if (item?.id == null) return true
+        const id = String(item.id)
+        if (seen.has(id)) {
+            hasDuplicates = true
+            return false
+        }
+        seen.add(id)
+        return true
+    })
+    return hasDuplicates ? result : items
+}
+
+export const removeItemFromUniListQueryCache =(queryClient: QueryClient, queryKey: unknown[], id: number | string) => {
     if (id == null || !queryClient) return
 
     queryClient.setQueryData(queryKey, (old: any) => {

+ 97 - 2
neo/packages/app/lib/browse-query/index.ts

@@ -2,12 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
 import { appSetting } from 'app/lib/util'
 import emitter, { EVENTS } from 'app/context/emitter'
+import { subscribe } from 'app/ui/atoms/socket'
 import {
     isSameItemsForUniList,
     flattenPagesForUniList,
+    dedupeUniListItems,
     fetchUniListData,
     prependItemToUniListQueryCache,
     removeItemFromUniListQueryCache,
+    mergeFirstPageIntoUniListQueryCache,
     cursorNextPageParam,
 } from './helpers'
 
@@ -28,10 +31,32 @@ export {
     startPerPageNextPageParam,
     prependItemToUniListQueryCache,
     removeItemFromUniListQueryCache,
+    mergeFirstPageIntoUniListQueryCache,
+    dedupeUniListItems,
     matchesFeedOwnerFilter,
     isSameItemsForUniList,
 } from './helpers'
 
+/** Batch window for timeline `added` events, plus a random spread so clients don't all hit UNA at once. */
+const TIMELINE_BATCH_MS = 1000
+const TIMELINE_JITTER_MS = 2000
+/** Cap on timeline ids remembered as deleted, so a first page fetched before a deletion cannot bring the row back. */
+const TIMELINE_DELETED_MAX = 200
+
+type TimelineSocketEvent = { id?: number | string }
+
+/** Timeline socket payload: a JSON string or an object (`{ id, author_id, peformer_id }`). */
+function parseTimelinePayload(payload: unknown): TimelineSocketEvent | null {
+    if (typeof payload === 'string') {
+        try {
+            return JSON.parse(payload)
+        } catch {
+            return null
+        }
+    }
+    return payload && typeof payload === 'object' ? (payload as TimelineSocketEvent) : null
+}
+
 /**
  * Shared UniList infinite query: TanStack is the store.
  * First page is always fetched (no page-JSON seed). Skeletons until that returns.
@@ -48,9 +73,10 @@ export function useUniListQuery({
     getNextPageParam = cursorNextPageParam,
     listenPageReload = false,
     listenFeed = false,
+    listenTimeline = false,
     matchesNewContent,
     canFetchNext,
-}: { queryKey: any; requestUrl: string; defaultParams: any; enabled?: boolean; deferNewItems?: any; refetchOnWindowFocus?: any; refetchOnReconnect?: any; gcTime?: number; getNextPageParam?: any; listenPageReload?: boolean; listenFeed?: boolean; matchesNewContent?: any; canFetchNext?: any }) {
+}: { queryKey: any; requestUrl: string; defaultParams: any; enabled?: boolean; deferNewItems?: any; refetchOnWindowFocus?: any; refetchOnReconnect?: any; gcTime?: number; getNextPageParam?: any; listenPageReload?: boolean; listenFeed?: boolean; listenTimeline?: boolean; matchesNewContent?: any; canFetchNext?: any }) {
     const queryClient = useQueryClient()
     // Exposed as `skipToastRef` (tail skeleton count). Not read in render here:
     // whether a data change is shown or deferred is decided by `requested` below.
@@ -110,7 +136,7 @@ export function useUniListQuery({
     const isPending = (queryResult as { isPending?: boolean }).isPending
 
     const cacheItems = useMemo(
-        () => flattenPagesForUniList(pagesData),
+        () => dedupeUniListItems(flattenPagesForUniList(pagesData)),
         [pagesData]
     )
 
@@ -157,6 +183,75 @@ export function useUniListQuery({
         return () => subscription.remove()
     }, [listenFeed, queryClient])
 
+    /*
+     * Timeline socket (`bx_timeline_0`): every client gets every post of the
+     * site, so a full refetch here (all loaded pages, on every client at once)
+     * loaded UNA on each post. A deleted row is dropped from the cache without
+     * a request. Added posts are batched: one first-page request after a
+     * jittered delay, merged on top of the cache; nothing changes when the new
+     * post is not in this list. Own posts are not skipped: `EVENTS.feed` only
+     * reaches the tab that posted, and the merge ignores rows already cached.
+     */
+    useEffect(() => {
+        if (listenTimeline !== true) return undefined
+
+        let timer: ReturnType<typeof setTimeout> | null = null
+        let cancelled = false
+        const deletedIds = new Set<string>()
+
+        const loadNewItems = async () => {
+            timer = null
+            const cacheKey = qKeyRef.current
+            try {
+                const freshPage = await fetchUniListData({
+                    pageParam: undefined,
+                    requestUrl: requestUrlRef.current,
+                    defaultParams: paramsRef.current,
+                })
+                if (cancelled || cacheKey !== qKeyRef.current) return
+                const page = deletedIds.size
+                    ? { ...freshPage, data: (freshPage?.data ?? []).filter((it: any) => !deletedIds.has(String(it.id))) }
+                    : freshPage
+                if (mergeFirstPageIntoUniListQueryCache(queryClient, cacheKey, page)) {
+                    refetchRef.current.skipToast = true
+                    setRequested(true)
+                }
+            } catch {
+                // The next event or refetch catches up.
+            }
+        }
+
+        const offAdded = subscribe('bx_timeline_0', 'added', () => {
+            if (timer) return
+            // Jitter only spreads the load; not security-sensitive.
+            timer = setTimeout(loadNewItems, TIMELINE_BATCH_MS + Math.random() * TIMELINE_JITTER_MS) // NOSONAR
+        })
+
+        const offDeleted = subscribe('bx_timeline_0', 'deleted', (payload: unknown) => {
+            const id = parseTimelinePayload(payload)?.id
+            if (id == null) return
+            deletedIds.add(String(id))
+            if (deletedIds.size > TIMELINE_DELETED_MAX) deletedIds.delete(deletedIds.values().next().value as string)
+            const cacheKey = qKeyRef.current
+            const isShown = flattenPagesForUniList(queryClient.getQueryData(cacheKey)).some((it: any) => it.id == id)
+            if (isShown) {
+                refetchRef.current.skipToast = true
+                setRequested(true)
+                removeItemFromUniListQueryCache(queryClient, cacheKey, id)
+            }
+            setFrozen((f) => f?.items.some((it: any) => it.id == id)
+                ? { ...f, items: f.items.filter((it: any) => it.id != id) }
+                : f)
+        })
+
+        return () => {
+            cancelled = true
+            if (timer) clearTimeout(timer)
+            offAdded()
+            offDeleted()
+        }
+    }, [listenTimeline, queryClient])
+
     /*
      * Visible rows are derived from the query cache instead of being copied into
      * local state by effects (one extra commit per data change, plus a reset

+ 7 - 3
neo/packages/app/lib/util/datetime.ts

@@ -307,11 +307,15 @@ function formatDateUniversal(date: Date, t: TFunction, {
     return (rel || '') + parts.join(' ');
 }
 
-/** "start - end" for two Unix timestamps (seconds), collapsing a shared day or time. */
-export const formatDateInterval = (dateStart: number, dateEnd: number, t: TFunction): string => {
+/**
+ * "start - end" for two Unix timestamps (seconds), collapsing a shared day or time.
+ * Pass `locale` from `useDateLocaleTag()` in anything rendered on the server: the
+ * module-level tag switches to the device locale as soon as any component hydrates,
+ * so a later-hydrating caller without it renders a different date order than SSR.
+ */
+export const formatDateInterval = (dateStart: number, dateEnd: number, t: TFunction, locale: string = currentDateLocaleTag): string => {
     const start = new Date(dateStart * 1000);
     const end = new Date(dateEnd * 1000);
-    const locale = currentDateLocaleTag;
 
     const isSingleDate = isSameCalendarDay(start, end);
     const isSingleTime = isSameClockTime(start, end);