From 1219649e27f81b022509be0ccd008b15a3794f21 Mon Sep 17 00:00:00 2001 From: Milan Burda Date: Tue, 11 Apr 2023 14:45:28 +0200 Subject: [PATCH] feat: add `will-frame-navigate` event (#34418) --- docs/api/structures/event.md | 3 - docs/api/web-contents.md | 67 ++- docs/api/webview-tag.md | 22 + filenames.auto.gni | 1 - filenames.gni | 2 + lib/browser/guest-view-manager.ts | 10 + package.json | 4 +- .../browser/api/electron_api_web_contents.cc | 68 +++- shell/browser/api/electron_api_web_contents.h | 2 + shell/browser/electron_navigation_throttle.cc | 4 + shell/common/gin_helper/event_new.cc | 31 ++ shell/common/gin_helper/event_new.h | 48 +++ spec/api-browser-window-spec.ts | 384 +++++++++++++++++- .../pages/webview-will-navigate-in-frame.html | 12 + spec/ts-smoke/electron/main.ts | 1 - spec/webview-spec.ts | 43 +- yarn.lock | 16 +- 17 files changed, 683 insertions(+), 35 deletions(-) delete mode 100644 docs/api/structures/event.md create mode 100644 shell/common/gin_helper/event_new.cc create mode 100644 shell/common/gin_helper/event_new.h create mode 100644 spec/fixtures/pages/webview-will-navigate-in-frame.html diff --git a/docs/api/structures/event.md b/docs/api/structures/event.md deleted file mode 100644 index 415d269feec98..0000000000000 --- a/docs/api/structures/event.md +++ /dev/null @@ -1,3 +0,0 @@ -# Event Object extends `GlobalEvent` - -* `preventDefault` VoidFunction diff --git a/docs/api/web-contents.md b/docs/api/web-contents.md index b05b8bfe217c8..928ed7f98bb28 100644 --- a/docs/api/web-contents.md +++ b/docs/api/web-contents.md @@ -19,6 +19,36 @@ const contents = win.webContents console.log(contents) ``` +## Navigation Events + +Several events can be used to monitor navigations as they occur within a `webContents`. + +### Document Navigations + +When a `webContents` navigates to another page (as opposed to an [in-page navigation](web-contents.md#in-page-navigation)), the following events will be fired. + +* [`did-start-navigation`](web-contents.md#event-did-start-navigation) +* [`will-frame-navigate`](web-contents.md#event-will-frame-navigate) +* [`will-navigate`](web-contents.md#event-will-navigate) (only fired when main frame navigates) +* [`will-redirect`](web-contents.md#event-will-redirect) (only fired when a redirect happens during navigation) +* [`did-redirect-navigation`](web-contents.md#event-did-redirect-navigation) (only fired when a redirect happens during navigation) +* [`did-frame-navigate`](web-contents.md#event-did-frame-navigate) +* [`did-navigate`](web-contents.md#event-did-navigate) (only fired when main frame navigates) + +Subsequent events will not fire if `event.preventDefault()` is called on any of the cancellable events. + +### In-page Navigation + +In-page navigations don't cause the page to reload, but instead navigate to a location within the current page. These events are not cancellable. For an in-page navigations, the following events will fire in this order: + +* [`did-start-navigation`](web-contents.md#event-did-start-navigation) +* [`did-navigate-in-page`](web-contents.md#event-did-navigate-in-page) + +### Frame Navigation + +The [`will-navigate`](web-contents.md#event-will-navigate) and [`did-navigate`](web-contents.md#event-did-navigate) events only fire when the [mainFrame](web-contents.md#contentsmainframe-readonly) navigates. +If you want to also observe navigations in ``); + }); + + it('is triggered when navigating from file: to http:', async () => { + await w.loadFile(path.join(fixtures, 'api', 'blank.html')); + w.webContents.executeJavaScript(`location.href = ${JSON.stringify(url)}`); + const navigatedTo = await new Promise(resolve => { + w.webContents.once('will-frame-navigate', (e) => { + e.preventDefault(); + resolve(e.url); + }); + }); + expect(navigatedTo).to.equal(url); + expect(w.webContents.getURL()).to.match(/^file:/); + }); + + it('is triggered when navigating from about:blank to http:', async () => { + await w.loadURL('about:blank'); + w.webContents.executeJavaScript(`location.href = ${JSON.stringify(url)}`); + const navigatedTo = await new Promise(resolve => { + w.webContents.once('will-frame-navigate', (e) => { + e.preventDefault(); + resolve(e.url); + }); + }); + expect(navigatedTo).to.equal(url); + expect(w.webContents.getURL()).to.equal('about:blank'); + }); + + it('is triggered when a cross-origin iframe navigates _top', async () => { + await w.loadURL(`data:text/html,`); + await setTimeout(1000); + + let willFrameNavigateEmitted = false; + let isMainFrameValue; + w.webContents.on('will-frame-navigate', (event) => { + willFrameNavigateEmitted = true; + isMainFrameValue = event.isMainFrame; + }); + const didNavigatePromise = once(w.webContents, 'did-navigate'); + + w.webContents.debugger.attach('1.1'); + const targets = await w.webContents.debugger.sendCommand('Target.getTargets'); + const iframeTarget = targets.targetInfos.find((t: any) => t.type === 'iframe'); + const { sessionId } = await w.webContents.debugger.sendCommand('Target.attachToTarget', { + targetId: iframeTarget.targetId, + flatten: true + }); + await w.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: 10, + y: 10, + clickCount: 1, + button: 'left' + }, sessionId); + await w.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: 10, + y: 10, + clickCount: 1, + button: 'left' + }, sessionId); + + await didNavigatePromise; + + expect(willFrameNavigateEmitted).to.be.true(); + expect(isMainFrameValue).to.be.true(); + }); + + it('is triggered when a cross-origin iframe navigates itself', async () => { + await w.loadURL(`data:text/html,`); + await setTimeout(1000); + + let willNavigateEmitted = false; + let isMainFrameValue; + w.webContents.on('will-frame-navigate', (event) => { + willNavigateEmitted = true; + isMainFrameValue = event.isMainFrame; + }); + const didNavigatePromise = once(w.webContents, 'did-frame-navigate'); + + w.webContents.debugger.attach('1.1'); + const targets = await w.webContents.debugger.sendCommand('Target.getTargets'); + const iframeTarget = targets.targetInfos.find((t: any) => t.type === 'iframe'); + const { sessionId } = await w.webContents.debugger.sendCommand('Target.attachToTarget', { + targetId: iframeTarget.targetId, + flatten: true + }); + await w.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: 10, + y: 10, + clickCount: 1, + button: 'left' + }, sessionId); + await w.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: 10, + y: 10, + clickCount: 1, + button: 'left' + }, sessionId); + + await didNavigatePromise; + + expect(willNavigateEmitted).to.be.true(); + expect(isMainFrameValue).to.be.false(); + }); + + it('can cancel when a cross-origin iframe navigates itself', async () => { + + }); + }); + describe('will-redirect event', () => { let server = null as unknown as http.Server; let url = null as unknown as string; @@ -678,6 +879,179 @@ describe('BrowserWindow module', () => { w.loadURL(`${url}/navigate-302`); }); }); + + describe('ordering', () => { + let server = null as unknown as http.Server; + let url = null as unknown as string; + const navigationEvents = [ + 'did-start-navigation', + 'did-navigate-in-page', + 'will-frame-navigate', + 'will-navigate', + 'will-redirect', + 'did-redirect-navigation', + 'did-frame-navigate', + 'did-navigate' + ]; + before((done) => { + server = http.createServer((req, res) => { + if (req.url === '/navigate') { + res.end('navigate'); + } else if (req.url === '/redirect') { + res.end('redirect'); + } else if (req.url === '/redirect2') { + res.statusCode = 302; + res.setHeader('location', url); + res.end(); + } else if (req.url === '/in-page') { + res.end('redirect
'); + } else { + res.end(''); + } + }); + server.listen(0, '127.0.0.1', () => { + url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/`; + done(); + }); + }); + it('for initial navigation, event order is consistent', async () => { + const firedEvents: string[] = []; + const expectedEventOrder = [ + 'did-start-navigation', + 'did-frame-navigate', + 'did-navigate' + ]; + const allEvents = Promise.all(navigationEvents.map(event => + once(w.webContents, event).then(() => firedEvents.push(event)) + )); + const timeout = setTimeout(1000); + w.loadURL(url); + await Promise.race([allEvents, timeout]); + expect(firedEvents).to.deep.equal(expectedEventOrder); + }); + + it('for second navigation, event order is consistent', async () => { + const firedEvents: string[] = []; + const expectedEventOrder = [ + 'did-start-navigation', + 'will-frame-navigate', + 'will-navigate', + 'did-frame-navigate', + 'did-navigate' + ]; + w.loadURL(`${url}navigate`); + await once(w.webContents, 'did-navigate'); + await setTimeout(1000); + navigationEvents.forEach(event => + once(w.webContents, event).then(() => firedEvents.push(event)) + ); + const navigationFinished = once(w.webContents, 'did-navigate'); + w.webContents.debugger.attach('1.1'); + const targets = await w.webContents.debugger.sendCommand('Target.getTargets'); + const pageTarget = targets.targetInfos.find((t: any) => t.type === 'page'); + const { sessionId } = await w.webContents.debugger.sendCommand('Target.attachToTarget', { + targetId: pageTarget.targetId, + flatten: true + }); + await w.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: 10, + y: 10, + clickCount: 1, + button: 'left' + }, sessionId); + await w.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: 10, + y: 10, + clickCount: 1, + button: 'left' + }, sessionId); + await navigationFinished; + expect(firedEvents).to.deep.equal(expectedEventOrder); + }); + + it('when navigating with redirection, event order is consistent', async () => { + const firedEvents: string[] = []; + const expectedEventOrder = [ + 'did-start-navigation', + 'will-frame-navigate', + 'will-navigate', + 'will-redirect', + 'did-redirect-navigation', + 'did-frame-navigate', + 'did-navigate' + ]; + w.loadURL(`${url}redirect`); + await once(w.webContents, 'did-navigate'); + await setTimeout(1000); + navigationEvents.forEach(event => + once(w.webContents, event).then(() => firedEvents.push(event)) + ); + const navigationFinished = once(w.webContents, 'did-navigate'); + w.webContents.debugger.attach('1.1'); + const targets = await w.webContents.debugger.sendCommand('Target.getTargets'); + const pageTarget = targets.targetInfos.find((t: any) => t.type === 'page'); + const { sessionId } = await w.webContents.debugger.sendCommand('Target.attachToTarget', { + targetId: pageTarget.targetId, + flatten: true + }); + await w.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: 10, + y: 10, + clickCount: 1, + button: 'left' + }, sessionId); + await w.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: 10, + y: 10, + clickCount: 1, + button: 'left' + }, sessionId); + await navigationFinished; + expect(firedEvents).to.deep.equal(expectedEventOrder); + }); + + it('when navigating in-page, event order is consistent', async () => { + const firedEvents: string[] = []; + const expectedEventOrder = [ + 'did-start-navigation', + 'did-navigate-in-page' + ]; + w.loadURL(`${url}in-page`); + await once(w.webContents, 'did-navigate'); + await setTimeout(1000); + navigationEvents.forEach(event => + once(w.webContents, event).then(() => firedEvents.push(event)) + ); + const navigationFinished = once(w.webContents, 'did-navigate-in-page'); + w.webContents.debugger.attach('1.1'); + const targets = await w.webContents.debugger.sendCommand('Target.getTargets'); + const pageTarget = targets.targetInfos.find((t: any) => t.type === 'page'); + const { sessionId } = await w.webContents.debugger.sendCommand('Target.attachToTarget', { + targetId: pageTarget.targetId, + flatten: true + }); + await w.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: 10, + y: 10, + clickCount: 1, + button: 'left' + }, sessionId); + await w.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: 10, + y: 10, + clickCount: 1, + button: 'left' + }, sessionId); + await navigationFinished; + expect(firedEvents).to.deep.equal(expectedEventOrder); + }); + }); }); } @@ -3801,7 +4175,7 @@ describe('BrowserWindow module', () => { // Pending endFrameSubscription to next tick can reliably reproduce // a crash which happens when nothing is returned in the callback. - setTimeout(() => { + setTimeout().then(() => { w.webContents.endFrameSubscription(); done(); }); @@ -4241,7 +4615,7 @@ describe('BrowserWindow module', () => { const twoShown = emittedOnce(two, 'show'); two.show(); await twoShown; - setTimeout(() => two.close(), 500); + setTimeout(500).then(() => two.close()); await emittedOnce(two, 'closed'); }; @@ -4257,7 +4631,7 @@ describe('BrowserWindow module', () => { const oneShown = emittedOnce(one, 'show'); one.show(); await oneShown; - setTimeout(() => one.destroy(), 500); + setTimeout(500).then(() => one.destroy()); await emittedOnce(one, 'closed'); await createTwo(); diff --git a/spec/fixtures/pages/webview-will-navigate-in-frame.html b/spec/fixtures/pages/webview-will-navigate-in-frame.html new file mode 100644 index 0000000000000..63f921e724278 --- /dev/null +++ b/spec/fixtures/pages/webview-will-navigate-in-frame.html @@ -0,0 +1,12 @@ + + + + + diff --git a/spec/ts-smoke/electron/main.ts b/spec/ts-smoke/electron/main.ts index 6cd4db3b71efd..014e3b236318b 100644 --- a/spec/ts-smoke/electron/main.ts +++ b/spec/ts-smoke/electron/main.ts @@ -23,7 +23,6 @@ import { session, systemPreferences, webContents, - Event, TouchBar } from 'electron' diff --git a/spec/webview-spec.ts b/spec/webview-spec.ts index 2a30247057dad..10a868e950ef4 100644 --- a/spec/webview-spec.ts +++ b/spec/webview-spec.ts @@ -1481,7 +1481,7 @@ describe(' tag', function () { }); describe('will-navigate event', () => { - it('emits when a url that leads to outside of the page is clicked', async () => { + it('emits when a url that leads to outside of the page is loaded', async () => { const { url } = await loadWebViewAndWaitForEvent(w, { src: `file://${fixtures}/pages/webview-will-navigate.html` }, 'will-navigate'); @@ -1490,6 +1490,47 @@ describe(' tag', function () { }); }); + describe('will-frame-navigate event', () => { + it('emits when a link that leads to outside of the page is loaded', async () => { + const { url, isMainFrame } = await loadWebViewAndWaitForEvent(w, { + src: `file://${fixtures}/pages/webview-will-navigate.html` + }, 'will-frame-navigate'); + expect(url).to.equal('http://host/'); + expect(isMainFrame).to.be.true(); + }); + + it('emits when a link within an iframe, which leads to outside of the page, is loaded', async () => { + await loadWebView(w, { + src: `file://${fixtures}/pages/webview-will-navigate-in-frame.html`, + nodeIntegration: '' + }); + + const { url, frameProcessId, frameRoutingId } = await w.executeJavaScript(` + new Promise((resolve, reject) => { + let hasFrameNavigatedOnce = false; + const webview = document.getElementById('webview'); + webview.addEventListener('will-frame-navigate', ({url, isMainFrame, frameProcessId, frameRoutingId}) => { + if (isMainFrame) return; + if (hasFrameNavigatedOnce) resolve({ + url, + isMainFrame, + frameProcessId, + frameRoutingId, + }); + + // First navigation is the initial iframe load within the + hasFrameNavigatedOnce = true; + }); + webview.executeJavaScript('loadSubframe()'); + }); + `); + + expect(url).to.equal('http://host/'); + expect(frameProcessId).to.be.a('number'); + expect(frameRoutingId).to.be.a('number'); + }); + }); + describe('did-navigate event', () => { it('emits when a url that leads to outside of the page is clicked', async () => { const pageUrl = url.pathToFileURL(path.join(fixtures, 'pages', 'webview-will-navigate.html')).toString(); diff --git a/yarn.lock b/yarn.lock index af341ef1d17a5..8010439570461 100644 --- a/yarn.lock +++ b/yarn.lock @@ -123,10 +123,10 @@ optionalDependencies: "@types/glob" "^7.1.1" -"@electron/docs-parser@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@electron/docs-parser/-/docs-parser-1.0.0.tgz#1844ed2e18528ea56aaef0ace1cfa0633a6fa1b1" - integrity sha512-nIqEO8Ga6LavdaY2aJMPfq2vSOPVlgOvNv7jpiyaoqsAz5vYnWNUnxeCyaalCaDyFiKhVeHbKwP8Kt2TENwneg== +"@electron/docs-parser@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@electron/docs-parser/-/docs-parser-1.1.0.tgz#ba095def41746bde56bee731feaf22272bf0b765" + integrity sha512-qrjIKJk8t4/xAYldDVNQgcF8zdAAuG260bzPxdh/xI3p/yddm61bftoct+Tx2crnWFnOfOkr6nGERsDknNiT8A== dependencies: "@types/markdown-it" "^10.0.0" chai "^4.2.0" @@ -176,10 +176,10 @@ "@octokit/auth-app" "^3.6.1" "@octokit/rest" "^18.12.0" -"@electron/typescript-definitions@^8.10.0": - version "8.10.0" - resolved "https://registry.yarnpkg.com/@electron/typescript-definitions/-/typescript-definitions-8.10.0.tgz#e9cf2b329ec4b0b76947ef751725383a6cf8994d" - integrity sha512-FVc2y0GUfxFZDoma0scYiMxkoalle19Fq332fNFGWoCJ9rCj5OUvriewSjPtGBsRuHv2xaMS5MhBuy2/pRuFuQ== +"@electron/typescript-definitions@^8.14.0": + version "8.14.0" + resolved "https://registry.yarnpkg.com/@electron/typescript-definitions/-/typescript-definitions-8.14.0.tgz#a88f74e915317ba943b57ffe499b319d04f01ee3" + integrity sha512-J3b4is6L0NB4+r+7s1Hl1YlzaveKnQt1gswadRyMRwb4gFU3VAe2oBMJLOhFRJMs/9PK/Xp+y9QwyC92Tyqe6A== dependencies: "@types/node" "^11.13.7" chalk "^2.4.2"