From 3cf56d7d48e350be2bcfe3f1eea78f32b34b8f46 Mon Sep 17 00:00:00 2001 From: linhh-phv Date: Tue, 20 Oct 2026 22:14:32 +0700 Subject: [PATCH] fix(script-manager): allow retry after resolveScript failure `loadScript` and `prefetchScript` awaited `resolveScript` outside the try/finally that removes the entry from `scriptsPromises`. When a resolver threw, the rejected promise stayed cached and every later call for the same script returned the same error without resolving again. Remove the cached promise when resolving fails and rethrow, so the next call can retry. --- .../script-manager-resolve-failure-retry.md | 5 ++ .../modules/ScriptManager/ScriptManager.ts | 40 ++++++++++----- .../__tests__/ScriptManager.test.ts | 51 +++++++++++++++++++ 3 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 .changeset/script-manager-resolve-failure-retry.md diff --git a/.changeset/script-manager-resolve-failure-retry.md b/.changeset/script-manager-resolve-failure-retry.md new file mode 100644 index 000000000..a11298ff3 --- /dev/null +++ b/.changeset/script-manager-resolve-failure-retry.md @@ -0,0 +1,5 @@ +--- +"@callstack/repack": patch +--- + +Fix `ScriptManager.loadScript` and `ScriptManager.prefetchScript` caching a failed resolution forever. `resolveScript` was awaited outside the `try`/`finally` that clears `scriptsPromises`, so when a resolver threw (e.g. the device was offline while fetching the script URL), the rejected promise stayed cached and every later call for the same script returned that same error without calling the resolvers again, until the app was restarted. The rejected promise is now removed when resolving fails, so the next call retries. diff --git a/packages/repack/src/modules/ScriptManager/ScriptManager.ts b/packages/repack/src/modules/ScriptManager/ScriptManager.ts index 5c4f22f24..a55b73147 100644 --- a/packages/repack/src/modules/ScriptManager/ScriptManager.ts +++ b/packages/repack/src/modules/ScriptManager/ScriptManager.ts @@ -583,12 +583,20 @@ export class ScriptManager extends EventEmitter { } const loadProcess = async () => { - let script = await this.resolveScript( - options.scriptId, - options.caller, - options.webpackContext, - options.referenceUrl - ); + let script: Script; + try { + script = await this.resolveScript( + options.scriptId, + options.caller, + options.webpackContext, + options.referenceUrl + ); + } catch (error) { + // resolving failed - remove the rejected promise so that + // subsequent calls can retry instead of reusing this failure + delete this.scriptsPromises[uniqueId]; + throw error; + } try { if (this.hookMap.beforeLoad.isUsed()) { @@ -708,12 +716,20 @@ export class ScriptManager extends EventEmitter { return this.scriptsPromises[uniqueId]; } const loadProcess = async () => { - const script = await this.resolveScript( - scriptId, - caller, - webpackContext, - referenceUrl - ); + let script: Script; + try { + script = await this.resolveScript( + scriptId, + caller, + webpackContext, + referenceUrl + ); + } catch (error) { + // resolving failed - remove the rejected promise so that + // subsequent calls can retry instead of reusing this failure + delete this.scriptsPromises[uniqueId]; + throw error; + } try { this.emit('prefetching', script.toObject()); diff --git a/packages/repack/src/modules/ScriptManager/__tests__/ScriptManager.test.ts b/packages/repack/src/modules/ScriptManager/__tests__/ScriptManager.test.ts index 3172a008e..b9b2ea5f9 100644 --- a/packages/repack/src/modules/ScriptManager/__tests__/ScriptManager.test.ts +++ b/packages/repack/src/modules/ScriptManager/__tests__/ScriptManager.test.ts @@ -1050,6 +1050,57 @@ describe('ScriptManagerAPI', () => { spy.mockRestore(); }); + + it('should retry loadScript after resolver failure', async () => { + const spy = mockLoadScriptBasedOnFetch(); + + const resolver = jest + .fn() + .mockRejectedValueOnce(new Error('Network request failed')) + .mockImplementation(async (scriptId: string) => ({ + url: Script.getRemoteURL(scriptId), + })); + ScriptManager.shared.addResolver(resolver); + + await expect(ScriptManager.shared.loadScript('miniApp')).rejects.toThrow( + 'Network request failed' + ); + + // should not reuse the rejected promise from the first call + await expect( + ScriptManager.shared.loadScript('miniApp') + ).resolves.toBeUndefined(); + expect(resolver).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledTimes(1); + + spy.mockRestore(); + }); + + it('should retry prefetchScript after resolver failure', async () => { + const spy = jest.spyOn(NativeScriptManager, 'prefetchScript'); + spy.mockResolvedValue(null); + + const resolver = jest + .fn() + .mockRejectedValueOnce(new Error('Network request failed')) + .mockImplementation(async (scriptId: string) => ({ + url: Script.getRemoteURL(scriptId), + })); + ScriptManager.shared.addResolver(resolver); + + await expect( + ScriptManager.shared.prefetchScript('miniApp') + ).rejects.toThrow('Network request failed'); + + // should not reuse the rejected promise from the first call + await expect( + ScriptManager.shared.prefetchScript('miniApp') + ).resolves.toBeUndefined(); + expect(resolver).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledTimes(1); + + spy.mockRestore(); + }); }); function mockLoadScriptBasedOnFetch() {