Repository: electron/electron (language: cpp)

Issue:
[Feature Request]: View and modify navigation history of webcontents (and by extension webviews)
### Preflight Checklist

- [X] I have read the [Contributing Guidelines](https://github.com/electron/electron/blob/main/CONTRIBUTING.md) for this project.
- [X] I agree to follow the [Code of Conduct](https://github.com/electron/electron/blob/main/CODE_OF_CONDUCT.md) that this project adheres to.
- [X] I have searched the [issue tracker](https://www.github.com/electron/electron/issues) for a feature request that matches the one I want to file, without success.

### Problem Description

Currently we only have functions for checking if there is history in a specific direction and moving there, which are `canGoBack`, `goBack`, `canGoForward` and `goForward`. We also have `goToIndex` and `goToOffset`, but these seem fairly useless if there is no way to know the length of the list nor the current index, let alone which entries are in the navigation list. Finally, it's also impossible to change this "invisible" navigation history list in any way. It [used to be possible with `.history`](https://github.com/electron/electron/issues/7186), but that got removed, and now there seems to be no other way.

### Proposed Solution

Add back `.history` or some alternative (maybe getters or setters, though Electron seems to be moving to read/write properties lately, so that would also have my preference) to view the list so that there is actually a use for `goToIndex` and `goToOffset` that's not just a guess. Secondly, add a way to know where in the list the navigation currently is, again, `canGoForward` and such can let you make a guess, but it would be better to know. Finally, I would like a way to modify this list, as I need that for one of my projects to be able to restore the history for a webview after restarting the app (or recreating the webview after closing it). If technically easier, I would not mind this being something that needs to happen on creation of the webview, but a property or set of functions on the webcontents seem more reusable and fitting if you ask me.

### Alternatives Considered

As a last resort, not using `goBack` and `goForward` or ANY of the navigation features provided by Electron and building a custom history manager from scratch, which is tedious, but otherwise needed, because the current implementation is not clear on what is in the history at all, and cannot be modified in any way.

### Additional Information

_No response_


Discussion / hints:
The browser extensions has a [history API](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/history), but I am not sure if it can be used in Electron.
Kind of, but it does not allow full control, since this has just a search and some index function, while I would expect/like to have an array of urls that I can modify per webview/webcontents.
> Kind of, but it does not allow full control, since this has just a search and some index function, while I would expect/like to have an array of urls that I can modify per webview/webcontents.

The `history.search` method can set the text parameter to an empty string to get all the history. But I haven't tested this, and I've looked at electron's source code and found that webContents' history is implemented by itself via js, not chromium's own api, which may not be applicable to browser extensions. For now I am planning to listen to the `did-navigate` and `did-navigate-in-page` events, and save the url myself to implement a history feature.
The problem with that, being that you cannot restore previous history to the webcontents and expect to be able to use `goBack` and `goForward`, as the history is empty for new webcontents and cannot be changed without navigating, nor can the existing history be read from the renderer process easily, this would need a preload and the navigations will still only be visible if they are in the same domain, so of no real use really for reading/writing custom history.

Fix patch:
diff --git a/docs/api/navigation-history.md b/docs/api/navigation-history.md
new file mode 100644
index 0000000000000..7eed76851c4a9
--- /dev/null
+++ b/docs/api/navigation-history.md
@@ -0,0 +1,29 @@
+## Class: NavigationHistory
+
+> Manage a list of navigation entries, representing the user's browsing history within the application.
+
+Process: [Main](../glossary.md#main-process)<br />
+_This class is not exported from the `'electron'` module. It is only available as a return value of other methods in the Electron API._
+
+Each navigation entry corresponds to a specific page. The indexing system follows a sequential order, where the first available navigation entry is at index 0, representing the earliest visited page, and the latest navigation entry is at index N, representing the most recent page. Maintaining this ordered list of navigation entries enables seamless navigation both backward and forward through the user's browsing history.
+
+### Instance Methods
+
+#### `navigationHistory.getActiveIndex()`
+
+Returns `Integer` - The index of the current page, from which we would go back/forward or reload.
+
+#### `navigationHistory.getEntryAtIndex(index)`
+
+* `index` Integer
+
+Returns `Object`:
+
+* `url` string - The URL of the navigation entry at the given index.
+* `title` string - The page title of the navigation entry at the given index.
+
+If index is out of bounds (greater than history length or less than 0), null will be returned.
+
+#### `navigationHistory.length()`
+
+Returns `Integer` - History length.
diff --git a/docs/api/web-contents.md b/docs/api/web-contents.md
index 1530acf29e7a2..1aef04a308019 100644
--- a/docs/api/web-contents.md
+++ b/docs/api/web-contents.md
@@ -2223,6 +2223,10 @@ A `Integer` representing the unique ID of this WebContents. Each ID is unique am
 
 A [`Session`](session.md) used by this webContents.
 
+#### `contents.navigationHistory` _Readonly_
+
+A [`NavigationHistory`](navigation-history.md) used by this webContents.
+
 #### `contents.hostWebContents` _Readonly_
 
 A [`WebContents`](web-contents.md) instance that might own this `WebContents`.
diff --git a/filenames.auto.gni b/filenames.auto.gni
index cac988467ffbb..000573868c73d 100644
--- a/filenames.auto.gni
+++ b/filenames.auto.gni
@@ -34,6 +34,7 @@ auto_filenames = {
     "docs/api/message-port-main.md",
     "docs/api/native-image.md",
     "docs/api/native-theme.md",
+    "docs/api/navigation-history.md",
     "docs/api/net-log.md",
     "docs/api/net.md",
     "docs/api/notification.md",
diff --git a/lib/browser/api/web-contents.ts b/lib/browser/api/web-contents.ts
index 680e6e5ac5d1d..a03306c9b35a9 100644
--- a/lib/browser/api/web-contents.ts
+++ b/lib/browser/api/web-contents.ts
@@ -533,6 +533,17 @@ WebContents.prototype._init = function () {
     enumerable: true
   });
 
+  // Add navigationHistory property which handles session history,
+  // maintaining a list of navigation entries for backward and forward navigation.
+  Object.defineProperty(this, 'navigationHistory', {
+    value: {
+      getActiveIndex: this._getActiveIndex.bind(this),
+      length: this._historyLength.bind(this),
+      getEntryAtIndex: this._getNavigationEntryAtIndex.bind(this)
+    },
+    writable: false
+  });
+
   // Dispatch IPC messages to the ipc module.
   this.on('-ipc-message' as any, function (this: Electron.WebContents, event: Electron.IpcMainEvent, internal: boolean, channel: string, args: any[]) {
     addSenderToEvent(event, this);
diff --git a/shell/browser/api/electron_api_web_contents.cc b/shell/browser/api/electron_api_web_contents.cc
index 754e7a3fb9a9f..01ed13bf0f4ea 100644
--- a/shell/browser/api/electron_api_web_contents.cc
+++ b/shell/browser/api/electron_api_web_contents.cc
@@ -352,6 +352,20 @@ struct Converter<scoped_refptr<content::DevToolsAgentHost>> {
   }
 };
 
+template <>
+struct Converter<content::NavigationEntry*> {
+  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));
+    dict.Set("url", entry->GetURL().spec());
+    dict.Set("title", entry->GetTitleForDisplay());
+    return dict.GetHandle();
+  }
+};
+
 }  // namespace gin
 
 namespace electron::api {
@@ -2560,6 +2574,11 @@ int WebContents::GetActiveIndex() const {
   return web_contents()->GetController().GetCurrentEntryIndex();
 }
 
+content::NavigationEntry* WebContents::GetNavigationEntryAtIndex(
+    int index) const {
+  return web_contents()->GetController().GetEntryAtIndex(index);
+}
+
 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
@@ -4353,9 +4372,11 @@ void WebContents::FillObjectTemplate(v8::Isolate* isolate,
       .SetMethod("goToOffset", &WebContents::GoToOffset)
       .SetMethod("canGoToIndex", &WebContents::CanGoToIndex)
       .SetMethod("goToIndex", &WebContents::GoToIndex)
-      .SetMethod("getActiveIndex", &WebContents::GetActiveIndex)
+      .SetMethod("_getActiveIndex", &WebContents::GetActiveIndex)
+      .SetMethod("_getNavigationEntryAtIndex",
+                 &WebContents::GetNavigationEntryAtIndex)
+      .SetMethod("_historyLength", &WebContents::GetHistoryLength)
       .SetMethod("clearHistory", &WebContents::ClearHistory)
-      .SetMethod("length", &WebContents::GetHistoryLength)
       .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 052a58acd38ed..17cf138bc7152 100644
--- a/shell/browser/api/electron_api_web_contents.h
+++ b/shell/browser/api/electron_api_web_contents.h
@@ -194,6 +194,7 @@ class WebContents : public ExclusiveAccessContext,
   bool CanGoToIndex(int index) const;
   void GoToIndex(int index);
   int GetActiveIndex() const;
+  content::NavigationEntry* GetNavigationEntryAtIndex(int index) const;
   void ClearHistory();
   int GetHistoryLength() const;
   const std::string GetWebRTCIPHandlingPolicy() const;
diff --git a/spec/api-web-contents-spec.ts b/spec/api-web-contents-spec.ts
index 17ed36292e0d1..cdace1feab1c3 100644
--- a/spec/api-web-contents-spec.ts
+++ b/spec/api-web-contents-spec.ts
@@ -546,6 +546,94 @@ describe('webContents module', () => {
     });
   });
 
+  describe('navigationHistory', () => {
+    let w: BrowserWindow;
+
+    beforeEach(async () => {
+      w = new BrowserWindow({ show: false });
+    });
+    afterEach(closeAllWindows);
+    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: '' });
+      });
+      it('should fetch navigation entry given a valid index', async () => {
+        await w.loadURL('https://www.google.com');
+        w.webContents.on('did-finish-load', async () => {
+          const result = w.webContents.navigationHistory.getEntryAtIndex(0);
+          expect(result).to.deep.equal({ url: 'https://www.google.com/', title: 'Google' });
+        });
+      });
+      it('should return null given an invalid index larger than history length', async () => {
+        await w.loadURL('https://www.google.com');
+        w.webContents.on('did-finish-load', async () => {
+          const result = w.webContents.navigationHistory.getEntryAtIndex(5);
+          expect(result).to.be.null();
+        });
+      });
+      it('should return null given an invalid negative index', async () => {
+        await w.loadURL('https://www.google.com');
+        w.webContents.on('did-finish-load', async () => {
+          const result = w.webContents.navigationHistory.getEntryAtIndex(-1);
+          expect(result).to.be.null();
+        });
+      });
+    });
+
+    describe('navigationHistory.getActiveIndex() API', () => {
+      it('should return valid active index after a single page visit', async () => {
+        await w.loadURL('https://www.google.com');
+        w.webContents.on('did-finish-load', async () => {
+          expect(w.webContents.navigationHistory.getActiveIndex()).to.equal(0);
+        });
+      });
+
+      it('should return valid active index after a multiple page visits', async () => {
+        const loadPromise = once(w.webContents, 'did-finish-load');
+        await w.loadURL('https://www.github.com');
+        await loadPromise;
+        await w.loadURL('https://www.google.com');
+        await loadPromise;
+        await w.loadURL('about:blank');
+        await loadPromise;
+
+        expect(w.webContents.navigationHistory.getActiveIndex()).to.equal(2);
+      });
+
+      it('should return valid active index given no page visits', async () => {
+        expect(w.webContents.navigationHistory.getActiveIndex()).to.equal(0);
+      });
+    });
+
+    describe('navigationHistory.length() API', () => {
+      it('should return valid history length after a single page visit', async () => {
+        await w.loadURL('https://www.google.com');
+        w.webContents.on('did-finish-load', async () => {
+          expect(w.webContents.navigationHistory.length()).to.equal(1);
+        });
+      });
+
+      it('should return valid history length after a multiple page visits', async () => {
+        const loadPromise = once(w.webContents, 'did-finish-load');
+        await w.loadURL('https://www.github.com');
+        await loadPromise;
+        await w.loadURL('https://www.google.com');
+        await loadPromise;
+        await w.loadURL('about:blank');
+        await loadPromise;
+
+        expect(w.webContents.navigationHistory.length()).to.equal(3);
+      });
+
+      it('should return valid history length given no page visits', async () => {
+        // Note: Even if no navigation has committed, the history list will always start with an initial navigation entry
+        // Ref: https://source.chromium.org/chromium/chromium/src/+/main:ceontent/public/browser/navigation_controller.h;l=381
+        expect(w.webContents.navigationHistory.length()).to.equal(1);
+      });
+    });
+  });
+
   describe('getFocusedWebContents() API', () => {
     afterEach(closeAllWindows);
 
diff --git a/spec/chromium-spec.ts b/spec/chromium-spec.ts
index 5694e7ea11e48..7f64caad59d56 100644
--- a/spec/chromium-spec.ts
+++ b/spec/chromium-spec.ts
@@ -2068,13 +2068,13 @@ describe('chromium features', () => {
         const w = new BrowserWindow({ show: false });
         await w.loadFile(path.join(fixturesPath, 'pages', 'blank.html'));
         // History should have current page by now.
-        expect(w.webContents.length()).to.equal(1);
+        expect(w.webContents.navigationHistory.length()).to.equal(1);
 
         const waitCommit = once(w.webContents, 'navigation-entry-committed');
         w.webContents.executeJavaScript('window.history.pushState({}, "")');
         await waitCommit;
         // Initial page + pushed state.
-        expect(w.webContents.length()).to.equal(2);
+        expect(w.webContents.navigationHistory.length()).to.equal(2);
       });
     });
 
