Explorar el Código

Neo: let React Compiler compile composers, feed, browse-query and more (#5945)

* Neo: let React Compiler compile Gallery and usePulseOne

Gallery built its PanResponder once, so its swipe handlers kept the first
render's currentIndex and every swipe stepped from slide 0. goLeft/goRight are
now memoized and the responder is rebuilt when the slide changes. The auto-scroll
effect also depends on goRight now, which fixes its exhaustive-deps warning.

usePulseOne checked "reduce motion" with a conditional inside try/catch, which the
compiler can't handle; the check moves to a helper with the same behaviour.

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

* Neo: stop the comment and message composers mutating their props

Both composers wrote the composer's look (icons, styles, rounded) straight into
props.data.inputs on every render, which React Compiler refuses, so the whole
composer was left uncompiled. They now build copies; nothing else read the mutated
fields. The comment form also lists hasMood as an effect dependency.

The messenger form's "payload" hidden field was a timestamp rewritten on every
render; it is now set to the send time in the submit handler.

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

* Neo: let React Compiler compile feed-item-parts, browse-query and dropdown-menu

feed-item-parts: ActionMenu wrote params into the feed item's menu items on
every render; it copies them now. UnitFeed seeded its state through a useMemo that
only ever fed useState, so the state initializer does it directly.

browse-query: useUniListQuery wrote its "latest value" refs during render; they are
written in a layout effect now, which runs before TanStack's fetch. A ternary and an
|| inside the try of the timeline merge moved into small helpers.

dropdown-menu: two useCallback dependency arrays were missing values the callbacks
use (the modal openers, resolveContent).

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
RomanL hace 5 horas
padre
commit
f9d403ec3f

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

@@ -5,7 +5,6 @@ import Profile from 'app/ui/molecules/profile/profile'
 import ProfileHoverCard from 'app/ui/molecules/profile/profile-hover-card'
 import {
     memo,
-    useMemo,
     useState,
     useEffect,
     useRef,
@@ -471,16 +470,17 @@ const MenuManage_ = memo(({ id, menu, setViewState, defaultOpen }) => {
 export const ActionMenu = memo(({ data }) => {
     const settings = appSetting('feed', 'actions_menu');
 
-    if (data?.items?.length > 2) {
-        for (let i = 2; i < data.items.length; i++)
-            if (data.items[i].data)
-                data.items[i].data.params = { 'button_show_title_from_size': 'sm' }
-            else
-                data.items[i].params = { 'button_show_title_from_size': 'sm' }
-    }
+    // From the third item on, titles show from `sm` up. Items are copied, never written to.
+    const items = data?.items?.length > 2
+        ? data.items.map((item, i) => {
+            if (i < 2) return item
+            const params = { 'button_show_title_from_size': 'sm' }
+            return item.data ? { ...item, data: { ...item.data, params } } : { ...item, params }
+        })
+        : data?.items
 
-    const data_other = {...data, items: data?.items.filter(item => item.class != " bx-mi-primary" && item.primary != 1 )};
-    const data_primary = {...data, items: data?.items.filter(item => item.class == " bx-mi-primary" || item.primary == 1 )};
+    const data_other = {...data, items: items?.filter(item => item.class != " bx-mi-primary" && item.primary != 1 )};
+    const data_primary = {...data, items: items?.filter(item => item.class == " bx-mi-primary" || item.primary == 1 )};
 
     return settings && <Row className="flex-auto justify-between gap-1">
         <Menu {...data_primary} displayType="button" params={settings} />
@@ -684,9 +684,8 @@ function enrichFeedItem(data) {
 }
 
 export const UnitFeed = ({ data, mode, DefaultUnit, SmallUnit, feed_type }) => {
-    const enrichedData = useMemo(() => enrichFeedItem(data), [data?.id]);
-
-    const [datas, setDatas] = useState(enrichedData)
+    // Only the first item seeds the state; later edits arrive through setDatas.
+    const [datas, setDatas] = useState(() => enrichFeedItem(data))
     const itemId = datas?.id
 
     // Comments preview shared through the object store: a reload after a socket

+ 25 - 17
neo/packages/app/components/forms/comments.js

@@ -35,6 +35,9 @@ export default function FormComments(props) {
    
   
 
+    // Jobs reviews send `cmt_mood` (star rating).
+    const hasMood = !!props.data.inputs['cmt_mood']
+
     const rawEditorText = formContext.watch('cmt_text')
     const imagesValue = formContext.watch('cmt_image')
     const moodValue = formContext.watch('cmt_mood')
@@ -57,7 +60,7 @@ export default function FormComments(props) {
         // The success cue plays when UNA returns the new comment (comments-browse).
         if (formContext.formState.isSubmitted) {
             formContext.setValue('cmt_text', '');
-            if (props.data.inputs['cmt_mood']) {
+            if (hasMood) {
                 formContext.setValue('cmt_mood', 0);
             }
             emitter.emit(EVENTS.comment, { action: 'send'})
@@ -68,7 +71,7 @@ export default function FormComments(props) {
                 Keyboard.dismiss()
             }
         }
-    }, [formContext.formState.isSubmitted, formContext, isWeb]);
+    }, [formContext.formState.isSubmitted, formContext, isWeb, hasMood]);
 
     const handleSubmitWithSanitization = () => {
         let sanitizedHtml = removeEmptyTags(formContext.getValues('cmt_text') || '');
@@ -83,22 +86,27 @@ export default function FormComments(props) {
         handleSubmitWithSanitization();
     };
 
-    props.data.inputs['cmt_submit'].hide_errors = true
-    props.data.inputs['cmt_submit'].icon = 'ArrowUp'
-    props.data.inputs['cmt_submit'].style = 'bordered'
-    props.data.inputs['cmt_submit'].rounded = 'true'
-    props.data.inputs['cmt_submit'].icon_only = true
-    props.data.inputs['cmt_image'].rounded = 'true'
-    props.data.inputs['cmt_image'].variant = 'default'
+    // Copies with the composer's look; `props` itself is never written to.
+    const submitInput = {
+        ...props.data.inputs['cmt_submit'],
+        hide_errors: true,
+        icon: 'ArrowUp',
+        style: 'bordered',
+        rounded: 'true',
+        icon_only: true,
+    }
+    const imageInput = {
+        ...props.data.inputs['cmt_image'],
+        rounded: 'true',
+        variant: 'default',
+    }
     // Comment storage decides: images-only modules keep the photo icon, others get a generic attachment.
-    const attachIcon = getStoragePickerKind(props.data.inputs['cmt_image'].ext_allow) === 'any' ? 'Paperclip' : 'Image'
+    const attachIcon = getStoragePickerKind(imageInput.ext_allow) === 'any' ? 'Paperclip' : 'Image'
 
     const hasContent = !!hasText || !!imagesValue || props.data.inputs['cmt_text'].html == 2 || props.data.inputs['cmt_text'].html == 1
-    // Jobs reviews send `cmt_mood` (star rating). Keep it on the composer
-    // toolbar so the empty pill stays a single line — same row as the
-    // placeholder and attach button — then it drops to the bottom with
-    // attach/send once the composer expands.
-    const hasMood = !!props.data.inputs['cmt_mood']
+    // The mood stars sit on the composer toolbar so the empty pill stays a single
+    // line — same row as the placeholder and attach button — then drop to the
+    // bottom with attach/send once the composer expands.
     const moodInline = hasMood && !hasContent
 
     useEffect(() => {
@@ -184,7 +192,7 @@ export default function FormComments(props) {
                                     {hasContent ? (
                                         <View>
                                             {getFormFieldByData(
-                                                props.data.inputs['cmt_submit'],
+                                                submitInput,
                                                 handleSubmitWithSanitization,
                                                 'custom',
                                                 {
@@ -224,7 +232,7 @@ export default function FormComments(props) {
             <ScrollView horizontal={true}>
                 <Row className={imagesValue ? 'flex-wrap mb-1 mx-1' : 'flex-wrap'}>
                     {getFormFieldByData(
-                        props.data.inputs['cmt_image'],
+                        imageInput,
                         props.handleSubmit,
                         'notitle',
                         { hide_button: true, list_only: true, asDefaultStorage: true, form_name: props.name}

+ 20 - 11
neo/packages/app/components/forms/messenger.js

@@ -12,6 +12,9 @@ import emitter, { EVENTS } from 'app/context/emitter'
 import { useTranslation } from 'react-i18next'
 import { MessageInput } from 'app/components/elements/chat/parts/message-input'
 
+/** Send time in seconds. Module level on purpose: it is called from the submit handler, never while rendering. */
+const nowInSeconds = () => Math.floor(Date.now() / 1000)
+
 export default function FormMessenger(props) {
     const { t } = useTranslation()
     const isWeb = Platform.OS == 'web'
@@ -54,6 +57,7 @@ export default function FormMessenger(props) {
         if (!editorHtmlIsSubmittable(sanitizedHtml) && !hasFiles(images)) return
 
         formContext.setValue('message', sanitizedHtml, { shouldValidate: true, shouldDirty: true })
+        formContext.setValue('payload', nowInSeconds())
         props.handleSubmit()
     }
 
@@ -61,15 +65,20 @@ export default function FormMessenger(props) {
         handleSubmitWithSanitization()
     }
 
-    props.data.inputs['payload'].value = parseInt((new Date()).getTime() / 1000)
-
-    props.data.inputs['submit'].hide_errors = true
-    props.data.inputs['submit'].icon = 'ArrowUp'
-    props.data.inputs['submit'].style = 'bordered'
-    props.data.inputs['submit'].rounded = 'true'
-    props.data.inputs['submit'].icon_only = true
-    props.data.inputs['files'].rounded = 'true'
-    props.data.inputs['files'].variant = 'default'
+    // Copies with the composer's look; `props` itself is never written to.
+    const submitInput = {
+        ...props.data.inputs['submit'],
+        hide_errors: true,
+        icon: 'ArrowUp',
+        style: 'bordered',
+        rounded: 'true',
+        icon_only: true,
+    }
+    const filesInput = {
+        ...props.data.inputs['files'],
+        rounded: 'true',
+        variant: 'default',
+    }
 
     const hasContent = !!hasText || !!imagesValue
 
@@ -100,7 +109,7 @@ export default function FormMessenger(props) {
                 {hasContent ? (
                     <View className="">
                         {getFormFieldByData(
-                            props.data.inputs['submit'],
+                            submitInput,
                             handleSubmitWithSanitization,
                             'custom',
                             {
@@ -126,7 +135,7 @@ export default function FormMessenger(props) {
                 <ScrollView horizontal={true}>
                     <Row className="flex-wrap  mx-1">
                         {getFormFieldByData(
-                            props.data.inputs['files'],
+                            filesInput,
                             props.handleSubmit,
                             'notitle',
                             { hide_button: true, list_only: true, asDefaultStorage: true, form_name: props.name }

+ 22 - 10
neo/packages/app/lib/browse-query/index.ts

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useLayoutEffect, 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'
@@ -57,6 +57,13 @@ function parseTimelinePayload(payload: unknown): TimelineSocketEvent | null {
     return payload && typeof payload === 'object' ? (payload as TimelineSocketEvent) : null
 }
 
+/** A page without the rows the socket reported deleted since it was requested. */
+function withoutDeleted(page: any, deletedIds: Set<string>) {
+    return deletedIds.size
+        ? { ...page, data: (page?.data ?? []).filter((it: any) => !deletedIds.has(String(it.id))) }
+        : page
+}
+
 /**
  * Shared UniList infinite query: TanStack is the store.
  * First page is always fetched (no page-JSON seed). Skeletons until that returns.
@@ -97,11 +104,16 @@ export function useUniListQuery({
     const matchesNewContentRef = useRef(matchesNewContent)
     const canFetchNextRef = useRef(canFetchNext)
 
-    requestUrlRef.current = requestUrl
-    paramsRef.current = defaultParams
-    qKeyRef.current = queryKey
-    matchesNewContentRef.current = matchesNewContent
-    canFetchNextRef.current = canFetchNext
+    // "Latest value" refs: written in a layout effect, not during render (React Compiler
+    // rule). Layout effects run before every passive effect, including TanStack's fetch,
+    // so `queryFn` and the socket handlers below always see the current values.
+    useLayoutEffect(() => {
+        requestUrlRef.current = requestUrl
+        paramsRef.current = defaultParams
+        qKeyRef.current = queryKey
+        matchesNewContentRef.current = matchesNewContent
+        canFetchNextRef.current = canFetchNext
+    })
 
     const qKeyId = JSON.stringify(queryKey)
     const queryEnabled = Boolean(enabled && requestUrl)
@@ -202,16 +214,16 @@ export function useUniListQuery({
         const loadNewItems = async () => {
             timer = null
             const cacheKey = qKeyRef.current
+            // The effect was torn down, or the list moved on to another query, while fetching.
+            const isStale = () => cancelled || 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 (isStale()) return
+                const page = withoutDeleted(freshPage, deletedIds)
                 if (mergeFirstPageIntoUniListQueryCache(queryClient, cacheKey, page)) {
                     refetchRef.current.skipToast = true
                     setRequested(true)

+ 16 - 11
neo/packages/app/lib/hooks/use-pulse-once.ts

@@ -4,6 +4,18 @@ import { AccessibilityInfo, Animated } from 'react-native';
 import { nativeDriver } from 'app/lib/platform/animation';
 import type { PulseOnceOptions } from './use-pulse-once.types';
 
+/**
+ * The system "reduce motion" setting; a failed or missing lookup counts as off.
+ * Kept out of the hook: React Compiler can't compile a conditional inside try/catch.
+ */
+async function prefersReducedMotion(): Promise<boolean> {
+    try {
+        return AccessibilityInfo.isReduceMotionEnabled ? await AccessibilityInfo.isReduceMotionEnabled() : false;
+    } catch {
+        return false;
+    }
+}
+
 /**
  * Parameters:
  * - pulseDurationMs: duration of ONE pulse (down-up), default 3000ms
@@ -48,17 +60,10 @@ export function usePulseOne({
             return;
         }
 
-        try {
-            if (respectReducedMotion && AccessibilityInfo.isReduceMotionEnabled) {
-                const reduced = await AccessibilityInfo.isReduceMotionEnabled();
-                if (reduced) {
-                    opacity.setValue(1);
-                    onEnd && onEnd();
-                    return;
-                }
-            }
-        } catch {
-            // Silently ignore AccessibilityInfo errors
+        if (respectReducedMotion && (await prefersReducedMotion())) {
+            opacity.setValue(1);
+            onEnd && onEnd();
+            return;
         }
 
         const anim = buildAnimation();

+ 2 - 2
neo/packages/app/ui/atoms/dropdown-menu.tsx

@@ -251,7 +251,7 @@ function DropdownMenuPopup({
                     content: resolveContent ? resolveContent(item.content, item) : item.content as ReactNode,
                 }) : openModalByUrl(item.link)) : redirectdRef.current?.redirect('' + item.link);
         },
-        [onSelect, setIsOpen, resolveContent]
+        [onSelect, setIsOpen, resolveContent, openModalWithContent, openModalByUrl]
     );
 
     useEffect(() => {
@@ -421,7 +421,7 @@ const MenuBottomSheet = memo(({ items, onSelect, setBottomSheetData, resolveCont
             }
             redirectdRef.current?.redirect('' + item.link);
         },
-        [onSelect, setBottomSheetData, openModalByUrl, openModalWithContent]
+        [onSelect, setBottomSheetData, openModalByUrl, openModalWithContent, resolveContent]
     );
 
     const classes = isGridLayout ? variantClassMap.grid : variantClassMap.vertical;

+ 37 - 34
neo/packages/app/ui/molecules/content/gallery.js

@@ -1,4 +1,4 @@
-import { useRef, useState, useEffect } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useAnimatedValue } from 'app/lib/hooks/use-animated-value';
 import { Animated, PanResponder, Dimensions } from 'react-native';
 import { View } from 'app/design/view';
@@ -17,7 +17,7 @@ export default function Gallery({ items, autoscroll }) {
     const [isPaused, setIsPaused] = useState(false);
 
 
-    const animateToIndex = (newIndex, direction) => {
+    const animateToIndex = useCallback((newIndex, direction) => {
         Animated.parallel([
             Animated.timing(position, {
                 toValue: direction * -screenWidth,
@@ -45,47 +45,50 @@ export default function Gallery({ items, autoscroll }) {
                 }),
             ]).start();
         });
-    };
+    }, [position, opacity]);
 
-    const goLeft = () => {
+    const goLeft = useCallback(() => {
         const prevIndex = (currentIndex - 1 + items.length) % items.length;
         animateToIndex(prevIndex, -1);
-    };
+    }, [currentIndex, items.length, animateToIndex]);
 
-    const goRight = () => {
+    const goRight = useCallback(() => {
         const nextIndex = (currentIndex + 1) % items.length;
         animateToIndex(nextIndex, 1);
-    };
+    }, [currentIndex, items.length, animateToIndex]);
 
-// Auto-scroll
-useEffect(() => {
-    if (!autoscroll || isPaused || items.length <= 1) return;
-    
-    const interval = typeof autoscroll === 'number' ? autoscroll : 3000;
-    const timer = setInterval(() => {
-        goRight();
-    }, interval);
+    // Auto-scroll; restarts after every slide change (manual or automatic).
+    useEffect(() => {
+        if (!autoscroll || isPaused || items.length <= 1) return;
 
-    return () => clearInterval(timer);
-}, [autoscroll, isPaused, currentIndex, items.length]);
+        const interval = typeof autoscroll === 'number' ? autoscroll : 3000;
+        const timer = setInterval(goRight, interval);
 
-    const panResponder = useRef(
-        PanResponder.create({
-            onMoveShouldSetPanResponder: (_, gesture) =>
-                Math.abs(gesture.dx) > 20,
-            onPanResponderGrant: () => {
-                setIsPaused(true); 
-            },
-            onPanResponderRelease: (_, gesture) => {
-                setIsPaused(false); 
-                if (gesture.dx > 50) {
-                    goLeft();
-                } else if (gesture.dx < -50) {
-                    goRight();
-                }
-            },
-        })
-    ).current;
+        return () => clearInterval(timer);
+    }, [autoscroll, isPaused, items.length, goRight]);
+
+    // Rebuilt only when the slide changes (goLeft/goRight depend on it), so a swipe in
+    // progress keeps its responder. Created once, it kept the first render's currentIndex
+    // and every swipe stepped from slide 0.
+    const panResponder = useMemo(
+        () =>
+            PanResponder.create({
+                onMoveShouldSetPanResponder: (_, gesture) =>
+                    Math.abs(gesture.dx) > 20,
+                onPanResponderGrant: () => {
+                    setIsPaused(true);
+                },
+                onPanResponderRelease: (_, gesture) => {
+                    setIsPaused(false);
+                    if (gesture.dx > 50) {
+                        goLeft();
+                    } else if (gesture.dx < -50) {
+                        goRight();
+                    }
+                },
+            }),
+        [goLeft, goRight]
+    );
 
     return (
         <View className=' w-full overflow-hidden py-2 px-0.5 -my-2 ' {...panResponder.panHandlers}>