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
18 changes: 1 addition & 17 deletions plugins/atlas/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,23 +52,7 @@
"source": "/atlas-login",
"target": "/resources/login"
}
],
"uiplugins": {
"researcher": [
{
"name": "Atlas",
"nameI18nKey": "UI_PLUGIN_ATLAS",
"route": "atlas",
"pluginPath": "/atlas-portal/index.js",
"type": "app",
"autoMount": false,
"enabled": true,
"requiredRoles": [
"RESEARCHER"
]
}
]
}
]
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { cancelPreload, preloadNow, preloadWhenIdle, resetPreloadQueue } from "../preloadScheduler";

// Force the setTimeout fallback so the tests drive the scheduler with fake
// timers rather than depending on a jsdom requestIdleCallback.
const IDLE_FALLBACK_MS = 500;

const flush = async () => {
// Let already-resolved promise callbacks run between timer advances.
await Promise.resolve();
await Promise.resolve();
};

const advance = async (ms: number) => {
jest.advanceTimersByTime(ms);
await flush();
};

/** A load function whose promise this test resolves by hand. */
function deferred() {
let resolve!: () => void;
let reject!: (e: unknown) => void;
const promise = new Promise<void>((res, rej) => {
resolve = () => res();
reject = rej;
});
const load = jest.fn(() => promise);
return { load, resolve, reject };
}

describe("preloadScheduler", () => {
beforeEach(() => {
jest.useFakeTimers();
resetPreloadQueue();
delete (window as any).requestIdleCallback;
});

afterEach(() => {
jest.useRealTimers();
});

it("loads the active plugin straight away", () => {
const active = deferred();
preloadNow("active", active.load);
expect(active.load).toHaveBeenCalledTimes(1);
});

it("does not start a queued preload immediately", async () => {
const queued = deferred();
preloadWhenIdle("queued", queued.load);

expect(queued.load).not.toHaveBeenCalled();

await advance(IDLE_FALLBACK_MS);
expect(queued.load).toHaveBeenCalledTimes(1);
});

it("holds background preloads until the active one settles", async () => {
const active = deferred();
const queued = deferred();

preloadNow("active", active.load);
preloadWhenIdle("queued", queued.load);

// The active bundle is still in flight, so nothing else may compete.
await advance(IDLE_FALLBACK_MS * 4);
expect(queued.load).not.toHaveBeenCalled();

active.resolve();
await flush();
await advance(IDLE_FALLBACK_MS);
expect(queued.load).toHaveBeenCalledTimes(1);
});

it("drains the queue one bundle at a time", async () => {
const first = deferred();
const second = deferred();

preloadWhenIdle("first", first.load);
preloadWhenIdle("second", second.load);

await advance(IDLE_FALLBACK_MS);
expect(first.load).toHaveBeenCalledTimes(1);
expect(second.load).not.toHaveBeenCalled();

first.resolve();
await flush();
await advance(IDLE_FALLBACK_MS);
expect(second.load).toHaveBeenCalledTimes(1);
});

it("keeps draining after a preload fails", async () => {
const failing = deferred();
const next = deferred();

preloadWhenIdle("failing", failing.load);
preloadWhenIdle("next", next.load);

await advance(IDLE_FALLBACK_MS);
expect(failing.load).toHaveBeenCalledTimes(1);

failing.reject(new Error("network"));
await flush();
await advance(IDLE_FALLBACK_MS);
expect(next.load).toHaveBeenCalledTimes(1);
});

it("does not download a plugin whose preload was cancelled", async () => {
// A plugin can unmount before its turn comes up — a dataset switch
// remounts the whole researcher container. Downloading it then would
// compete with whatever the user moved on to.
const going = deferred();
const staying = deferred();

preloadWhenIdle("going", going.load);
preloadWhenIdle("staying", staying.load);
cancelPreload("going");

await advance(IDLE_FALLBACK_MS);

expect(going.load).not.toHaveBeenCalled();
expect(staying.load).toHaveBeenCalledTimes(1);
});

it("queues one entry per plugin, so a re-register cannot double up", async () => {
// `generateAppId` derives the id from the path alone, so a plugin that is
// unregistered and registered again reuses it. Two entries would put two
// background downloads on the wire.
const plugin = deferred();

preloadWhenIdle("plugin", plugin.load);
preloadWhenIdle("plugin", plugin.load);

await advance(IDLE_FALLBACK_MS);
expect(plugin.load).toHaveBeenCalledTimes(1);

plugin.resolve();
await flush();
await advance(IDLE_FALLBACK_MS * 2);
expect(plugin.load).toHaveBeenCalledTimes(1);
});

it("holds background preloads until every foreground preload settles", async () => {
// Two plugins can both match the current location when their base paths
// nest. With a boolean flag the first to settle resumed the drain while
// the second was still on the wire.
const firstActive = deferred();
const secondActive = deferred();
const queued = deferred();

preloadNow("active-a", firstActive.load);
preloadNow("active-b", secondActive.load);
preloadWhenIdle("queued", queued.load);

firstActive.resolve();
await flush();
await advance(IDLE_FALLBACK_MS * 2);
expect(queued.load).not.toHaveBeenCalled();

secondActive.resolve();
await flush();
await advance(IDLE_FALLBACK_MS);
expect(queued.load).toHaveBeenCalledTimes(1);
});
});
133 changes: 133 additions & 0 deletions plugins/ui/apps/portal/src/singleSpa/preloadScheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Schedules plugin bundle preloads so the active plugin is not starved.
*
* The researcher container pre-renders a container for every `type: "app"`
* plugin, so `registerSingleSpaApp` runs for all of them on the same tick. An
* unconditional eager preload therefore put six bundles on the wire together.
*
* On a constrained link that starves the plugin the user actually asked for.
* Measured at 4 Mbit/s with six bundles in flight, vue-mri's 1,036 KB entry
* took 8,935 ms, an effective 116 KB/s out of the 500 KB/s the link allows.
*
* The plugin whose route is already active still preloads immediately, which
* is what closes the LOADING_SOURCE_CODE race that the eager preload was added
* for. Every other plugin is queued and drained one at a time while the browser
* is idle, so at most one background download competes with the foreground one.
*
* Deferring is only an optimisation. If the user opens a plugin before its
* queued preload has run, single-spa calls the app's load function on
* activation and the module cache dedupes the two callers.
*/

