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() {