fix(react): add missing destroy cleanups (#1096)

This commit is contained in:
rahim
2026-03-23 16:59:37 -07:00
committed by GitHub
parent 4af0f66462
commit 1792bae3b4
10 changed files with 107 additions and 37 deletions
@@ -109,4 +109,40 @@ describe('useDestroy', () => {
// Setup should only run once — the re-mount skips it
expect(setup).toHaveBeenCalledOnce();
});
it('calls teardown before destroy on unmount', () => {
const order: string[] = [];
const instance = { destroy: vi.fn(() => order.push('destroy')) };
const teardown = vi.fn(() => order.push('teardown'));
const { unmount } = renderHook(() => useDestroy(instance, undefined, teardown));
unmount();
vi.runAllTimers();
expect(teardown).toHaveBeenCalledOnce();
expect(instance.destroy).toHaveBeenCalledOnce();
expect(order).toEqual(['teardown', 'destroy']);
});
it('does not call teardown in StrictMode double-mount', () => {
const instance = { destroy: vi.fn() };
const teardown = vi.fn();
function TestComponent() {
useDestroy(instance, undefined, teardown);
return null;
}
render(
<StrictMode>
<TestComponent />
</StrictMode>
);
vi.runAllTimers();
expect(teardown).not.toHaveBeenCalled();
expect(instance.destroy).not.toHaveBeenCalled();
});
});
+13 -4
View File
@@ -2,6 +2,8 @@
import { useEffect, useRef } from 'react';
import { useLatestRef } from './use-latest-ref';
interface Destroyable {
destroy(): void;
}
@@ -17,20 +19,27 @@ interface Destroyable {
* @param instance - Object with a `destroy()` method.
* @param setup - Optional setup called on first mount. Skipped on StrictMode
* re-mount since the previous setup was never torn down.
* @param teardown - Optional teardown called right before `destroy()` on real
* unmount. Skipped on StrictMode simulated unmount.
*/
export function useDestroy(instance: Destroyable, setup?: () => void): void {
export function useDestroy(instance: Destroyable, setup?: () => void, teardown?: () => void): void {
const pendingRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const setupRef = useLatestRef(setup);
const teardownRef = useLatestRef(teardown);
useEffect(() => {
if (pendingRef.current !== null) {
clearTimeout(pendingRef.current);
pendingRef.current = null;
} else {
setup?.();
setupRef.current?.();
}
return () => {
pendingRef.current = setTimeout(() => instance.destroy(), 0);
pendingRef.current = setTimeout(() => {
teardownRef.current?.();
instance.destroy();
}, 0);
};
}, [instance, setup]);
}, [instance]);
}
@@ -0,0 +1,29 @@
'use client';
import type { Media } from '@videojs/core/dom';
import { useState } from 'react';
import { useMediaAttach } from '../player/context';
import { useDestroy } from './use-destroy';
/**
* Create and manage a media instance lifecycle within a player context.
*
* Instantiates the media class once, attaches it to the player on mount,
* and safely detaches on unmount using a functional updater to avoid race
* conditions when swapping media components (e.g. DashVideo HlsVideo).
*/
export function useMediaInstance<Instance extends Media & { destroy(): void }>(
MediaClass: new () => Instance
): Instance {
const [instance] = useState(() => new MediaClass());
const setMedia = useMediaAttach();
useDestroy(
instance,
() => setMedia?.(instance),
() => setMedia?.((prev) => (prev === instance ? null : prev))
);
return instance;
}