diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/block.json b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/block.json
new file mode 100644
index 0000000000000..bdb5a19e03062
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/block.json
@@ -0,0 +1,14 @@
+{
+ "apiVersion": 2,
+ "name": "test/router-navigate",
+ "title": "E2E Interactivity tests - router navigate",
+ "category": "text",
+ "icon": "heart",
+ "description": "",
+ "supports": {
+ "interactivity": true
+ },
+ "textdomain": "e2e-interactivity",
+ "viewScript": "router-navigate-view",
+ "render": "file:./render.php"
+}
diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php
new file mode 100644
index 0000000000000..683e0eaff6e89
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/render.php
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+ $link ) {
+ $i = $key += 1;
+ echo <<link $i
+
link $i with hash
+HTML;
+ }
+ }
+ ?>
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js
new file mode 100644
index 0000000000000..468b4a64482d3
--- /dev/null
+++ b/packages/e2e-tests/plugins/interactive-blocks/router-navigate/view.js
@@ -0,0 +1,35 @@
+( ( { wp } ) => {
+ /**
+ * WordPress dependencies
+ */
+ const { store, navigate } = wp.interactivity;
+
+ store( {
+ state: {
+ router: {
+ status: 'idle',
+ navigations: 0,
+ }
+ },
+ actions: {
+ router: {
+ navigate: async ( { state, event: e } ) => {
+ e.preventDefault();
+
+ state.router.navigations += 1;
+ state.router.status = 'busy';
+
+ const force = e.target.dataset.forceNavigation === 'true';
+
+ await navigate( e.target.href, { force } );
+
+ state.router.navigations -= 1;
+
+ if ( state.router.navigations === 0) {
+ state.router.status = 'idle';
+ }
+ },
+ },
+ },
+ } );
+} )( window );
diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md
index 636f78d3357cf..19e82ff30471b 100644
--- a/packages/interactivity/CHANGELOG.md
+++ b/packages/interactivity/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+### Enhancements
+
+- Improve `navigate()` to render only the result of the last call when multiple happen simultaneously. ([#54201](https://github.com/WordPress/gutenberg/pull/54201))
+
## 2.2.0 (2023-08-31)
### Enhancements
diff --git a/packages/interactivity/src/router.js b/packages/interactivity/src/router.js
index cc7925e2fc398..17b2b54e4d457 100644
--- a/packages/interactivity/src/router.js
+++ b/packages/interactivity/src/router.js
@@ -78,11 +78,21 @@ const renderRegions = ( page ) => {
} );
};
+// Variable to store the current navigation.
+let navigatingTo = '';
+
// Navigate to a new page.
export const navigate = async ( href, options = {} ) => {
const url = cleanUrl( href );
+ navigatingTo = href;
prefetch( url, options );
const page = await pages.get( url );
+
+ // Once the page is fetched, the destination URL could have changed (e.g.,
+ // by clicking another link in the meantime). If so, bail out, and let the
+ // newer execution to update the HTML.
+ if ( navigatingTo !== href ) return;
+
if ( page ) {
renderRegions( page );
window.history[ options.replace ? 'replaceState' : 'pushState' ](
diff --git a/test/e2e/specs/interactivity/router-navigate.spec.ts b/test/e2e/specs/interactivity/router-navigate.spec.ts
new file mode 100644
index 0000000000000..308bc6fd98618
--- /dev/null
+++ b/test/e2e/specs/interactivity/router-navigate.spec.ts
@@ -0,0 +1,129 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from './fixtures';
+
+test.describe( 'Router navigate', () => {
+ test.beforeAll( async ( { interactivityUtils: utils } ) => {
+ await utils.activatePlugins();
+ const link2 = await utils.addPostWithBlock( 'test/router-navigate', {
+ alias: 'router navigate - link 2',
+ attributes: { title: 'Link 2' },
+ } );
+ const link1 = await utils.addPostWithBlock( 'test/router-navigate', {
+ alias: 'router navigate - link 1',
+ attributes: { title: 'Link 1' },
+ } );
+ await utils.addPostWithBlock( 'test/router-navigate', {
+ alias: 'router navigate - main',
+ attributes: { title: 'Main', links: [ link1, link2 ] },
+ } );
+ } );
+
+ test.beforeEach( async ( { interactivityUtils: utils, page } ) => {
+ await page.goto( utils.getLink( 'router navigate - main' ) );
+ } );
+
+ test.afterAll( async ( { interactivityUtils: utils } ) => {
+ await utils.deactivatePlugins();
+ await utils.deleteAllPosts();
+ } );
+
+ test( 'should update the HTML only for the latest navigation', async ( {
+ page,
+ interactivityUtils: utils,
+ } ) => {
+ const link1 = utils.getLink( 'router navigate - link 1' );
+ const link2 = utils.getLink( 'router navigate - link 2' );
+
+ const navigations = page.getByTestId( 'router navigations' );
+ const status = page.getByTestId( 'router status' );
+ const title = page.getByTestId( 'title' );
+
+ await expect( navigations ).toHaveText( '0' );
+ await expect( status ).toHaveText( 'idle' );
+
+ let resolveLink1: Function;
+ let resolveLink2: Function;
+
+ await page.route( link1, async ( route ) => {
+ await new Promise( ( r ) => ( resolveLink1 = r ) );
+ await route.continue();
+ } );
+ await page.route( link2, async ( route ) => {
+ await new Promise( ( r ) => ( resolveLink2 = r ) );
+ await route.continue();
+ } );
+
+ await page.getByTestId( 'link 1' ).click();
+ await page.getByTestId( 'link 2' ).click();
+
+ await expect( navigations ).toHaveText( '2' );
+ await expect( status ).toHaveText( 'busy' );
+ await expect( title ).toHaveText( 'Main' );
+
+ await Promise.resolve().then( () => resolveLink2() );
+
+ await expect( navigations ).toHaveText( '1' );
+ await expect( status ).toHaveText( 'busy' );
+ await expect( title ).toHaveText( 'Link 2' );
+
+ await Promise.resolve().then( () => resolveLink1() );
+
+ await expect( navigations ).toHaveText( '0' );
+ await expect( status ).toHaveText( 'idle' );
+ await expect( title ).toHaveText( 'Link 2' );
+ } );
+
+ test( 'should update the URL from the last navigation if only varies in the URL fragment', async ( {
+ page,
+ interactivityUtils: utils,
+ } ) => {
+ const link1 = utils.getLink( 'router navigate - link 1' );
+
+ const navigations = page.getByTestId( 'router navigations' );
+ const status = page.getByTestId( 'router status' );
+ const title = page.getByTestId( 'title' );
+
+ await expect( navigations ).toHaveText( '0' );
+ await expect( status ).toHaveText( 'idle' );
+
+ const resolvers: Function[] = [];
+
+ await page.route( link1, async ( route ) => {
+ await new Promise( ( r ) => resolvers.push( r ) );
+ await route.continue();
+ } );
+
+ await page.getByTestId( 'link 1' ).click();
+ await page.getByTestId( 'link 1 with hash' ).click();
+
+ const href = ( await page
+ .getByTestId( 'link 1 with hash' )
+ .getAttribute( 'href' ) ) as string;
+
+ await expect( navigations ).toHaveText( '2' );
+ await expect( status ).toHaveText( 'busy' );
+ await expect( title ).toHaveText( 'Main' );
+
+ {
+ const resolver = resolvers.pop();
+ if ( resolver ) resolver();
+ }
+
+ await expect( navigations ).toHaveText( '1' );
+ await expect( status ).toHaveText( 'busy' );
+ await expect( title ).toHaveText( 'Link 1' );
+ await expect( page ).toHaveURL( href );
+
+ {
+ const resolver = resolvers.pop();
+ if ( resolver ) resolver();
+ }
+
+ await expect( navigations ).toHaveText( '0' );
+ await expect( status ).toHaveText( 'idle' );
+ await expect( title ).toHaveText( 'Link 1' );
+ await expect( page ).toHaveURL( href );
+ } );
+} );