Explorar el Código

Neo/feed render fixes (#5827)

* Neo: one timeline `edited` socket binding for all feed cards

Every feed card bound its own `bx_timeline_0` `edited` handler, so one edit ran
and JSON-parsed the payload once per card on screen. app/lib/timeline-edits
binds the event once and calls only the edited item's listeners.

UnitFeed also bound with `[]` deps over `datas` (stale closure), compared the
raw edited item with the enriched one (never equal), and replaced the card with
the raw item, which has no `mainImage`: an edited post lost its picture in the
feed. The edited item is now enriched the same way as the initial one.

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

* Neo: read the UNA page response body once

The page shell cloned every UNA response just to have the text for the parse
error branch, buffering each page body twice. It now reads the text once and
parses it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
RomanL hace 1 semana
padre
commit
338086c6d1

+ 4 - 3
neo/apps/next/app/[...path]/page.js

@@ -87,11 +87,12 @@ const getData = cache(async (path, searchParamsKey) => {
             return { data: { title: SITE_TITLE, description: SITE_TITLE }, code: res.status };
         }*/
 
-        const resClone = res.clone();
+        // Read the body once: a clone kept for the error branch buffered every page twice.
+        let text = '';
         try {
-            return await res.json();
+            text = await res.text();
+            return JSON.parse(text);
         } catch (error) {
-            const text = await resClone.text();
             const fatal = text.match(/Fatal error[^<]*/i)?.[0]
                 || text.match(/Uncaught [\w\\]+:[^\n<]+/)?.[0]
                 || text.slice(0, 200);

+ 27 - 40
neo/packages/app/components/elements/feed-item-parts.js

@@ -8,13 +8,11 @@ import {
     useMemo,
     useState,
     useEffect,
-    useCallback,
     useRef,
 } from 'react'
 import { useCurrentUser } from 'app/context/user'
 import {
     appSetting,
-    isObjectsEqual,
     menuItemsByName,
     visibilityById,
     runMenuItemCallback,
@@ -32,7 +30,7 @@ import Card from 'app/ui/molecules/page/card'
 import AnimatedBlock from 'app/ui/atoms/animated-block'
 import { CommentsBrowseShort } from 'app/components/elements/comments-browse';
 import { Pressable } from 'app/design/view'
-import { subscribe } from 'app/ui/atoms/socket'
+import { subscribeTimelineEdit } from 'app/lib/timeline-edits'
 import { getDataForMenu } from 'app/lib/util'
 import Form from 'app/components/elements/form'
 import useFetchForm from 'app/lib/hooks/use-fetch-form'
@@ -673,48 +671,37 @@ export function SmallUnit({ data }) {
     )
 }
 
+/** Card fields derived from a timeline item (first image, first comments). */
+function enrichFeedItem(data) {
+    const mainImage = data?.content?.images?.length > 0 ? data.content.images[0] : null;
+    const comments = data?.cmts?.data?.length > 0
+        ? data.cmts.data[0][Object.keys(data.cmts.data[0])[0]].data
+        : null;
+    return { ...data, mainImage, comments, showMore: true };
+}
+
 export const UnitFeed = ({ data, mode, DefaultUnit, SmallUnit, feed_type }) => {
-    const enrichedData = useMemo(() => {
-        const mainImage = data?.content?.images?.length > 0 ? data.content.images[0] : null;
-        const comments = data?.cmts?.data?.length > 0
-            ? data.cmts.data[0][Object.keys(data.cmts.data[0])[0]].data
-            : null;
-        return { ...data, mainImage, comments, showMore: true };
-    }, [data?.id]);
+    const enrichedData = useMemo(() => enrichFeedItem(data), [data?.id]);
 
     const [datas, setDatas] = useState(enrichedData)
+    const itemId = datas?.id
 
-    const onItemEdited = useCallback(
-        async (strData) => {
-            const editedData = JSON.parse(strData)
-            const editedId = editedData?.id
-            const currentId = datas?.id
-
-            if (editedId == null || currentId == null) {
-                return
-            }
-
-            if (editedId.toString() == currentId.toString()) {
-                const result = await fetcher(
-                    '/api.php?r=' +
-                    appSetting('urls', 'feed_item') +
-                    '{"params":{"browse":"id","value":' +
-                    editedId +
-                    '}}'
-                )
-                if (result.data && !isObjectsEqual(result.data, datas))
-                    setDatas(result.data)
-            }
-        },
-        [datas]
-    )
-
+    // Reload the card when its post is edited. The edited item is enriched like
+    // the initial one: without `mainImage` the card dropped its picture.
     useEffect(() => {
-        const sub1 = subscribe('bx_timeline_0', 'edited', onItemEdited);
-        return () => {
-            sub1();
-        };
-    }, [])
+        if (itemId == null) return undefined
+        return subscribeTimelineEdit(itemId, async () => {
+            const result = await fetcher(
+                '/api.php?r=' +
+                appSetting('urls', 'feed_item') +
+                '{"params":{"browse":"id","value":' +
+                itemId +
+                '}}'
+            )
+            if (result.data)
+                setDatas(enrichFeedItem(result.data))
+        })
+    }, [itemId])
 
     return mode == 'small' ? (
         <SmallUnit data={datas} />

+ 12 - 23
neo/packages/app/components/elements/feed-item.js

@@ -3,7 +3,7 @@ import Html from 'app/ui/atoms/html';
 import { useState, useEffect } from 'react'
 import Embed from 'app/ui/molecules/content/embed'
 import { cloneObject, appSetting } from 'app/lib/util';
-import { subscribe } from 'app/ui/atoms/socket';
+import { subscribeTimelineEdit } from 'app/lib/timeline-edits';
 import { ActionMenu, CounterMenu } from 'app/components/elements/feed-item-parts'
 import { UnitImages } from 'app/lib/feed-items'
 import { PollItem } from 'app/components/elements/entity-poll';
@@ -16,29 +16,18 @@ export default function ElementFeedItem({ data, isModal, blockWrapperProps }) {
 
     // Same as UnitFeed card: refetch on timeline "edited" socket
     useEffect(() => {
-        if (eventId == null) return
+        if (eventId == null) return undefined
 
-        const onItemEdited = async (strData) => {
-            try {
-                const editedData = JSON.parse(strData)
-                const editedId = editedData?.id
-                if (editedId == null || editedId.toString() !== eventId.toString()) return
-
-                const result = await fetcher(
-                    '/api.php?r=' +
-                    appSetting('urls', 'feed_item') +
-                    '{"params":{"browse":"id","value":' +
-                    editedId +
-                    '}}'
-                )
-                if (result.data?.content) setContent(result.data.content)
-            } catch {
-                // ignore malformed socket payloads
-            }
-        }
-
-        const unsub = subscribe('bx_timeline_0', 'edited', onItemEdited)
-        return () => unsub()
+        return subscribeTimelineEdit(eventId, async () => {
+            const result = await fetcher(
+                '/api.php?r=' +
+                appSetting('urls', 'feed_item') +
+                '{"params":{"browse":"id","value":' +
+                eventId +
+                '}}'
+            )
+            if (result.data?.content) setContent(result.data.content)
+        })
     }, [eventId])
 
     const tlContent = content?.text

+ 56 - 0
neo/packages/app/lib/timeline-edits.ts

@@ -0,0 +1,56 @@
+import { subscribe } from 'app/ui/atoms/socket';
+
+/**
+ * One `bx_timeline_0` `edited` binding for every feed card on screen. Each card
+ * used to bind its own handler, so one edit ran (and JSON-parsed) once per card;
+ * now the payload is parsed once and only the edited card's listeners run.
+ */
+
+const listeners = new Map<string, Set<() => unknown>>();
+let unsubscribeSocket: (() => void) | null = null;
+
+/** Timeline socket payload: a JSON string or an object (`{ id, author_id, peformer_id }`). */
+function editedId(payload: unknown): string | null {
+    let event: unknown = payload;
+    if (typeof payload === 'string') {
+        try {
+            event = JSON.parse(payload);
+        } catch {
+            return null;
+        }
+    }
+    const id = event && typeof event === 'object' ? (event as { id?: unknown }).id : undefined;
+    return id == null ? null : String(id);
+}
+
+function onEdited(payload: unknown) {
+    const id = editedId(payload);
+    if (id == null) return;
+    listeners.get(id)?.forEach((listener) => {
+        // A failed reload keeps the card as it is; the next edit retries.
+        Promise.resolve().then(listener).catch(() => {});
+    });
+}
+
+/** Call `listener` when the timeline event `id` is edited; returns the unsubscribe function. */
+export function subscribeTimelineEdit(id: number | string, listener: () => unknown): () => void {
+    const key = String(id);
+    let set = listeners.get(key);
+    if (!set) {
+        set = new Set();
+        listeners.set(key, set);
+    }
+    set.add(listener);
+    if (!unsubscribeSocket) unsubscribeSocket = subscribe('bx_timeline_0', 'edited', onEdited);
+
+    return () => {
+        const current = listeners.get(key);
+        if (!current) return;
+        current.delete(listener);
+        if (current.size === 0) listeners.delete(key);
+        if (listeners.size === 0 && unsubscribeSocket) {
+            unsubscribeSocket();
+            unsubscribeSocket = null;
+        }
+    };
+}