type PreloadTask = { id: string; load: () => Promise<unknown> };

const queue: PreloadTask[] = [];
let draining = false;
// How many foreground preloads are downloading. Background preloads wait for
// all of them, so the plugin the user opened gets the whole link.
//
// A counter rather than a boolean: two registered plugins can both match the
// current location if their base paths nest, and with a boolean the first to
// settle would resume background draining while the second was still on the
// wire.
let foregroundInFlight = 0;

const IDLE_TIMEOUT_MS = 5000;
const FALLBACK_DELAY_MS = 500;

function whenIdle(run: () => void): void {
const idle = (window as any).requestIdleCallback;
if (typeof idle === "function") {
idle(() => run(), { timeout: IDLE_TIMEOUT_MS });
return;
}
window.setTimeout(run, FALLBACK_DELAY_MS);
}

function drain(): void {
if (foregroundInFlight > 0) {
// A foreground preload started while we were waiting for idle. Stand down;
// preloadNow restarts the drain once it settles.
draining = false;
return;
}

const next = queue.shift();
if (!next) {
draining = false;
return;
}

console.debug(`[preloadScheduler] ${next.id} - background preload`);
// Chain the next task on settle, not on success, so one failing bundle does
// not strand the rest of the queue.
next
.load()
.catch(() => undefined)
.then(() => whenIdle(drain));
}

/**
* Preload immediately. Use for the plugin whose route is already active.
*
* Background preloads start only once this one settles, so the active plugin
* gets the whole link rather than sharing it.
*/
export function preloadNow(id: string, load: () => Promise<unknown>): void {
console.debug(`[preloadScheduler] ${id} - foreground preload`);
foregroundInFlight += 1;
// Fire and forget. A failure here is surfaced later by single-spa when it
// calls the load function through its own lifecycle.
load()
.catch((error) => {
console.debug(`[preloadScheduler] ${id} - preload failed (will retry on activation):`, error);
})
.then(() => {
foregroundInFlight -= 1;
startDraining();
});
}

