From 9056dd728ae74953fa3a85bb8bdd6ef08072023d Mon Sep 17 00:00:00 2001 From: Alice Zhao <66543449+alicelovescake@users.noreply.github.com> Date: Fri, 5 Apr 2024 10:59:46 -0700 Subject: [PATCH] feat: add `navigationHistory.getEntryAtIndex(int index)` method (#41662) * feat: add `navigationHistory.getEntryAtIndex(int index)` method (#41577) * test: fix flaky tests by replacing real urls with data urls * test: remove hardcoded url --- docs/api/navigation-history.md | 29 ++++++++ docs/api/web-contents.md | 4 + filenames.auto.gni | 1 + lib/browser/api/web-contents.ts | 11 +++ .../browser/api/electron_api_web_contents.cc | 25 ++++++- shell/browser/api/electron_api_web_contents.h | 1 + spec/api-web-contents-spec.ts | 73 +++++++++++++++++++ spec/chromium-spec.ts | 6 +- typings/internal-electron.d.ts | 6 +- 9 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 docs/api/navigation-history.md diff --git a/docs/api/navigation-history.md b/docs/api/navigation-history.md new file mode 100644 index 0000000000..7eed76851c --- /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)
+_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 dbce3066ef..75570c328e 100644 --- a/docs/api/web-contents.md +++ b/docs/api/web-contents.md @@ -2206,6 +2206,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 3fbf80faba..e0381fdaf1 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 c8bf54b2b6..4166dc5cad 100644 --- a/lib/browser/api/web-contents.ts +++ b/lib/browser/api/web-contents.ts @@ -531,6 +531,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 754e7a3fb9..01ed13bf0f 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> { } }; +template <> +struct Converter { + 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)); + 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 052a58acd3..17cf138bc7 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 e61b5df75b..f5fd80061e 100644 --- a/spec/api-web-contents-spec.ts +++ b/spec/api-web-contents-spec.ts @@ -546,6 +546,79 @@ describe('webContents module', () => { }); }); + describe('navigationHistory', () => { + let w: BrowserWindow; + const urlPage1 = 'data:text/html,'; + const urlPage2 = 'data:text/html,'; + const urlPage3 = 'data:text/html,'; + + 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(urlPage1); + const result = w.webContents.navigationHistory.getEntryAtIndex(0); + expect(result).to.deep.equal({ url: urlPage1, title: 'Page 1' }); + }); + it('should return null given an invalid index larger than history length', async () => { + await w.loadURL(urlPage1); + 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(urlPage1); + 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(urlPage1); + expect(w.webContents.navigationHistory.getActiveIndex()).to.equal(0); + }); + + it('should return valid active index after a multiple page visits', async () => { + await w.loadURL(urlPage1); + await w.loadURL(urlPage2); + await w.loadURL(urlPage3); + + 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(urlPage1); + expect(w.webContents.navigationHistory.length()).to.equal(1); + }); + + it('should return valid history length after a multiple page visits', async () => { + await w.loadURL(urlPage1); + await w.loadURL(urlPage2); + await w.loadURL(urlPage3); + + 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 7c73a6ee2a..8cfc735e1d 100644 --- a/spec/chromium-spec.ts +++ b/spec/chromium-spec.ts @@ -2200,13 +2200,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); }); }); @@ -2234,7 +2234,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 c68b9bf4d4..f810052758 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; _init(): void; + _getNavigationEntryAtIndex(index: number): Electron.EntryAtIndex | null; + _getActiveIndex(): number; + _historyLength(): number; canGoToIndex(index: number): boolean; - getActiveIndex(): number; - length(): number; destroy(): void; // attachToIframe(embedderWebContents: Electron.WebContents, embedderFrameId: number): void; @@ -163,7 +164,6 @@ declare namespace Electron { _replyChannel: ReplyChannel; } - // Deprecated / undocumented BrowserWindow methods interface BrowserWindow { getURL(): string;