Browse Source

feat: Restore webContents navigation history and page state (#45584)

* feat: Working navigationHistory.restore with just title/url

Co-authored-by: Felix Rieseberg <[email protected]>

* feat: Restore page state, too

Co-authored-by: Felix Rieseberg <[email protected]>

* chore: Docs, lint, tests

Co-authored-by: Felix Rieseberg <[email protected]>

* Implement feedback

Co-authored-by: Felix Rieseberg <[email protected]>

* More magic

Co-authored-by: Felix Rieseberg <[email protected]>

* Make _awaitNextLoad truly private

Co-authored-by: Felix Rieseberg <[email protected]>

* Implement API group feedback

Co-authored-by: Felix Rieseberg <[email protected]>

* One more round of feedback

Co-authored-by: Felix Rieseberg <[email protected]>

---------

Co-authored-by: trop[bot] <37223003+trop[bot]@users.noreply.github.com>
Co-authored-by: Felix Rieseberg <[email protected]>
trop[bot] 2 months ago
parent
commit
ddc7afd3f0

+ 19 - 0
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<void>` - 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.

+ 3 - 0
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.

+ 18 - 1
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
-
 ```

+ 29 - 5
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<void>((resolve, reject) => {
+function _awaitNextLoad (this: Electron.WebContents, navigationUrl: string) {
+  return new Promise<void>((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 ?? {});
@@ -609,7 +613,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

+ 90 - 1
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"
@@ -353,14 +354,60 @@ struct Converter<scoped_refptr<content::DevToolsAgentHost>> {
 
 template <>
 struct Converter<content::NavigationEntry*> {
+  static bool FromV8(v8::Isolate* isolate,
+                     v8::Local<v8::Value> 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<v8::Value> 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();
   }
 };
@@ -2560,6 +2607,47 @@ std::vector<content::NavigationEntry*> WebContents::GetHistory() const {
   return history;
 }
 
+void WebContents::RestoreHistory(
+    v8::Isolate* isolate,
+    gin_helper::ErrorThrower thrower,
+    int index,
+    const std::vector<v8::Local<v8::Value>>& 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<std::unique_ptr<content::NavigationEntry>>>();
+
+  for (const auto& entry : entries) {
+    content::NavigationEntry* nav_entry = nullptr;
+    if (!gin::Converter<content::NavigationEntry*>::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<content::NavigationEntry>(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
@@ -4389,6 +4477,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)

+ 4 - 0
shell/browser/api/electron_api_web_contents.h

@@ -214,6 +214,10 @@ class WebContents final : public ExclusiveAccessContext,
   bool RemoveNavigationEntryAtIndex(int index);
   std::vector<content::NavigationEntry*> GetHistory() const;
   void ClearHistory();
+  void RestoreHistory(v8::Isolate* isolate,
+                      gin_helper::ErrorThrower thrower,
+                      int index,
+                      const std::vector<v8::Local<v8::Value>>& entries);
   int GetHistoryLength() const;
   const std::string GetWebRTCIPHandlingPolicy() const;
   void SetWebRTCIPHandlingPolicy(const std::string& webrtc_ip_handling_policy);

+ 94 - 3
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('<html><head><title>Form</title></head><body><form><input type="text" value="value" /></form></body></html>');
+        });
+        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<string>(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);
+        });
+      });
     });
   });
 

+ 2 - 0
typings/internal-electron.d.ts

@@ -72,6 +72,7 @@ declare namespace Electron {
   }
 
   interface WebContents {
+    _awaitNextLoad(expectedUrl: string): Promise<void>;
     _loadURL(url: string, options: ElectronInternal.LoadURLOptions): void;
     getOwnerBrowserWindow(): Electron.BrowserWindow | null;
     getLastWebPreferences(): Electron.WebPreferences | null;
@@ -100,6 +101,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;