fix: avoid render-time ref access in height reveal

This commit is contained in:
777genius 2026-05-30 18:02:19 +03:00
parent 1ebeba8f6e
commit 0a8fbc9801
3 changed files with 52 additions and 31 deletions

View file

@ -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.

View file

@ -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

View file

@ -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<T>(ref: Ref<T> | undefined, value: T | null): void {
ref(value);
return;
}
const mutableRef = ref as MutableRefObject<T | null>;
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<AnimatedHeightRevealProps> {
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 <AnimatedHeightRevealPassthrough>{children}</AnimatedHeightRevealPassthrough>;
}
return (
<AnimatedHeightRevealInner
animate={animate}
className={className}
style={style}
containerRef={containerRef}
>
{children}
</AnimatedHeightRevealInner>
);
}
}
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 (
<AnimatedHeightRevealInner
animate={animate}
className={className}
style={style}
containerRef={containerRef}
<AnimatedHeightRevealSlot
animate={props.animate}
className={props.className}
style={props.style}
containerRef={props.containerRef}
>
{children}
</AnimatedHeightRevealInner>
{props.children}
</AnimatedHeightRevealSlot>
);
};