fix: avoid render-time ref access in height reveal
This commit is contained in:
parent
1ebeba8f6e
commit
0a8fbc9801
3 changed files with 52 additions and 31 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue