mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 09:26:14 -08:00
fix(electron): show the main window once its document has loaded; enforce J1 IPC and mutation counters (#1828)
* fix(electron): show the main window once its document has loaded; enforce J1 IPC and mutation counters Re-lands #1788, which merged into #1782's branch after #1782 had already reached master, so none of it is on master. The hidden main window was shown on ready-to-show only. On Linux under X11, when the startup scripts run before the window's first frame, the next frame comes about a second later: nothing is on screen and the splash's requestAnimationFrame waits, so J1's first card came ~940 ms after load instead of ~480 ms in most runner launches (18 bridge calls / 1,031-1,033 DOM mutations instead of 15 / 558). The window is now shown at ready-to-show or the main frame's did-finish-load, whichever comes first, with the splash colour as its background so showing before the first paint does not flash. The journey gate keeps the app's did-finish-load listeners away from its about:blank detour, as it already does for ready-to-show. Three dispatched runs on this branch (37192092882, 37192097790, 37192103151) read 15 calls and 558 mutations in all 18 iterations, stable: true. Both become baselines (slack 0), and the Performance journeys job checks them with check-journey-ratchet.mjs --only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(perf): record the evidence PR of the J1 runtime baselines Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: 4gray <fourgray@proton.me>
This commit is contained in:
17 files changed
+451
-39
No files matched your search
@@ -12,6 +12,8 @@ export interface JourneyRendererGateState {
|
||||
readonly gatedEpochMs: number | null;
|
||||
readonly gatedMethod: string | null;
|
||||
readonly passThroughLoads: number;
|
||||
/** `did-finish-load` events kept from the app's listeners on about:blank. */
|
||||
readonly didFinishLoadHeldOnBlank: number;
|
||||
/** `ready-to-show` events dropped while the window was on about:blank. */
|
||||
readonly readyToShowHeldOnBlank: number;
|
||||
readonly releasedEpochMs: number | null;
|
||||
|
||||
@@ -14,6 +14,7 @@ function gate(
|
||||
blankLoadedEpochMs: 1_050,
|
||||
errors: [],
|
||||
gatedEpochMs: 1_020,
|
||||
didFinishLoadHeldOnBlank: 1,
|
||||
gatedMethod: 'loadFile',
|
||||
passThroughLoads: 0,
|
||||
readyToShowHeldOnBlank: 1,
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
* therefore drops `ready-to-show` while the window is on `about:blank`;
|
||||
* Electron emits it again for the real document's first paint, because the
|
||||
* window is still hidden, which is the moment production sees.
|
||||
* The app also shows its window at the main frame's `did-finish-load`
|
||||
* when that comes first, so the gate keeps the app's `did-finish-load`
|
||||
* listeners (those registered before the gated load) away from the
|
||||
* about:blank load too. Electron's own listener that resolves
|
||||
* `loadURL(about:blank)` is registered later and still runs.
|
||||
*
|
||||
* With `ipcMain` passed in, the gate also keeps the listeners registered
|
||||
* with `ipcMain.handle` for `TAPPED_IPC_CHANNELS`, so the test can call a
|
||||
@@ -53,6 +58,38 @@ function holdReadyToShowWhileBlank(window, state) {
|
||||
};
|
||||
}
|
||||
|
||||
function holdDidFinishLoadWhileBlank(window, state) {
|
||||
const contents = window.webContents;
|
||||
if (
|
||||
!contents ||
|
||||
typeof contents.emit !== 'function' ||
|
||||
typeof contents.rawListeners !== 'function'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const appListeners = contents.rawListeners('did-finish-load');
|
||||
const originalEmit = contents.emit;
|
||||
contents.emit = function gatedContentsEmit(eventName, ...args) {
|
||||
if (eventName !== 'did-finish-load' || !isShowingBlank(window)) {
|
||||
return originalEmit.call(this, eventName, ...args);
|
||||
}
|
||||
state.didFinishLoadHeldOnBlank += 1;
|
||||
const attached = this.rawListeners(eventName);
|
||||
const held = appListeners.filter((listener) =>
|
||||
attached.includes(listener)
|
||||
);
|
||||
for (const listener of held) this.removeListener(eventName, listener);
|
||||
try {
|
||||
return originalEmit.call(this, eventName, ...args);
|
||||
} finally {
|
||||
// Raw listeners keep their `once` wrappers, so a re-added once
|
||||
// listener still fires once for the real document.
|
||||
for (const listener of held)
|
||||
this.prependListener(eventName, listener);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function tapIpcHandlers(ipcMain, channels) {
|
||||
const handlers = new Map();
|
||||
const originalHandle = ipcMain.handle;
|
||||
@@ -78,6 +115,7 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) {
|
||||
errors: [],
|
||||
gatedEpochMs: null,
|
||||
gatedMethod: null,
|
||||
didFinishLoadHeldOnBlank: 0,
|
||||
passThroughLoads: 0,
|
||||
readyToShowHeldOnBlank: 0,
|
||||
releasedEpochMs: null,
|
||||
@@ -128,6 +166,7 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) {
|
||||
state.gatedEpochMs = now();
|
||||
state.gatedMethod = method;
|
||||
holdReadyToShowWhileBlank(this, state);
|
||||
holdDidFinishLoadWhileBlank(this, state);
|
||||
try {
|
||||
await this.webContents.loadURL(BLANK_URL);
|
||||
state.blankLoadedEpochMs = now();
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from 'node:test';
|
||||
|
||||
interface GateState {
|
||||
blankLoadedEpochMs: number | null;
|
||||
didFinishLoadHeldOnBlank: number;
|
||||
errors: string[];
|
||||
gatedEpochMs: number | null;
|
||||
gatedMethod: string | null;
|
||||
@@ -155,13 +156,13 @@ test('records a failed about:blank navigation and still loads after release', as
|
||||
function createEmittingBrowserWindow(log: string[]) {
|
||||
class EmittingBrowserWindow extends EventEmitter {
|
||||
url = '';
|
||||
webContents = {
|
||||
webContents = Object.assign(new EventEmitter(), {
|
||||
getURL: () => this.url,
|
||||
loadURL: async (url: string) => {
|
||||
this.url = url;
|
||||
log.push(`webContents.loadURL:${url}`);
|
||||
},
|
||||
};
|
||||
});
|
||||
async loadFile(file: string): Promise<void> {
|
||||
this.url = `file:///${file}`;
|
||||
log.push(`loadFile:${file}`);
|
||||
@@ -201,6 +202,47 @@ test('holds ready-to-show while the window shows about:blank, then lets the real
|
||||
assert.equal(api.state.readyToShowHeldOnBlank, 1);
|
||||
});
|
||||
|
||||
test('keeps did-finish-load of about:blank from the app listeners, not from later ones', async () => {
|
||||
const log: string[] = [];
|
||||
const EmittingBrowserWindow = createEmittingBrowserWindow(log);
|
||||
const api = gateModule.installJourneyRendererGate(
|
||||
EmittingBrowserWindow as unknown as {
|
||||
prototype: Record<string, unknown>;
|
||||
},
|
||||
{},
|
||||
{ timeoutMs: 60_000 }
|
||||
);
|
||||
const window = new EmittingBrowserWindow();
|
||||
// The app shows its window at the first did-finish-load.
|
||||
window.webContents.once('did-finish-load', () =>
|
||||
log.push('app:did-finish-load')
|
||||
);
|
||||
const load = window.loadFile('index.html');
|
||||
await settle();
|
||||
// Registered after the gated load, like Electron's own listener that
|
||||
// resolves loadURL(about:blank).
|
||||
window.webContents.on('did-finish-load', () =>
|
||||
log.push('electron:did-finish-load')
|
||||
);
|
||||
window.webContents.emit('did-finish-load');
|
||||
assert.equal(api.state.didFinishLoadHeldOnBlank, 1);
|
||||
|
||||
api.release();
|
||||
await load;
|
||||
window.webContents.emit('did-finish-load');
|
||||
window.webContents.emit('did-finish-load');
|
||||
|
||||
assert.deepEqual(log, [
|
||||
'webContents.loadURL:about:blank',
|
||||
'electron:did-finish-load',
|
||||
'loadFile:index.html',
|
||||
'app:did-finish-load',
|
||||
'electron:did-finish-load',
|
||||
'electron:did-finish-load',
|
||||
]);
|
||||
assert.equal(api.state.didFinishLoadHeldOnBlank, 1);
|
||||
});
|
||||
|
||||
test('taps ipcMain.handle for the counters channel and passes registrations through', async () => {
|
||||
const registered: string[] = [];
|
||||
const ipcMain: FakeIpcMain = {
|
||||
|
||||
@@ -112,6 +112,7 @@ function measurement(
|
||||
blankLoadedEpochMs: 1_050,
|
||||
errors: [],
|
||||
gatedEpochMs: 1_020,
|
||||
didFinishLoadHeldOnBlank: 1,
|
||||
gatedMethod: 'loadFile',
|
||||
passThroughLoads: 0,
|
||||
readyToShowHeldOnBlank: 1,
|
||||
@@ -185,6 +186,7 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn-
|
||||
'main.startupPhases': 9,
|
||||
});
|
||||
assert.equal(record.evidence['rendererGateReadyToShowHeldOnBlank'], 1);
|
||||
assert.equal(record.evidence['rendererGateDidFinishLoadHeldOnBlank'], 1);
|
||||
assert.deepEqual(record.evidence['epochs'], {
|
||||
firstCard: 2_600.04,
|
||||
firstCardPaint: 2_650,
|
||||
|
||||
@@ -185,6 +185,8 @@ export function toLaunchIterationRecord(
|
||||
mainCountersAtRead: mainCounters.counters,
|
||||
rendererGateReadyToShowHeldOnBlank:
|
||||
measurement.gate.readyToShowHeldOnBlank,
|
||||
rendererGateDidFinishLoadHeldOnBlank:
|
||||
measurement.gate.didFinishLoadHeldOnBlank,
|
||||
ipcCallsByMethod: ipc.callsByMethod,
|
||||
ipcSerialDepth: serialDepth,
|
||||
ipcTimelineAmbiguousCompletions: ipc.ambiguousTimelineCompletions,
|
||||
|
||||
@@ -27,8 +27,9 @@ const setWindowState = (
|
||||
);
|
||||
|
||||
/**
|
||||
* The app creates its window with `show: false` and shows it on
|
||||
* `ready-to-show`; a `hide()` sent earlier would be undone by that `show()`.
|
||||
* The app creates its window with `show: false` and shows it at
|
||||
* `ready-to-show` or `did-finish-load`, whichever comes first; a `hide()`
|
||||
* sent earlier would be undone by that `show()`.
|
||||
*/
|
||||
async function waitUntilShown(app: UnautomatedElectronApp): Promise<void> {
|
||||
await expect
|
||||
|
||||
Reference in new issue
Block a user