From 6455b55c6607bf5f863c12a6469f17ad0c29aeff Mon Sep 17 00:00:00 2001 From: Alex Hoffer Date: Wed, 22 Jul 2026 14:04:46 -0700 Subject: [PATCH] Preserve shared Link script during loading --- src/react-script-hook/index.test.tsx | 34 +++++++++++++++++++++++++++- src/react-script-hook/index.tsx | 23 ++++++++++++++----- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/react-script-hook/index.test.tsx b/src/react-script-hook/index.test.tsx index 913b3b71..9b748e1d 100644 --- a/src/react-script-hook/index.test.tsx +++ b/src/react-script-hook/index.test.tsx @@ -153,6 +153,38 @@ describe('useScript', () => { expect(handle2.result.current).toStrictEqual([false, null]); }); + it('should keep a loading script when one of multiple hooks unmounts', () => { + const src = 'http://scriptsrc/shared'; + const handle1 = renderHook(() => useScript({ src })); + const handle2 = renderHook(() => useScript({ src })); + + handle1.unmount(); + + const script = document.querySelector(`script[src="${src}"]`); + expect(script).not.toBeNull(); + expect(scripts[src]).toBeDefined(); + + act(() => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + script!.dispatchEvent(new Event('load')); + }); + + expect(handle2.result.current).toStrictEqual([false, null]); + }); + + it('should remove a loading script after all hooks unmount', () => { + const src = 'http://scriptsrc/shared'; + const handle1 = renderHook(() => useScript({ src })); + const handle2 = renderHook(() => useScript({ src })); + + handle1.unmount(); + expect(document.querySelector(`script[src="${src}"]`)).not.toBeNull(); + + handle2.unmount(); + expect(document.querySelector(`script[src="${src}"]`)).toBeNull(); + expect(scripts[src]).toBeUndefined(); + }); + it('should set loading true if previously loaded', async () => { const props = { src: 'http://scriptsrc/' }; const handle1 = renderHook((p) => useScript(p), { @@ -294,4 +326,4 @@ describe('useScript', () => { expect(Object.keys(scripts).length).toBe(0); expect(scripts[testSrc]).toBeUndefined(); }); -}); \ No newline at end of file +}); diff --git a/src/react-script-hook/index.tsx b/src/react-script-hook/index.tsx index acbcc5a6..55254da7 100644 --- a/src/react-script-hook/index.tsx +++ b/src/react-script-hook/index.tsx @@ -15,6 +15,7 @@ type ScriptStatus = { loading: boolean; error: ErrorState; scriptEl: HTMLScriptElement; + subscribers: number; }; type ScriptStatusMap = { [key: string]: ScriptStatus; @@ -36,6 +37,7 @@ const checkExisting = (src: string): ScriptStatus | undefined => { loading: false, error: null, scriptEl: existing, + subscribers: 0, }); } return undefined; @@ -100,18 +102,21 @@ export default function useScript({ loading: true, error: null, scriptEl: scriptEl, + subscribers: 0, }; } // `status` is now guaranteed to be defined: either the old status // from a previous load, or a newly created one. + const subscribedStatus = status; + subscribedStatus.subscribers += 1; const handleLoad = () => { - if (status) status.loading = false; + subscribedStatus.loading = false; setLoading(false); setScriptLoaded(true); }; const handleError = (error: ErrorEvent) => { - if (status) status.error = error; + subscribedStatus.error = error; setError(error); }; @@ -123,19 +128,25 @@ export default function useScript({ return () => { scriptEl.removeEventListener('load', handleLoad); scriptEl.removeEventListener('error', handleError); + subscribedStatus.subscribers -= 1; // if we unmount, and we are still loading the script, then // remove from the DOM & cache so we have a clean slate next time. // this is similar to the `removeOnUnmount` behavior of the TS useScript hook // https://github.com/juliencrn/usehooks-ts/blob/20667273744a22dd2cd2c48c38cd3c10f254ae47/packages/usehooks-ts/src/useScript/useScript.ts#L134 - // but only applied when loading - if (status && status.loading) { + // but only applied when loading and no other hook still needs it + if ( + subscribedStatus.loading && + subscribedStatus.subscribers === 0 + ) { scriptEl.remove(); - delete scripts[src]; + if (scripts[src] === subscribedStatus) { + delete scripts[src]; + } } }; // we need to ignore the attributes as they're a new object per call, so we'd never skip an effect call }, [src]); return [loading, error]; -} \ No newline at end of file +}