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 + +

Code is Poetry.

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" },