diff --git a/docs/api/navigation-history.md b/docs/api/navigation-history.md index 16d53ce37b11..372852a1caa8 100644 --- a/docs/api/navigation-history.md +++ b/docs/api/navigation-history.md @@ -74,3 +74,22 @@ Returns `boolean` - Whether the navigation entry was removed from the webContent #### `navigationHistory.getAllEntries()` Returns [`NavigationEntry[]`](structures/navigation-entry.md) - WebContents complete history. + +#### `navigationHistory.restore(options)` + +Restores navigation history and loads the given entry in the in stack. Will make a best effort +to restore not just the navigation stack but also the state of the individual pages - for instance +including HTML form values or the scroll position. It's recommended to call this API before any +navigation entries are created, so ideally before you call `loadURL()` or `loadFile()` on the +`webContents` object. + +This API allows you to create common flows that aim to restore, recreate, or clone other webContents. + +* `options` Object + * `entries` [NavigationEntry[]](structures/navigation-entry.md) - Result of a prior `getAllEntries()` call + * `index` Integer (optional) - Index of the stack that should be loaded. If you set it to `0`, the webContents will load the first (oldest) entry. If you leave it undefined, Electron will automatically load the last (newest) entry. + +Returns `Promise` - the promise will resolve when the page has finished loading the selected navigation entry +(see [`did-finish-load`](web-contents.md#event-did-finish-load)), and rejects +if the page fails to load (see +[`did-fail-load`](web-contents.md#event-did-fail-load)). A noop rejection handler is already attached, which avoids unhandled rejection errors. diff --git a/docs/api/structures/navigation-entry.md b/docs/api/structures/navigation-entry.md index 6d1cd6734d13..72afc3d30d41 100644 --- a/docs/api/structures/navigation-entry.md +++ b/docs/api/structures/navigation-entry.md @@ -2,3 +2,6 @@ * `url` string * `title` string +* `pageState` string (optional) - A base64 encoded data string containing Chromium page state + including information like the current scroll position or form values. It is committed by + Chromium before a navigation event and on a regular interval. diff --git a/docs/tutorial/navigation-history.md b/docs/tutorial/navigation-history.md index 1c8f32cb7582..807d3b676b02 100644 --- a/docs/tutorial/navigation-history.md +++ b/docs/tutorial/navigation-history.md @@ -69,8 +69,25 @@ if (navigationHistory.canGoToOffset(2)) { } ``` +## Restoring history + +A common flow is that you want to restore the history of a webContents - for instance to implement an "undo close tab" feature. To do so, you can call `navigationHistory.restore({ index, entries })`. This will restore the webContent's navigation history and the webContents location in said history, meaning that `goBack()` and `goForward()` navigate you through the stack as expected. + +```js @ts-type={navigationHistory:Electron.NavigationHistory} + +const firstWindow = new BrowserWindow() + +// Later, you want a second window to have the same history and navigation position +async function restore () { + const entries = firstWindow.webContents.navigationHistory.getAllEntries() + const index = firstWindow.webContents.navigationHistory.getActiveIndex() + + const secondWindow = new BrowserWindow() + await secondWindow.webContents.navigationHistory.restore({ index, entries }) +} +``` + Here's a full example that you can open with Electron Fiddle: ```fiddle docs/fiddles/features/navigation-history - ``` diff --git a/lib/browser/api/web-contents.ts b/lib/browser/api/web-contents.ts index 2e37f439175d..caa9d1873289 100644 --- a/lib/browser/api/web-contents.ts +++ b/lib/browser/api/web-contents.ts @@ -8,7 +8,7 @@ import * as deprecate from '@electron/internal/common/deprecate'; import { IPC_MESSAGES } from '@electron/internal/common/ipc-messages'; import { app, ipcMain, session, webFrameMain, dialog } from 'electron/main'; -import type { BrowserWindowConstructorOptions, MessageBoxOptions } from 'electron/main'; +import type { BrowserWindowConstructorOptions, MessageBoxOptions, NavigationEntry } from 'electron/main'; import * as path from 'path'; import * as url from 'url'; @@ -343,8 +343,8 @@ WebContents.prototype.loadFile = function (filePath, options = {}) { type LoadError = { errorCode: number, errorDescription: string, url: string }; -WebContents.prototype.loadURL = function (url, options) { - const p = new Promise((resolve, reject) => { +function _awaitNextLoad (this: Electron.WebContents, navigationUrl: string) { + return new Promise((resolve, reject) => { const resolveAndCleanup = () => { removeListeners(); resolve(); @@ -402,7 +402,7 @@ WebContents.prototype.loadURL = function (url, options) { // the only one is with a bad scheme, perhaps ERR_INVALID_ARGUMENT // would be more appropriate. if (!error) { - error = { errorCode: -2, errorDescription: 'ERR_FAILED', url }; + error = { errorCode: -2, errorDescription: 'ERR_FAILED', url: navigationUrl }; } finishListener(); }; @@ -426,6 +426,10 @@ WebContents.prototype.loadURL = function (url, options) { this.on('did-stop-loading', stopLoadingListener); this.on('destroyed', stopLoadingListener); }); +}; + +WebContents.prototype.loadURL = function (url, options) { + const p = _awaitNextLoad.call(this, url); // Add a no-op rejection handler to silence the unhandled rejection error. p.catch(() => {}); this._loadURL(url, options ?? {}); @@ -611,7 +615,27 @@ WebContents.prototype._init = function () { length: this._historyLength.bind(this), getEntryAtIndex: this._getNavigationEntryAtIndex.bind(this), removeEntryAtIndex: this._removeNavigationEntryAtIndex.bind(this), - getAllEntries: this._getHistory.bind(this) + getAllEntries: this._getHistory.bind(this), + restore: ({ index, entries }: { index?: number, entries: NavigationEntry[] }) => { + if (index === undefined) { + index = entries.length - 1; + } + + if (index < 0 || !entries[index]) { + throw new Error('Invalid index. Index must be a positive integer and within the bounds of the entries length.'); + } + + const p = _awaitNextLoad.call(this, entries[index].url); + p.catch(() => {}); + + try { + this._restoreHistory(index, entries); + } catch (error) { + return Promise.reject(error); + } + + return p; + } }, writable: false, enumerable: true diff --git a/shell/browser/api/electron_api_web_contents.cc b/shell/browser/api/electron_api_web_contents.cc index fc415e75f5b4..f0cc5b03a88f 100644 --- a/shell/browser/api/electron_api_web_contents.cc +++ b/shell/browser/api/electron_api_web_contents.cc @@ -54,6 +54,7 @@ #include "content/public/browser/keyboard_event_processing_result.h" #include "content/public/browser/navigation_details.h" #include "content/public/browser/navigation_entry.h" +#include "content/public/browser/navigation_entry_restore_context.h" #include "content/public/browser/navigation_handle.h" #include "content/public/browser/render_frame_host.h" #include "content/public/browser/render_process_host.h" @@ -363,14 +364,60 @@ struct Converter> { template <> struct Converter { + static bool FromV8(v8::Isolate* isolate, + v8::Local val, + content::NavigationEntry** out) { + gin_helper::Dictionary dict; + if (!gin::ConvertFromV8(isolate, val, &dict)) + return false; + + std::string url_str; + std::string title; + std::string encoded_page_state; + GURL url; + + if (!dict.Get("url", &url) || !dict.Get("title", &title)) + return false; + + auto entry = content::NavigationEntry::Create(); + entry->SetURL(url); + entry->SetTitle(base::UTF8ToUTF16(title)); + + // Handle optional page state + if (dict.Get("pageState", &encoded_page_state)) { + std::string decoded_page_state; + if (base::Base64Decode(encoded_page_state, &decoded_page_state)) { + auto restore_context = content::NavigationEntryRestoreContext::Create(); + + auto page_state = + blink::PageState::CreateFromEncodedData(decoded_page_state); + if (!page_state.IsValid()) + return false; + + entry->SetPageState(std::move(page_state), restore_context.get()); + } + } + + *out = entry.release(); + return true; + } + static v8::Local ToV8(v8::Isolate* isolate, content::NavigationEntry* entry) { if (!entry) { return v8::Null(isolate); } - gin_helper::Dictionary dict(isolate, v8::Object::New(isolate)); + gin_helper::Dictionary dict = gin_helper::Dictionary::CreateEmpty(isolate); dict.Set("url", entry->GetURL().spec()); dict.Set("title", entry->GetTitleForDisplay()); + + // Page state saves scroll position and values of any form fields + const blink::PageState& page_state = entry->GetPageState(); + if (page_state.IsValid()) { + std::string encoded_data = base::Base64Encode(page_state.ToEncodedData()); + dict.Set("pageState", encoded_data); + } + return dict.GetHandle(); } }; @@ -2572,6 +2619,47 @@ std::vector WebContents::GetHistory() const { return history; } +void WebContents::RestoreHistory( + v8::Isolate* isolate, + gin_helper::ErrorThrower thrower, + int index, + const std::vector>& entries) { + if (!web_contents() + ->GetController() + .GetLastCommittedEntry() + ->IsInitialEntry()) { + thrower.ThrowError( + "Cannot restore history on webContents that have previously loaded " + "a page."); + return; + } + + auto navigation_entries = std::make_unique< + std::vector>>(); + + for (const auto& entry : entries) { + content::NavigationEntry* nav_entry = nullptr; + if (!gin::Converter::FromV8(isolate, entry, + &nav_entry) || + !nav_entry) { + // Invalid entry, bail out early + thrower.ThrowError( + "Failed to restore navigation history: Invalid navigation entry at " + "index " + + std::to_string(index) + "."); + return; + } + navigation_entries->push_back( + std::unique_ptr(nav_entry)); + } + + if (!navigation_entries->empty()) { + web_contents()->GetController().Restore( + index, content::RestoreType::kRestored, navigation_entries.get()); + web_contents()->GetController().LoadIfNecessary(); + } +} + void WebContents::ClearHistory() { // In some rare cases (normally while there is no real history) we are in a // state where we can't prune navigation entries @@ -4397,6 +4485,7 @@ void WebContents::FillObjectTemplate(v8::Isolate* isolate, &WebContents::RemoveNavigationEntryAtIndex) .SetMethod("_getHistory", &WebContents::GetHistory) .SetMethod("_clearHistory", &WebContents::ClearHistory) + .SetMethod("_restoreHistory", &WebContents::RestoreHistory) .SetMethod("isCrashed", &WebContents::IsCrashed) .SetMethod("forcefullyCrashRenderer", &WebContents::ForcefullyCrashRenderer) diff --git a/shell/browser/api/electron_api_web_contents.h b/shell/browser/api/electron_api_web_contents.h index 27e0757bad0a..057d6e01201e 100644 --- a/shell/browser/api/electron_api_web_contents.h +++ b/shell/browser/api/electron_api_web_contents.h @@ -219,6 +219,10 @@ class WebContents final : public ExclusiveAccessContext, bool RemoveNavigationEntryAtIndex(int index); std::vector GetHistory() const; void ClearHistory(); + void RestoreHistory(v8::Isolate* isolate, + gin_helper::ErrorThrower thrower, + int index, + const std::vector>& entries); int GetHistoryLength() const; const std::string GetWebRTCIPHandlingPolicy() const; void SetWebRTCIPHandlingPolicy(const std::string& webrtc_ip_handling_policy); diff --git a/spec/api-web-contents-spec.ts b/spec/api-web-contents-spec.ts index 16f5b8e88a52..ed6fd5ef73c2 100644 --- a/spec/api-web-contents-spec.ts +++ b/spec/api-web-contents-spec.ts @@ -699,12 +699,14 @@ describe('webContents module', () => { describe('navigationHistory.getEntryAtIndex(index) API ', () => { it('should fetch default navigation entry when no urls are loaded', async () => { const result = w.webContents.navigationHistory.getEntryAtIndex(0); - expect(result).to.deep.equal({ url: '', title: '' }); + expect(result.url).to.equal(''); + expect(result.title).to.equal(''); }); it('should fetch navigation entry given a valid index', async () => { await w.loadURL(urlPage1); const result = w.webContents.navigationHistory.getEntryAtIndex(0); - expect(result).to.deep.equal({ url: urlPage1, title: 'Page 1' }); + expect(result.url).to.equal(urlPage1); + expect(result.title).to.equal('Page 1'); }); it('should return null given an invalid index larger than history length', async () => { await w.loadURL(urlPage1); @@ -763,7 +765,10 @@ describe('webContents module', () => { await w.loadURL(urlPage1); await w.loadURL(urlPage2); await w.loadURL(urlPage3); - const entries = w.webContents.navigationHistory.getAllEntries(); + const entries = w.webContents.navigationHistory.getAllEntries().map(entry => ({ + url: entry.url, + title: entry.title + })); expect(entries.length).to.equal(3); expect(entries[0]).to.deep.equal({ url: urlPage1, title: 'Page 1' }); expect(entries[1]).to.deep.equal({ url: urlPage2, title: 'Page 2' }); @@ -774,6 +779,92 @@ describe('webContents module', () => { const entries = w.webContents.navigationHistory.getAllEntries(); expect(entries.length).to.equal(0); }); + + it('should create a NavigationEntry with PageState that can be serialized/deserialized with JSON', async () => { + await w.loadURL(urlPage1); + await w.loadURL(urlPage2); + await w.loadURL(urlPage3); + + const entries = w.webContents.navigationHistory.getAllEntries(); + const serialized = JSON.stringify(entries); + const deserialized = JSON.parse(serialized); + expect(deserialized).to.deep.equal(entries); + }); + }); + + describe('navigationHistory.restore({ index, entries }) API', () => { + let server: http.Server; + let serverUrl: string; + + before(async () => { + server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.end('Form
'); + }); + serverUrl = (await listen(server)).url; + }); + + after(async () => { + if (server) await new Promise(resolve => server.close(resolve)); + server = null as any; + }); + + it('should restore navigation history with PageState', async () => { + await w.loadURL(urlPage1); + await w.loadURL(urlPage2); + await w.loadURL(serverUrl); + + // Fill out the form on the page + await w.webContents.executeJavaScript('document.querySelector("input").value = "Hi!";'); + + // PageState is committed: + // 1) When the page receives an unload event + // 2) During periodic serialization of page state + // To not wait randomly for the second option, we'll trigger another load + await w.loadURL(urlPage3); + + // Save the navigation state + const entries = w.webContents.navigationHistory.getAllEntries(); + + // Close the window, make a new one + w.close(); + w = new BrowserWindow(); + + const formValue = await new Promise(resolve => { + w.webContents.once('dom-ready', () => resolve(w.webContents.executeJavaScript('document.querySelector("input").value'))); + + // Restore the navigation history + return w.webContents.navigationHistory.restore({ index: 2, entries }); + }); + + expect(formValue).to.equal('Hi!'); + }); + + it('should handle invalid base64 pageState', async () => { + await w.loadURL(urlPage1); + await w.loadURL(urlPage2); + await w.loadURL(urlPage3); + + const brokenEntries = w.webContents.navigationHistory.getAllEntries().map(entry => ({ + ...entry, + pageState: 'invalid base64' + })); + + // Close the window, make a new one + w.close(); + w = new BrowserWindow(); + await w.webContents.navigationHistory.restore({ index: 2, entries: brokenEntries }); + + const entries = w.webContents.navigationHistory.getAllEntries(); + + // Check that we used the original url and titles but threw away the broken + // pageState + entries.forEach((entry, index) => { + expect(entry.url).to.equal(brokenEntries[index].url); + expect(entry.title).to.equal(brokenEntries[index].title); + expect(entry.pageState?.length).to.be.greaterThanOrEqual(100); + }); + }); }); }); diff --git a/typings/internal-electron.d.ts b/typings/internal-electron.d.ts index 7a86a3701fb2..8277198ac9b9 100644 --- a/typings/internal-electron.d.ts +++ b/typings/internal-electron.d.ts @@ -87,6 +87,7 @@ declare namespace Electron { } interface WebContents { + _awaitNextLoad(expectedUrl: string): Promise; _loadURL(url: string, options: ElectronInternal.LoadURLOptions): void; getOwnerBrowserWindow(): Electron.BrowserWindow | null; getLastWebPreferences(): Electron.WebPreferences | null; @@ -115,6 +116,7 @@ declare namespace Electron { _goToIndex(index: number): void; _removeNavigationEntryAtIndex(index: number): boolean; _getHistory(): Electron.NavigationEntry[]; + _restoreHistory(index: number, entries: Electron.NavigationEntry[]): void _clearHistory():void canGoToIndex(index: number): boolean; destroy(): void;