diff --git a/docs/manifest.json b/docs/manifest.json
index 67b8fac99f7137..e8c2e0d9d2b0f0 100644
--- a/docs/manifest.json
+++ b/docs/manifest.json
@@ -1673,6 +1673,12 @@
"markdown_source": "../packages/icons/README.md",
"parent": "packages"
},
+ {
+ "title": "@wordpress/interactivity-router",
+ "slug": "packages-interactivity-router",
+ "markdown_source": "../packages/interactivity-router/README.md",
+ "parent": "packages"
+ },
{
"title": "@wordpress/interactivity",
"slug": "packages-interactivity",
diff --git a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php
index de1d8b2a9e7890..ad9e5d7c439533 100644
--- a/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php
+++ b/lib/compat/wordpress-6.5/interactivity-api/class-wp-interactivity-api.php
@@ -150,6 +150,13 @@ public function register_script_modules() {
array(),
defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' )
);
+
+ wp_register_script_module(
+ '@wordpress/interactivity-router',
+ gutenberg_url( '/build/interactivity/router.min.js' ),
+ array( '@wordpress/interactivity' ),
+ defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' )
+ );
}
/**
diff --git a/package-lock.json b/package-lock.json
index 48abf7ff587d0a..9d87d5b29f9486 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17939,6 +17939,10 @@
"resolved": "packages/interactivity",
"link": true
},
+ "node_modules/@wordpress/interactivity-router": {
+ "resolved": "packages/interactivity-router",
+ "link": true
+ },
"node_modules/@wordpress/interface": {
"resolved": "packages/interface",
"link": true
@@ -54066,6 +54070,7 @@
"@wordpress/i18n": "file:../i18n",
"@wordpress/icons": "file:../icons",
"@wordpress/interactivity": "file:../interactivity",
+ "@wordpress/interactivity-router": "file:../interactivity-router",
"@wordpress/keycodes": "file:../keycodes",
"@wordpress/notices": "file:../notices",
"@wordpress/patterns": "file:../patterns",
@@ -54700,6 +54705,7 @@
"dependencies": {
"@wordpress/e2e-test-utils": "file:../e2e-test-utils",
"@wordpress/interactivity": "file:../interactivity",
+ "@wordpress/interactivity-router": "file:../interactivity-router",
"@wordpress/jest-console": "file:../jest-console",
"@wordpress/jest-puppeteer-axe": "file:../jest-puppeteer-axe",
"@wordpress/scripts": "file:../scripts",
@@ -55237,6 +55243,17 @@
"node": ">=12"
}
},
+ "packages/interactivity-router": {
+ "name": "@wordpress/interactivity-router",
+ "version": "0.1.0",
+ "license": "GPL-2.0-or-later",
+ "dependencies": {
+ "@wordpress/interactivity": "file:../interactivity"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"packages/interactivity/node_modules/@preact/signals": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@preact/signals/-/signals-1.2.2.tgz",
@@ -69267,6 +69284,7 @@
"@wordpress/i18n": "file:../i18n",
"@wordpress/icons": "file:../icons",
"@wordpress/interactivity": "file:../interactivity",
+ "@wordpress/interactivity-router": "file:../interactivity-router",
"@wordpress/keycodes": "file:../keycodes",
"@wordpress/notices": "file:../notices",
"@wordpress/patterns": "file:../patterns",
@@ -69711,6 +69729,7 @@
"requires": {
"@wordpress/e2e-test-utils": "file:../e2e-test-utils",
"@wordpress/interactivity": "file:../interactivity",
+ "@wordpress/interactivity-router": "file:../interactivity-router",
"@wordpress/jest-console": "file:../jest-console",
"@wordpress/jest-puppeteer-axe": "file:../jest-puppeteer-axe",
"@wordpress/scripts": "file:../scripts",
@@ -70124,6 +70143,12 @@
}
}
},
+ "@wordpress/interactivity-router": {
+ "version": "file:packages/interactivity-router",
+ "requires": {
+ "@wordpress/interactivity": "file:../interactivity"
+ }
+ },
"@wordpress/interface": {
"version": "file:packages/interface",
"requires": {
diff --git a/packages/block-library/package.json b/packages/block-library/package.json
index 12f4e23d9da4d0..d4927b341685e6 100644
--- a/packages/block-library/package.json
+++ b/packages/block-library/package.json
@@ -51,6 +51,7 @@
"@wordpress/i18n": "file:../i18n",
"@wordpress/icons": "file:../icons",
"@wordpress/interactivity": "file:../interactivity",
+ "@wordpress/interactivity-router": "file:../interactivity-router",
"@wordpress/keycodes": "file:../keycodes",
"@wordpress/notices": "file:../notices",
"@wordpress/patterns": "file:../patterns",
diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php
index f40d9df2fb06be..0239fadc6bf0af 100644
--- a/packages/block-library/src/query/index.php
+++ b/packages/block-library/src/query/index.php
@@ -100,8 +100,17 @@ function register_block_core_query() {
wp_register_script_module(
'@wordpress/block-library/query',
- '/wp-content/plugins/gutenberg/build/interactivity/query.min.js',
- array( '@wordpress/interactivity' ),
+ gutenberg_url( '/build/interactivity/query.min.js' ),
+ array(
+ array(
+ 'id' => '@wordpress/interactivity',
+ 'import' => 'static',
+ ),
+ array(
+ 'id' => '@wordpress/interactivity-router',
+ 'import' => 'dynamic',
+ ),
+ ),
defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : get_bloginfo( 'version' )
);
}
diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js
index ccf70810047673..89eb8198d1805c 100644
--- a/packages/block-library/src/query/view.js
+++ b/packages/block-library/src/query/view.js
@@ -1,13 +1,7 @@
/**
* WordPress dependencies
*/
-import {
- store,
- getContext,
- getElement,
- navigate,
- prefetch,
-} from '@wordpress/interactivity';
+import { store, getContext, getElement } from '@wordpress/interactivity';
const isValidLink = ( ref ) =>
ref &&
@@ -52,7 +46,10 @@ store( 'core/query', {
ctx.animation = 'start';
}, 400 );
- yield navigate( ref.href );
+ const { actions } = yield import(
+ '@wordpress/interactivity-router'
+ );
+ yield actions.navigate( ref.href );
// Dismiss loading message if it hasn't been added yet.
clearTimeout( timeout );
@@ -77,7 +74,10 @@ store( 'core/query', {
const isDisabled = ref.closest( '[data-wp-navigation-id]' )?.dataset
.wpNavigationDisabled;
if ( isValidLink( ref ) && ! isDisabled ) {
- yield prefetch( ref.href );
+ const { actions } = yield import(
+ '@wordpress/interactivity-router'
+ );
+ yield actions.prefetch( ref.href );
}
},
},
@@ -86,7 +86,10 @@ store( 'core/query', {
const { url } = getContext();
const { ref } = getElement();
if ( url && isValidLink( ref ) ) {
- yield prefetch( ref.href );
+ const { actions } = yield import(
+ '@wordpress/interactivity-router'
+ );
+ yield actions.prefetch( ref.href );
}
},
},
diff --git a/packages/block-library/tsconfig.json b/packages/block-library/tsconfig.json
index a9a30e9804e1f3..a5f01298a8fad4 100644
--- a/packages/block-library/tsconfig.json
+++ b/packages/block-library/tsconfig.json
@@ -27,6 +27,7 @@
{ "path": "../i18n" },
{ "path": "../icons" },
{ "path": "../interactivity" },
+ { "path": "../interactivity-router" },
{ "path": "../notices" },
{ "path": "../keycodes" },
{ "path": "../primitives" },
diff --git a/packages/dependency-extraction-webpack-plugin/lib/util.js b/packages/dependency-extraction-webpack-plugin/lib/util.js
index 39b2f4b4bca587..3e1795ddca2470 100644
--- a/packages/dependency-extraction-webpack-plugin/lib/util.js
+++ b/packages/dependency-extraction-webpack-plugin/lib/util.js
@@ -59,9 +59,11 @@ function defaultRequestToExternal( request ) {
/**
* Default request to external module transformation
*
- * Currently only @wordpress/interactivity
+ * Currently only @wordpress/interactivity and `@wordpress/interactivity-router`
+ * are supported.
*
- * Do not use the boolean shorthand here, it's only handled for the `requestToExternalModule` option.
+ * Do not use the boolean shorthand here, it's only handled for the
+ * `requestToExternalModule` option.
*
* @param {string} request Module request (the module name in `import from`) to be transformed
* @return {string|Error|undefined} The resulting external definition.
@@ -71,13 +73,19 @@ function defaultRequestToExternal( request ) {
*/
function defaultRequestToExternalModule( request ) {
if ( request === '@wordpress/interactivity' ) {
- // This is a special case. Interactivity does not support dynamic imports at this
- // time. We add the external "module" type to indicate that webpack should
- // externalize this as a module (instead of our default `import()` external type)
- // which forces @wordpress/interactivity imports to be hoisted to static imports.
+ // This is a special case. Interactivity does not support dynamic imports at
+ // this time. We add the external "module" type to indicate that webpack
+ // should externalize this as a module (instead of our default `import()`
+ // external type) which forces @wordpress/interactivity imports to be
+ // hoisted to static imports.
return `module ${ request }`;
}
+ if ( request === '@wordpress/interactivity-router' ) {
+ // Assumes this is usually going to be used as a dynamic import.
+ return `import ${ request }`;
+ }
+
const isWordPressScript = Boolean( defaultRequestToExternal( request ) );
if ( isWordPressScript ) {
diff --git a/packages/e2e-tests/package.json b/packages/e2e-tests/package.json
index 864eba1af12acf..bd6620dadc6b9f 100644
--- a/packages/e2e-tests/package.json
+++ b/packages/e2e-tests/package.json
@@ -25,6 +25,7 @@
"dependencies": {
"@wordpress/e2e-test-utils": "file:../e2e-test-utils",
"@wordpress/interactivity": "file:../interactivity",
+ "@wordpress/interactivity-router": "file:../interactivity-router",
"@wordpress/jest-console": "file:../jest-console",
"@wordpress/jest-puppeteer-axe": "file:../jest-puppeteer-axe",
"@wordpress/scripts": "file:../scripts",
diff --git a/packages/e2e-tests/plugins/interactive-blocks.php b/packages/e2e-tests/plugins/interactive-blocks.php
index 66fa658d0da3c7..54d68ab674694f 100644
--- a/packages/e2e-tests/plugins/interactive-blocks.php
+++ b/packages/e2e-tests/plugins/interactive-blocks.php
@@ -24,7 +24,13 @@ function () {
wp_register_script_module(
$name . '-view',
$view_file,
- array( '@wordpress/interactivity' ),
+ array(
+ '@wordpress/interactivity',
+ array(
+ 'id' => '@wordpress/interactivity-router',
+ 'import' => 'dynamic',
+ ),
+ ),
filemtime( $view_file )
);
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js
index 8317f155d190e9..83683a00aff12a 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js
@@ -1,7 +1,7 @@
/**
* WordPress dependencies
*/
-import { store, navigate, getContext } from '@wordpress/interactivity';
+import { store, getContext } from '@wordpress/interactivity';
store( 'directive-context', {
state: {
@@ -50,10 +50,14 @@ const { actions } = store( 'directive-context-navigate', {
ctx.newText = 'some new text';
},
navigate() {
- return navigate( window.location, {
- force: true,
- html,
- } );
+ return import( '@wordpress/interactivity-router' ).then(
+ ( { actions: routerActions } ) =>
+ routerActions.navigate(
+ window.location,
+ { force: true, html },
+ )
+ );
+
},
*asyncNavigate() {
yield actions.navigate();
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-each/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-each/view.js
index 55677c9629ad39..b38f531caee967 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/directive-each/view.js
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-each/view.js
@@ -1,7 +1,7 @@
/**
* WordPress dependencies
*/
-import { store, getContext, navigate } from '@wordpress/interactivity';
+import { store, getContext } from '@wordpress/interactivity';
const { state } = store( 'directive-each' );
@@ -178,8 +178,11 @@ const html = `
store( 'directive-each', {
actions: {
- navigate() {
- return navigate( window.location, {
+ *navigate() {
+ const { actions } = yield import(
+ "@wordpress/interactivity-router"
+ );
+ return actions.navigate( window.location, {
force: true,
html,
} );
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js
index cdbcb5b5973046..ed51a4bd2e76e0 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-key/view.js
@@ -1,7 +1,7 @@
/**
* WordPress dependencies
*/
-import { store, navigate } from '@wordpress/interactivity';
+import { store } from '@wordpress/interactivity';
const html = `
+
+## Unreleased
+
+### Breaking changes
+
+- Initial version. ([57924](https://github.com/WordPress/gutenberg/pull/57924))
diff --git a/packages/interactivity-router/README.md b/packages/interactivity-router/README.md
new file mode 100644
index 00000000000000..94b88e80886c90
--- /dev/null
+++ b/packages/interactivity-router/README.md
@@ -0,0 +1,76 @@
+# Interactivity Router
+
+> **Note**
+> This package is a extension of the API shared at [Proposal: The Interactivity API – A better developer experience in building interactive blocks](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/). As part of an [Open Source project](https://developer.wordpress.org/block-editor/getting-started/faq/#the-gutenberg-project) we encourage participation in helping shape this API and the [discussions in GitHub](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) is the best place to engage.
+
+This package defines an Interactivity API store with the `core/router` namespace, exposing state and actions like `navigate` and `prefetch` to handle client-side navigations.
+
+## Usage
+
+The package is intended to be imported dynamically in the `view.js` files of interactive blocks.
+
+```js
+import { store } from '@wordpress/interactivity';
+
+store( 'myblock', {
+ actions: {
+ *navigate( e ) {
+ e.preventDefault();
+ const { actions } = yield import(
+ '@wordpress/interactivity-router'
+ );
+ yield actions.navigate( e.target.href );
+ },
+ },
+} );
+```
+
+## Frequently Asked Questions
+
+At this point, some of the questions you have about the Interactivity API may be:
+
+### What is this?
+
+This is the base of a new standard to create interactive blocks. Read [the proposal](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/) to learn more about this.
+
+### Can I use it?
+
+You can test it, but it's still very experimental.
+
+### How do I get started?
+
+The best place to start with the Interactivity API is this [**Getting started guide**](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/1-getting-started.md). There you'll will find a very quick start guide and the current requirements of the Interactivity API.
+
+### Where can I ask questions?
+
+The [“Interactivity API” category](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) in Gutenberg repo discussions is the best place to ask questions about the Interactivity API.
+
+### Where can I share my feedback about the API?
+
+The [“Interactivity API” category](https://github.com/WordPress/gutenberg/discussions/categories/interactivity-api) in Gutenberg repo discussions is also the best place to share your feedback about the Interactivity API.
+
+## Installation
+
+Install the module:
+
+```bash
+npm install @wordpress/interactivity --save
+```
+
+_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._
+
+## Docs & Examples
+
+**[Interactivity API Documentation](https://github.com/WordPress/gutenberg/tree/trunk/packages/interactivity/docs)** is the best place to learn about this proposal. Although it's still in progress, some key pages are already available:
+
+- **[Getting Started Guide](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/1-getting-started.md)**: Follow this Getting Started guide to learn how to scaffold a new project and create your first interactive blocks.
+- **[API Reference](https://github.com/WordPress/gutenberg/blob/trunk/packages/interactivity/docs/2-api-reference.md)**: Check this page for technical detailed explanations and examples of the directives and the store.
+
+Here you have some more resources to learn/read more about the Interactivity API:
+
+- **[Interactivity API Discussions](https://github.com/WordPress/gutenberg/discussions/52882)**
+- [Proposal: The Interactivity API – A better developer experience in building interactive blocks](https://make.wordpress.org/core/2023/03/30/proposal-the-interactivity-api-a-better-developer-experience-in-building-interactive-blocks/)
+- Developer Hours sessions ([Americas](https://www.youtube.com/watch?v=RXNoyP2ZiS8&t=664s) & [APAC/EMEA](https://www.youtube.com/watch?v=6ghbrhyAcvA))
+- [wpmovies.dev](http://wpmovies.dev/) demo and its [wp-movies-demo](https://github.com/WordPress/wp-movies-demo) repo
+
+
diff --git a/packages/interactivity-router/package.json b/packages/interactivity-router/package.json
new file mode 100644
index 00000000000000..9afb103676c6b3
--- /dev/null
+++ b/packages/interactivity-router/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@wordpress/interactivity-router",
+ "version": "0.1.0",
+ "description": "Package that exposes state and actions from the `core/router` store, part of the Interactivity API.",
+ "author": "The WordPress Contributors",
+ "license": "GPL-2.0-or-later",
+ "keywords": [
+ "wordpress",
+ "gutenberg",
+ "interactivity"
+ ],
+ "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/interactivity-router/README.md",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/WordPress/gutenberg.git",
+ "directory": "packages/interactivity-router"
+ },
+ "bugs": {
+ "url": "https://github.com/WordPress/gutenberg/labels/%5BFeature%5D%20Interactivity%20API"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "main": "build/index.js",
+ "module": "build-module/index.js",
+ "react-native": "src/index",
+ "types": "build-types",
+ "dependencies": {
+ "@wordpress/interactivity": "file:../interactivity"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/interactivity-router/src/index.js b/packages/interactivity-router/src/index.js
new file mode 100644
index 00000000000000..22637a4602d474
--- /dev/null
+++ b/packages/interactivity-router/src/index.js
@@ -0,0 +1,160 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ render,
+ directivePrefix,
+ toVdom,
+ getRegionRootFragment,
+ store,
+} from '@wordpress/interactivity';
+
+// The cache of visited and prefetched pages.
+const pages = new Map();
+
+// Helper to remove domain and hash from the URL. We are only interesting in
+// caching the path and the query.
+const cleanUrl = ( url ) => {
+ const u = new URL( url, window.location );
+ return u.pathname + u.search;
+};
+
+// Fetch a new page and convert it to a static virtual DOM.
+const fetchPage = async ( url, { html } ) => {
+ try {
+ if ( ! html ) {
+ const res = await window.fetch( url );
+ if ( res.status !== 200 ) return false;
+ html = await res.text();
+ }
+ const dom = new window.DOMParser().parseFromString( html, 'text/html' );
+ return regionsToVdom( dom );
+ } catch ( e ) {
+ return false;
+ }
+};
+
+// Return an object with VDOM trees of those HTML regions marked with a
+// `navigation-id` directive.
+const regionsToVdom = ( dom ) => {
+ const regions = {};
+ const attrName = `data-${ directivePrefix }-navigation-id`;
+ dom.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
+ const id = region.getAttribute( attrName );
+ regions[ id ] = toVdom( region );
+ } );
+ const title = dom.querySelector( 'title' )?.innerText;
+ return { regions, title };
+};
+
+// Render all interactive regions contained in the given page.
+const renderRegions = ( page ) => {
+ const attrName = `data-${ directivePrefix }-navigation-id`;
+ document.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
+ const id = region.getAttribute( attrName );
+ const fragment = getRegionRootFragment( region );
+ render( page.regions[ id ], fragment );
+ } );
+ if ( page.title ) {
+ document.title = page.title;
+ }
+};
+
+// Variable to store the current navigation.
+let navigatingTo = '';
+
+// Listen to the back and forward buttons and restore the page if it's in the
+// cache.
+window.addEventListener( 'popstate', async () => {
+ const url = cleanUrl( window.location ); // Remove hash.
+ const page = pages.has( url ) && ( await pages.get( url ) );
+ if ( page ) {
+ renderRegions( page );
+ } else {
+ window.location.reload();
+ }
+} );
+
+// Cache the current regions.
+pages.set(
+ cleanUrl( window.location ),
+ Promise.resolve( regionsToVdom( document ) )
+);
+
+export const { state, actions } = store( 'core/router', {
+ actions: {
+ /**
+ * Navigates to the specified page.
+ *
+ * This function normalizes the passed href, fetchs the page HTML if
+ * needed, and updates any interactive regions whose contents have
+ * changed. It also creates a new entry in the browser session history.
+ *
+ * @param {string} href The page href.
+ * @param {Object} [options] Options object.
+ * @param {boolean} [options.force] If true, it forces re-fetching the
+ * URL.
+ * @param {string} [options.html] HTML string to be used instead of
+ * fetching the requested URL.
+ * @param {boolean} [options.replace] If true, it replaces the current
+ * entry in the browser session
+ * history.
+ * @param {number} [options.timeout] Time until the navigation is
+ * aborted, in milliseconds. Default
+ * is 10000.
+ *
+ * @return {Promise} Promise that resolves once the navigation is
+ * completed or aborted.
+ */
+ *navigate( href, options = {} ) {
+ const url = cleanUrl( href );
+ navigatingTo = href;
+ actions.prefetch( url, options );
+
+ // Create a promise that resolves when the specified timeout ends.
+ // The timeout value is 10 seconds by default.
+ const timeoutPromise = new Promise( ( resolve ) =>
+ setTimeout( resolve, options.timeout ?? 10000 )
+ );
+
+ const page = yield Promise.race( [
+ pages.get( url ),
+ timeoutPromise,
+ ] );
+
+ // 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'
+ ]( {}, '', href );
+ } else {
+ window.location.assign( href );
+ yield new Promise( () => {} );
+ }
+ },
+
+ /**
+ * Prefetchs the page with the passed URL.
+ *
+ * The function normalizes the URL and stores internally the fetch
+ * promise, to avoid triggering a second fetch for an ongoing request.
+ *
+ * @param {string} url The page URL.
+ * @param {Object} [options] Options object.
+ * @param {boolean} [options.force] Force fetching the URL again.
+ * @param {string} [options.html] HTML string to be used instead of
+ * fetching the requested URL.
+ */
+ prefetch( url, options = {} ) {
+ url = cleanUrl( url );
+ if ( options.force || ! pages.has( url ) ) {
+ pages.set( url, fetchPage( url, options ) );
+ }
+ },
+ },
+} );
diff --git a/packages/interactivity-router/tsconfig.json b/packages/interactivity-router/tsconfig.json
new file mode 100644
index 00000000000000..9008be9879c07f
--- /dev/null
+++ b/packages/interactivity-router/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "declarationDir": "build-types",
+ "checkJs": false,
+ "strict": false
+ },
+ "references": [ { "path": "../interactivity" } ],
+ "include": [ "src/**/*" ]
+}
diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md
index 252b200f9d4d01..61f76482090f98 100644
--- a/packages/interactivity/CHANGELOG.md
+++ b/packages/interactivity/CHANGELOG.md
@@ -19,6 +19,7 @@
- Remove `data-wp-slot` and `data-wp-fill`. ([#57854](https://github.com/WordPress/gutenberg/pull/57854))
- Remove `wp-data-navigation-link` directive. ([#57853](https://github.com/WordPress/gutenberg/pull/57853))
- Remove unused `state` and rename `props` to `attributes` in `getElement()`. ([#57974](https://github.com/WordPress/gutenberg/pull/57974))
+- Convert `navigate` and `prefetch` function to actions of the new `core/router` store, available when importing the `@wordpress/interactivity-router` module. ([#57924](https://github.com/WordPress/gutenberg/pull/57924))
### Bug Fix
diff --git a/packages/interactivity/src/index.js b/packages/interactivity/src/index.js
index 0864291455310d..5d9165dc9920ee 100644
--- a/packages/interactivity/src/index.js
+++ b/packages/interactivity/src/index.js
@@ -2,11 +2,10 @@
* Internal dependencies
*/
import registerDirectives from './directives';
-import { init } from './router';
+import { init } from './init';
export { store } from './store';
export { directive, getContext, getElement, getNamespace } from './hooks';
-export { navigate, prefetch } from './router';
export {
withScope,
useWatch,
@@ -16,8 +15,11 @@ export {
useCallback,
useMemo,
} from './utils';
+export { directivePrefix } from './constants';
+export { toVdom } from './vdom';
+export { getRegionRootFragment } from './init';
-export { h as createElement, cloneElement } from 'preact';
+export { h as createElement, cloneElement, render } from 'preact';
export { useContext, useState, useRef } from 'preact/hooks';
export { deepSignal } from 'deepsignal';
diff --git a/packages/interactivity/src/init.js b/packages/interactivity/src/init.js
new file mode 100644
index 00000000000000..d749003b86f49d
--- /dev/null
+++ b/packages/interactivity/src/init.js
@@ -0,0 +1,35 @@
+/**
+ * External dependencies
+ */
+import { hydrate } from 'preact';
+/**
+ * Internal dependencies
+ */
+import { toVdom, hydratedIslands } from './vdom';
+import { createRootFragment } from './utils';
+import { directivePrefix } from './constants';
+
+// Keep the same root fragment for each interactive region node.
+const regionRootFragments = new WeakMap();
+export const getRegionRootFragment = ( region ) => {
+ if ( ! regionRootFragments.has( region ) ) {
+ regionRootFragments.set(
+ region,
+ createRootFragment( region.parentElement, region )
+ );
+ }
+ return regionRootFragments.get( region );
+};
+
+// Initialize the router with the initial DOM.
+export const init = async () => {
+ document
+ .querySelectorAll( `[data-${ directivePrefix }-interactive]` )
+ .forEach( ( node ) => {
+ if ( ! hydratedIslands.has( node ) ) {
+ const fragment = getRegionRootFragment( node );
+ const vdom = toVdom( node );
+ hydrate( vdom, fragment );
+ }
+ } );
+};
diff --git a/packages/interactivity/src/router.js b/packages/interactivity/src/router.js
deleted file mode 100644
index 1082d43ff3a6a6..00000000000000
--- a/packages/interactivity/src/router.js
+++ /dev/null
@@ -1,177 +0,0 @@
-/**
- * External dependencies
- */
-import { hydrate, render } from 'preact';
-/**
- * Internal dependencies
- */
-import { toVdom, hydratedIslands } from './vdom';
-import { createRootFragment } from './utils';
-import { directivePrefix } from './constants';
-
-// The cache of visited and prefetched pages.
-const pages = new Map();
-
-// Keep the same root fragment for each interactive region node.
-const regionRootFragments = new WeakMap();
-const getRegionRootFragment = ( region ) => {
- if ( ! regionRootFragments.has( region ) ) {
- regionRootFragments.set(
- region,
- createRootFragment( region.parentElement, region )
- );
- }
- return regionRootFragments.get( region );
-};
-
-// Helper to remove domain and hash from the URL. We are only interesting in
-// caching the path and the query.
-const cleanUrl = ( url ) => {
- const u = new URL( url, window.location );
- return u.pathname + u.search;
-};
-
-// Fetch a new page and convert it to a static virtual DOM.
-const fetchPage = async ( url, { html } ) => {
- try {
- if ( ! html ) {
- const res = await window.fetch( url );
- if ( res.status !== 200 ) return false;
- html = await res.text();
- }
- const dom = new window.DOMParser().parseFromString( html, 'text/html' );
- return regionsToVdom( dom );
- } catch ( e ) {
- return false;
- }
-};
-
-// Return an object with VDOM trees of those HTML regions marked with a
-// `navigation-id` directive.
-const regionsToVdom = ( dom ) => {
- const regions = {};
- const attrName = `data-${ directivePrefix }-navigation-id`;
- dom.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
- const id = region.getAttribute( attrName );
- regions[ id ] = toVdom( region );
- } );
- const title = dom.querySelector( 'title' )?.innerText;
- return { regions, title };
-};
-
-/**
- * Prefetchs the page with the passed URL.
- *
- * The function normalizes the URL and stores internally the fetch promise, to
- * avoid triggering a second fetch for an ongoing request.
- *
- * @param {string} url The page URL.
- * @param {Object} [options] Options object.
- * @param {boolean} [options.force] Force fetching the URL again.
- * @param {string} [options.html] HTML string to be used instead of fetching
- * the requested URL.
- */
-export const prefetch = ( url, options = {} ) => {
- url = cleanUrl( url );
- if ( options.force || ! pages.has( url ) ) {
- pages.set( url, fetchPage( url, options ) );
- }
-};
-
-// Render all interactive regions contained in the given page.
-const renderRegions = ( page ) => {
- const attrName = `data-${ directivePrefix }-navigation-id`;
- document.querySelectorAll( `[${ attrName }]` ).forEach( ( region ) => {
- const id = region.getAttribute( attrName );
- const fragment = getRegionRootFragment( region );
- render( page.regions[ id ], fragment );
- } );
- if ( page.title ) {
- document.title = page.title;
- }
-};
-
-// Variable to store the current navigation.
-let navigatingTo = '';
-
-/**
- * Navigates to the specified page.
- *
- * This function normalizes the passed href, fetchs the page HTML if needed, and
- * updates any interactive regions whose contents have changed. It also creates
- * a new entry in the browser session history.
- *
- * @param {string} href The page href.
- * @param {Object} [options] Options object.
- * @param {boolean} [options.force] If true, it forces re-fetching the URL.
- * @param {string} [options.html] HTML string to be used instead of fetching
- * the requested URL.
- * @param {boolean} [options.replace] If true, it replaces the current entry in
- * the browser session history.
- * @param {number} [options.timeout] Time until the navigation is aborted, in
- * milliseconds. Default is 10000.
- *
- * @return {Promise} Promise that resolves once the navigation is completed or
- * aborted.
- */
-export const navigate = async ( href, options = {} ) => {
- const url = cleanUrl( href );
- navigatingTo = href;
- prefetch( url, options );
-
- // Create a promise that resolves when the specified timeout ends. The
- // timeout value is 10 seconds by default.
- const timeoutPromise = new Promise( ( resolve ) =>
- setTimeout( resolve, options.timeout ?? 10000 )
- );
-
- const page = await Promise.race( [ pages.get( url ), timeoutPromise ] );
-
- // 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' ](
- {},
- '',
- href
- );
- } else {
- window.location.assign( href );
- await new Promise( () => {} );
- }
-};
-
-// Listen to the back and forward buttons and restore the page if it's in the
-// cache.
-window.addEventListener( 'popstate', async () => {
- const url = cleanUrl( window.location ); // Remove hash.
- const page = pages.has( url ) && ( await pages.get( url ) );
- if ( page ) {
- renderRegions( page );
- } else {
- window.location.reload();
- }
-} );
-
-// Initialize the router with the initial DOM.
-export const init = async () => {
- document
- .querySelectorAll( `[data-${ directivePrefix }-interactive]` )
- .forEach( ( node ) => {
- if ( ! hydratedIslands.has( node ) ) {
- const fragment = getRegionRootFragment( node );
- const vdom = toVdom( node );
- hydrate( vdom, fragment );
- }
- } );
-
- // Cache the current regions.
- pages.set(
- cleanUrl( window.location ),
- Promise.resolve( regionsToVdom( document ) )
- );
-};
diff --git a/tools/webpack/interactivity.js b/tools/webpack/interactivity.js
index 03bb1f576cb783..5dd9192c855661 100644
--- a/tools/webpack/interactivity.js
+++ b/tools/webpack/interactivity.js
@@ -3,6 +3,10 @@
*/
const { join } = require( 'path' );
const CopyWebpackPlugin = require( 'copy-webpack-plugin' );
+/**
+ * WordPress dependencies
+ */
+const DependencyExtractionWebpackPlugin = require( '@wordpress/dependency-extraction-webpack-plugin' );
/**
* Internal dependencies
@@ -14,6 +18,7 @@ module.exports = {
name: 'interactivity',
entry: {
index: `./packages/interactivity/src/index.js`,
+ router: `./packages/interactivity-router/src/index.js`,
navigation: './packages/block-library/src/navigation/view.js',
query: './packages/block-library/src/query/view.js',
image: './packages/block-library/src/image/view.js',
@@ -31,10 +36,8 @@ module.exports = {
},
path: join( __dirname, '..', '..' ),
environment: { module: true },
- },
- externalsType: 'module',
- externals: {
- '@wordpress/interactivity': '@wordpress/interactivity',
+ module: true,
+ chunkFormat: 'module',
},
resolve: {
extensions: [ '.js', '.ts', '.tsx' ],
@@ -79,6 +82,7 @@ module.exports = {
},
],
} ),
+ new DependencyExtractionWebpackPlugin(),
],
watchOptions: {
ignored: [ '**/node_modules' ],
diff --git a/tsconfig.json b/tsconfig.json
index d05e883ed70b03..584ac01cb60c71 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -28,6 +28,7 @@
{ "path": "packages/i18n" },
{ "path": "packages/icons" },
{ "path": "packages/interactivity" },
+ { "path": "packages/interactivity-router" },
{ "path": "packages/is-shallow-equal" },
{ "path": "packages/keycodes" },
{ "path": "packages/lazy-import" },