From 0a8fbc9801b8457983225a1e5fdef4e28d0bb84e Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 30 May 2026 18:02:19 +0300 Subject: [PATCH] fix: avoid render-time ref access in height reveal --- .../services/infrastructure/FileWatcher.ts | 4 +- .../infrastructure/TeamTaskWatchRegistry.ts | 2 +- .../team/activity/AnimatedHeightReveal.tsx | 77 ++++++++++++------- 3 files changed, 52 insertions(+), 31 deletions(-) diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 05cfe512..b689db4c 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -132,7 +132,7 @@ export class FileWatcher extends EventEmitter { private disposed = false; /** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files). * Floored to second granularity because filesystem birthtimeMs may have lower resolution - * than Date.now() — without this, a file created in the same millisecond-window could + * than Date.now() - without this, a file created in the same millisecond-window could * appear older than the watcher on some platforms (e.g. ext4 on Linux). */ private readonly instanceCreatedAt = Math.floor(Date.now() / 1000) * 1000; @@ -255,7 +255,7 @@ export class FileWatcher extends EventEmitter { * Inject the provider that decides which teams' team-root and task artifacts * are watched (typically alive ∪ engaged teams). The teams root and every * team's inboxes are always watched. Returning null (or leaving the provider - * unset) watches every team — the safe fallback / original behavior. + * unset) watches every team - the safe fallback / original behavior. * * Only the chokidar registry path is scoped; the EMFILE polling fallback still * watches every team so a scope change can never be mistaken for a deletion. diff --git a/src/main/services/infrastructure/TeamTaskWatchRegistry.ts b/src/main/services/infrastructure/TeamTaskWatchRegistry.ts index 7dc6874a..a8cba329 100644 --- a/src/main/services/infrastructure/TeamTaskWatchRegistry.ts +++ b/src/main/services/infrastructure/TeamTaskWatchRegistry.ts @@ -19,7 +19,7 @@ export interface TeamTaskWatchRegistryOptions { * artifacts should be watched. The root directory is always watched (to detect * new/removed teams), and for the 'teams' kind every team's `inboxes/` is * always watched (cross-team message delivery and notifications must stay - * immediate). Return `null` (or omit the provider) to watch every team — the + * immediate). Return `null` (or omit the provider) to watch every team - the * original behavior and the safe fallback. * * Scoping exists because team-root (config/kanban/processes/meta) and task diff --git a/src/renderer/components/team/activity/AnimatedHeightReveal.tsx b/src/renderer/components/team/activity/AnimatedHeightReveal.tsx index 1236715f..86c86b02 100644 --- a/src/renderer/components/team/activity/AnimatedHeightReveal.tsx +++ b/src/renderer/components/team/activity/AnimatedHeightReveal.tsx @@ -1,6 +1,6 @@ -import { type JSX, useCallback, useEffect, useRef, useState } from 'react'; +import { Component, type JSX, useCallback, useEffect, useRef, useState } from 'react'; -import type { CSSProperties, MutableRefObject, PropsWithChildren, Ref } from 'react'; +import type { CSSProperties, PropsWithChildren, ReactNode, Ref } from 'react'; export const ENTRY_REVEAL_ANIMATION_MS = 700; export const ENTRY_REVEAL_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; @@ -18,44 +18,65 @@ function assignRef(ref: Ref | undefined, value: T | null): void { ref(value); return; } - const mutableRef = ref as MutableRefObject; + const mutableRef = ref as { current: T | null }; mutableRef.current = value; } -export const AnimatedHeightReveal = ({ - animate, - className, - style, - containerRef, - children, -}: AnimatedHeightRevealProps): JSX.Element => { - const needsAnimatedWrapper = Boolean(animate || className || style || containerRef); +function needsAnimatedWrapper(props: AnimatedHeightRevealProps): boolean { + return Boolean(props.animate || props.className || props.style || props.containerRef); +} + +const AnimatedHeightRevealPassthrough = ({ children }: PropsWithChildren): JSX.Element => ( + // eslint-disable-next-line react/jsx-no-useless-fragment -- preserves a DOM-free passthrough slot. + <>{children} +); + +class AnimatedHeightRevealSlot extends Component { + private hasRenderedInner = needsAnimatedWrapper(this.props); + + // eslint-disable-next-line sonarjs/function-return-type -- latch intentionally switches from passthrough to animated slot once. + render(): ReactNode { + const { animate, className, style, containerRef, children } = this.props; + const needsWrapper = needsAnimatedWrapper(this.props); + if (needsWrapper) { + this.hasRenderedInner = true; + } + + if (!this.hasRenderedInner) { + return {children}; + } + + return ( + + {children} + + ); + } +} + +export const AnimatedHeightReveal = (props: AnimatedHeightRevealProps): JSX.Element => { // Latch the inner (hook-bearing, animating) variant for the lifetime of this slot. // A call site that only passes `animate` (e.g. animate={isNewItem}) flips it true->false // on the render right after the item appears. Without the latch the returned element type // would switch from AnimatedHeightRevealInner to a bare Fragment on that flip, so React - // would unmount the inner subtree mid-reveal — aborting the entry animation and remounting + // would unmount the inner subtree mid-reveal - aborting the entry animation and remounting // the children (losing focus/internal state). Once the inner variant has rendered we keep // rendering it so the element type stays stable; items that never need it keep the // hook-free fast path. - const hasRenderedInnerRef = useRef(needsAnimatedWrapper); - if (needsAnimatedWrapper) { - hasRenderedInnerRef.current = true; - } - - if (!hasRenderedInnerRef.current) { - return <>{children}; - } - return ( - - {children} - + {props.children} + ); };