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;
|
private disposed = false;
|
||||||
/** Timestamp when this FileWatcher instance was created (used to distinguish old vs new files).
|
/** 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
|
* 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). */
|
* appear older than the watcher on some platforms (e.g. ext4 on Linux). */
|
||||||
private readonly instanceCreatedAt = Math.floor(Date.now() / 1000) * 1000;
|
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
|
* Inject the provider that decides which teams' team-root and task artifacts
|
||||||
* are watched (typically alive ∪ engaged teams). The teams root and every
|
* are watched (typically alive ∪ engaged teams). The teams root and every
|
||||||
* team's inboxes are always watched. Returning null (or leaving the provider
|
* 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
|
* 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.
|
* 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
|
* 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
|
* new/removed teams), and for the 'teams' kind every team's `inboxes/` is
|
||||||
* always watched (cross-team message delivery and notifications must stay
|
* 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.
|
* original behavior and the safe fallback.
|
||||||
*
|
*
|
||||||
* Scoping exists because team-root (config/kanban/processes/meta) and task
|
* 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_ANIMATION_MS = 700;
|
||||||
export const ENTRY_REVEAL_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
|
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);
|
ref(value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const mutableRef = ref as MutableRefObject<T | null>;
|
const mutableRef = ref as { current: T | null };
|
||||||
mutableRef.current = value;
|
mutableRef.current = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AnimatedHeightReveal = ({
|
function needsAnimatedWrapper(props: AnimatedHeightRevealProps): boolean {
|
||||||
animate,
|
return Boolean(props.animate || props.className || props.style || props.containerRef);
|
||||||
className,
|
}
|
||||||
style,
|
|
||||||
containerRef,
|
const AnimatedHeightRevealPassthrough = ({ children }: PropsWithChildren): JSX.Element => (
|
||||||
children,
|
// eslint-disable-next-line react/jsx-no-useless-fragment -- preserves a DOM-free passthrough slot.
|
||||||
}: AnimatedHeightRevealProps): JSX.Element => {
|
<>{children}</>
|
||||||
const needsAnimatedWrapper = Boolean(animate || className || style || containerRef);
|
);
|
||||||
|
|
||||||
|
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.
|
// 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
|
// 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
|
// 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 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
|
// 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
|
// rendering it so the element type stays stable; items that never need it keep the
|
||||||
// hook-free fast path.
|
// hook-free fast path.
|
||||||
const hasRenderedInnerRef = useRef(needsAnimatedWrapper);
|
|
||||||
if (needsAnimatedWrapper) {
|
|
||||||
hasRenderedInnerRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasRenderedInnerRef.current) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedHeightRevealInner
|
<AnimatedHeightRevealSlot
|
||||||
animate={animate}
|
animate={props.animate}
|
||||||
className={className}
|
className={props.className}
|
||||||
style={style}
|
style={props.style}
|
||||||
containerRef={containerRef}
|
containerRef={props.containerRef}
|
||||||
>
|
>
|
||||||
{children}
|
{props.children}
|
||||||
</AnimatedHeightRevealInner>
|
</AnimatedHeightRevealSlot>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue