From 0bca6e6969a4a9a81551edb13bc5efd60a9da176 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 24 Feb 2026 13:07:06 +1300 Subject: [PATCH] fix(memory): close blob URL leak race in useVideoBlobUrl - Track created blob URL in async chain with createdUrl variable - Cleanup function revokes orphaned URLs created after cancel flag set - Remove unnecessary prevInputRef deduplication (React handles this) - Fixes race where rapid videoUrl changes orphan blob URLs in memory --- src/hooks/useVideoBlobUrl.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/hooks/useVideoBlobUrl.ts b/src/hooks/useVideoBlobUrl.ts index 7ca8de87..c9e45109 100644 --- a/src/hooks/useVideoBlobUrl.ts +++ b/src/hooks/useVideoBlobUrl.ts @@ -17,13 +17,8 @@ import { useEffect, useRef, useState } from "react"; export function useVideoBlobUrl(videoUrl: string | null): string | null { const [blobUrl, setBlobUrl] = useState(null); const prevBlobUrlRef = useRef(null); - const prevInputRef = useRef(null); useEffect(() => { - // Input unchanged — skip - if (videoUrl === prevInputRef.current) return; - prevInputRef.current = videoUrl; - // Revoke previous blob URL if (prevBlobUrlRef.current) { URL.revokeObjectURL(prevBlobUrlRef.current); @@ -47,11 +42,13 @@ export function useVideoBlobUrl(videoUrl: string | null): string | null { setBlobUrl(videoUrl); let cancelled = false; + let createdUrl: string | null = null; fetch(videoUrl) .then((r) => r.blob()) .then((blob) => { if (cancelled) return; const url = URL.createObjectURL(blob); + createdUrl = url; prevBlobUrlRef.current = url; setBlobUrl(url); }) @@ -61,6 +58,10 @@ export function useVideoBlobUrl(videoUrl: string | null): string | null { return () => { cancelled = true; + // If a blob URL was created after we decided to cancel, revoke it + if (createdUrl && createdUrl !== prevBlobUrlRef.current) { + URL.revokeObjectURL(createdUrl); + } }; }