/** Queue a preload to run in the background, one bundle at a time. */
export function preloadWhenIdle(id: string, load: () => Promise<unknown>): void {
// One entry per plugin. A plugin can be registered again after an unload —
// `generateAppId` derives the id from the path alone, so the second
// registration reuses it — and two entries for one id would put two
// background downloads on the wire, which is the thing this file exists to
// prevent.
if (queue.some((task) => task.id === id)) {
console.debug(`[preloadScheduler] ${id} - already queued`);
return;
}
queue.push({ id, load });
startDraining();
}

/**
* Drop a plugin's queued preload. Call this when a plugin is unregistered.
*
* Without it a plugin that unmounts before its turn comes up still gets
* downloaded, competing with whatever the user moved on to. A dataset switch
* remounts the whole researcher container, so this is a normal event, not an
* edge case. A preload already in flight is left alone: the bytes are spent,
* and the module cache makes them harmless.
*/
export function cancelPreload(id: string): void {
const index = queue.findIndex((task) => task.id === id);
if (index === -1) return;
queue.splice(index, 1);
console.debug(`[preloadScheduler] ${id} - queued preload cancelled`);
}

function startDraining(): void {
if (draining || foregroundInFlight > 0) return;
draining = true;
whenIdle(drain);
}

/** Test seam: drop anything still queued. */
export function resetPreloadQueue(): void {
queue.length = 0;
draining = false;
foregroundInFlight = 0;
}
33 changes: 23 additions & 10 deletions plugins/ui/apps/portal/src/singleSpa/singleSpaRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import {
NOT_MOUNTED,
} from "single-spa";
import { RegisteredApp, SingleSpaPluginConfig } from "./types";
import { createActivityFunction, generateContainerId } from "./utils";
import { createActivityFunction, generateContainerId, matchesBasePath } from "./utils";
import { resolveModuleUrl } from "./overrideUtils";
import { cancelPreload, preloadNow, preloadWhenIdle } from "./preloadScheduler";

const registeredApps: Map<string, RegisteredApp> = new Map();
const moduleCache: Map<string, Promise<any>> = new Map();
Expand Down Expand Up @@ -60,15 +61,21 @@ export async function registerSingleSpaApp(config: SingleSpaPluginConfig): Promi
isActive: false,
});

// Eagerly kick off the bundle download so the module is cached in
// moduleCache by the time activeWhen first fires. Fire-and-forget — if
// the load fails, single-spa will surface the error when it calls
// app() through its normal lifecycle path. This eliminates the
// LOADING_SOURCE_CODE race window where a portal switch can catch a
// heavy bundle (e.g. vue-mri) mid-download.
loadModule().catch(error => {
console.debug(`[singleSpaRegistry] ${config.id} - preload failed (will retry on activation):`, error);
});
// Kick off the bundle download so the module is cached in moduleCache by the
// time activeWhen first fires. This eliminates the LOADING_SOURCE_CODE race
// window where a portal switch can catch a heavy bundle (e.g. vue-mri)
// mid-download.
//
// The researcher container registers every plugin on the same tick, so
// preloading all of them at once put six bundles on the wire together and
// starved whichever one the user had actually opened. Only the plugin on the
// current route preloads straight away; the rest queue and download one at a
// time in the background. See preloadScheduler.ts.
if (matchesBasePath(config.basePath, window.location)) {
preloadNow(config.id, loadModule);
} else {
preloadWhenIdle(config.id, loadModule);
}
}

export function updateCustomProps(appId: string, customProps: Record<string, any>): void {
Expand Down Expand Up @@ -103,6 +110,12 @@ export async function unloadSingleSpaApp(appId: string): Promise<void> {
const status = getAppStatus(appId);
console.debug(`[singleSpaRegistry] ${appId} - unregistering, current status: ${status}`);

// Before anything else: if this plugin's background preload has not run yet,
// drop it. Nobody is waiting for the bundle now, and downloading it would
// compete with whatever the user moved on to. Safe even in the deferred
// branch below, because a re-register queues the preload again.
cancelPreload(appId);

try {
if (status === MOUNTED || status === NOT_MOUNTED || status === NOT_LOADED) {
await unregisterApplication(appId);
Expand Down
Loading
Loading