Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: prevent to load script again #756

Merged
merged 15 commits into from
Oct 17, 2024
Merged
6 changes: 6 additions & 0 deletions .changeset/olive-singers-attack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@callstack/repack': patch
---

Prevent to loadScript which is already is loading
issue: https://github.com/callstack/repack/issues/749
95 changes: 67 additions & 28 deletions packages/repack/src/modules/ScriptManager/ScriptManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ type Cache = Record<
Pick<NormalizedScriptLocator, 'method' | 'url' | 'query' | 'headers' | 'body'>
>;

type ScriptsPromises = Record<
string,
(Promise<void> & { isPrefetch?: true }) | undefined
>;

const CACHE_NAME = 'Repack.ScriptManager.Cache';
const CACHE_VERSION = 'v4';
const CACHE_ENV = __DEV__ ? 'debug' : 'release';
Expand Down Expand Up @@ -100,6 +105,7 @@ export class ScriptManager extends EventEmitter {
}

protected cache: Cache = {};
protected scriptsPromises: ScriptsPromises = {};
protected cacheInitialized = false;
protected resolvers: [number, ScriptLocatorResolver][] = [];
protected storage?: StorageApi;
Expand Down Expand Up @@ -320,21 +326,39 @@ export class ScriptManager extends EventEmitter {
caller?: string,
webpackContext = getWebpackContext()
) {
const script = await this.resolveScript(scriptId, caller, webpackContext);

try {
this.emit('loading', script.toObject());
await this.loadScriptWithRetry(scriptId, script.locator);
this.emit('loaded', script.toObject());
} catch (error) {
const { code } = error as Error & { code: string };
this.handleError(
error,
'[ScriptManager] Failed to load script:',
code ? `[${code}]` : '',
script.toObject()
);
const uniqueId = Script.getScriptUniqueId(scriptId, caller);
if (this.scriptsPromises[uniqueId]) {
const { isPrefetch } = this.scriptsPromises[uniqueId];

// prefetch is not execute the script so we need to run loadScript if promise is for prefetch
if (isPrefetch) {
await this.scriptsPromises[uniqueId];
} else {
return this.scriptsPromises[uniqueId];
}
}
const loadProcess = async () => {
const script = await this.resolveScript(scriptId, caller, webpackContext);

try {
this.emit('loading', script.toObject());
await this.loadScriptWithRetry(scriptId, script.locator);
this.emit('loaded', script.toObject());
} catch (error) {
const { code } = error as Error & { code: string };
this.handleError(
error,
'[ScriptManager] Failed to load script:',
code ? `[${code}]` : '',
script.toObject()
);
}

delete this.scriptsPromises[uniqueId];
};

this.scriptsPromises[uniqueId] = loadProcess();
return this.scriptsPromises[uniqueId];
}

/**
Expand Down Expand Up @@ -390,20 +414,32 @@ export class ScriptManager extends EventEmitter {
caller?: string,
webpackContext = getWebpackContext()
) {
const script = await this.resolveScript(scriptId, caller, webpackContext);

try {
this.emit('prefetching', script.toObject());
await this.nativeScriptManager.prefetchScript(scriptId, script.locator);
} catch (error) {
const { code } = error as Error & { code: string };
this.handleError(
error,
'[ScriptManager] Failed to prefetch script:',
code ? `[${code}]` : '',
script.toObject()
);
const uniqueId = Script.getScriptUniqueId(scriptId, caller);
if (this.scriptsPromises[uniqueId]) {
return this.scriptsPromises[uniqueId];
}
const loadProcess = async () => {
const script = await this.resolveScript(scriptId, caller, webpackContext);

try {
this.emit('prefetching', script.toObject());
await this.nativeScriptManager.prefetchScript(scriptId, script.locator);
} catch (error) {
const { code } = error as Error & { code: string };
this.handleError(
error,
'[ScriptManager] Failed to prefetch script:',
code ? `[${code}]` : '',
script.toObject()
);
}

delete this.scriptsPromises[uniqueId];
};

this.scriptsPromises[uniqueId] = loadProcess();
this.scriptsPromises[uniqueId].isPrefetch = true;
return this.scriptsPromises[uniqueId];
}

/**
Expand All @@ -422,7 +458,10 @@ export class ScriptManager extends EventEmitter {
await this.initCache();

const ids = scriptIds.length ? scriptIds : Object.keys(this.cache);
ids.forEach((scriptId) => delete this.cache[scriptId]);
ids.forEach((scriptId) => {
delete this.cache[scriptId];
delete this.scriptsPromises[scriptId];
});

await this.saveCache();
await this.nativeScriptManager.invalidateScripts(scriptIds);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import NativeScriptManager from '../NativeScriptManager';
import NativeScriptManager, {
type NormalizedScriptLocator,
} from '../NativeScriptManager';
import { Script } from '../Script';
import { ScriptManager } from '../ScriptManager';

Expand Down Expand Up @@ -736,4 +738,114 @@ describe('ScriptManagerAPI', () => {
expect(NativeScriptManager.loadScript).toHaveBeenCalledTimes(3);
jest.useRealTimers();
});

it('should await loadScript with same scriptId to finish', async () => {
const spy = mockLoadScriptBasedOnFetch();

const cache = new FakeCache();
ScriptManager.shared.setStorage(cache);

ScriptManager.shared.addResolver(async (scriptId, _caller) => {
return {
url: Script.getRemoteURL(scriptId),
cache: true,
};
});

let loadingScriptIsFinished = false;

// loadScript should wait first time called loadScript although we are not awaited, because scriptId is same
ScriptManager.shared.loadScript('miniApp').then(() => {
loadingScriptIsFinished = true;
});
await ScriptManager.shared.loadScript('miniApp');

expect(loadingScriptIsFinished).toEqual(true);
spy.mockReset();
spy.mockRestore();
});

it('should wait loadScript with same scriptId to finished in a complex scenario', async () => {
const spy = mockLoadScriptBasedOnFetch();

const cache = new FakeCache();
ScriptManager.shared.setStorage(cache);

ScriptManager.shared.addResolver(async (scriptId, _caller) => {
return {
url: Script.getRemoteURL(scriptId),
cache: true,
};
});

let loadingScriptIsFinished = false;
let loadingScript2IsFinished = false;

// loadScript should wait first time called loadScript although we are not awaited, because scriptId is same
ScriptManager.shared.loadScript('miniApp').then(() => {
loadingScriptIsFinished = true;
});

ScriptManager.shared.loadScript('miniApp2').then(() => {
loadingScript2IsFinished = true;
});
await ScriptManager.shared.loadScript('miniApp');
expect(loadingScriptIsFinished).toEqual(true);

loadingScriptIsFinished = false;
ScriptManager.shared.loadScript('miniApp').then(() => {
loadingScriptIsFinished = true;
});

ScriptManager.shared.loadScript('miniApp2');
await ScriptManager.shared.loadScript('miniApp');

expect(loadingScriptIsFinished).toEqual(true);
await ScriptManager.shared.loadScript('miniApp2');
expect(loadingScript2IsFinished).toEqual(true);
spy.mockReset();
spy.mockRestore();
});

it('should wait loadScript and prefetchScript', async () => {
const spy = mockLoadScriptBasedOnFetch();

const cache = new FakeCache();
ScriptManager.shared.setStorage(cache);

ScriptManager.shared.addResolver(async (scriptId, _caller) => {
return {
url: Script.getRemoteURL(scriptId),
cache: true,
};
});

let prefetchScriptIsFinished = false;

// loadScript should wait first time called loadScript although we are not awaited, because scriptId is same
ScriptManager.shared.prefetchScript('miniApp').then(() => {
prefetchScriptIsFinished = true;
});
await ScriptManager.shared.loadScript('miniApp');

expect(prefetchScriptIsFinished).toEqual(true);
spy.mockReset();
spy.mockRestore();
});
});

function mockLoadScriptBasedOnFetch() {
jest.useFakeTimers({ advanceTimers: true });
const spy = jest.spyOn(NativeScriptManager, 'loadScript');

spy.mockImplementation(
(_scriptId: string, scriptConfig: NormalizedScriptLocator) =>
scriptConfig.fetch
? new Promise<null>((resolve) => {
setTimeout(() => resolve(null), 10);
})
: Promise.resolve(null)
);

return spy;
}