@@ -2102,7 +2102,7 @@ describe('chromium features', () => {
         });
         await w.webContents.mainFrame.frames[0].executeJavaScript('window.history.back()');
         expect(await w.webContents.executeJavaScript('window.history.state')).to.equal(1);
-        expect(w.webContents.getActiveIndex()).to.equal(1);
+        expect(w.webContents.navigationHistory.getActiveIndex()).to.equal(1);
       });
     });
   });
diff --git a/typings/internal-electron.d.ts b/typings/internal-electron.d.ts
index 6907f0712e859..21c9ebee297c9 100644
--- a/typings/internal-electron.d.ts
+++ b/typings/internal-electron.d.ts
@@ -85,9 +85,10 @@ declare namespace Electron {
     _print(options: any, callback?: (success: boolean, failureReason: string) => void): void;
     _getPrintersAsync(): Promise<Electron.PrinterInfo[]>;
     _init(): void;
+    _getNavigationEntryAtIndex(index: number): Electron.EntryAtIndex | null;
+    _getActiveIndex(): number;
+    _historyLength(): number;
     canGoToIndex(index: number): boolean;
-    getActiveIndex(): number;
-    length(): number;
     destroy(): void;
     // <webview>
     attachToIframe(embedderWebContents: Electron.WebContents, embedderFrameId: number): void;
@@ -112,7 +113,7 @@ declare namespace Electron {
     embedder?: Electron.WebContents;
     type?: 'backgroundPage' | 'window' | 'browserView' | 'remote' | 'webview' | 'offscreen';
   }
-  
+
   type CreateWindowFunction = (options: BrowserWindowConstructorOptions) => WebContents;
 
   interface Menu {
@@ -165,7 +166,6 @@ declare namespace Electron {
     _replyChannel: ReplyChannel;
   }
 
-
   // Deprecated / undocumented BrowserWindow methods
   interface BrowserWindow {
     getURL(): string;


Test patch: