Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/script-manager-resolve-failure-retry.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 28 additions & 12 deletions packages/repack/src/modules/ScriptManager/ScriptManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this change lets later calls try again, but one case stays open:
loadScript reuses the error fro running prefetch, so load that starts while prefetch fails also fails, and user must retry twice. Prefetch only fills the cache, so it must not decide the load:

if (isPrefetch) {
  // prefetch only fills native cache. Its failure must not fail this load.
  await this.scriptsPromises[uniqueId].catch(() => undefined);
}

non-blocking, but worth a test

throw error;
}

try {
if (this.hookMap.beforeLoad.isUsed()) {
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,57 @@ describe('ScriptManagerAPI', () => {

spy.mockRestore();
});

it('should retry loadScript after resolver failure', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

both new tests await between calls so shared cache is never exercised - but poisoning is a concurrency property. what about adding this one? I ran it & it passes:

it('should share a single resolution between concurrent loadScript calls and retry after 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);

  const results = await Promise.allSettled([
    ScriptManager.shared.loadScript('miniApp'),
    ScriptManager.shared.loadScript('miniApp'),
  ]);
  expect(results.map((r) => r.status)).toEqual(['rejected', 'rejected']);
  expect(resolver).toHaveBeenCalledTimes(1); // still deduped

  await expect(
    ScriptManager.shared.loadScript('miniApp')
  ).resolves.toBeUndefined();
  expect(resolver).toHaveBeenCalledTimes(2); // retry really happens

  spy.mockRestore();
});

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