Skip to content

iAPI Router: Add support for new router regions with attachTo #70421

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Jun 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,85 @@
</p>
</div>
</div>

<div id="regions-with-attach-to" data-testid="regions-with-attach-to">
<?php
/*
* Set of router regions with the `attachTo` property specified,
* as defined in the `regionsWithAttachTo` attribute.
*
* Each object inside such an attribute have the following properties:
* - `type`: the type of the HTML element where the `data-wp-router-region` directive is defined, e.g. 'div'.
* - `data`: the data passed to the `data-wp-router-region` directive, i.e., `id` and `attachTo`.
* - `hasDirectives`: a boolean indicating that the top element of the router region have actual directives that
* make the element to be wrapped in a `Directives` component.
*/
foreach ( $attributes['regionsWithAttachTo'] ?? array() as $region ) {
$region_type = esc_attr( $region['type'] );
$region_id = esc_attr( $region['data']['id'] );
$region_data = wp_json_encode( $region['data'] );
$has_directives = isset( $region['hasDirectives'] )
? ' data-wp-init="callbacks.init"'
: '';
$context_data = wp_interactivity_data_wp_context(
array(
'text' => $region['data']['id'],
'counter' => array(
'value' => $attributes['counter'] ?? 0,
'serverValue' => $attributes['counter'] ?? 0,
),
)
);

$html = <<<HTML
<$region_type
data-wp-interactive="router-regions"
data-wp-router-region='$region_data'
data-testid="$region_id"
$has_directives
>
<div $context_data>
<h2>Region with <code>attachTo</code></h2>
<p
data-testid="text"
data-wp-text="context.text"
>not hydrated</p>

<p> Client value:
<button
data-testid="client-counter"
data-wp-text="context.counter.value"
data-wp-on--click="actions.counter.increment"
>
NaN
</button>
</p>
<p> Server value:
<output
data-testid="server-counter"
data-wp-text="context.counter.serverValue"
data-wp-watch="actions.counter.updateCounterFromServer"
>
NaN
</output>
</p>
</div>
</$region_type>
HTML;

echo $html;
}
?>
</div>

<!--
Count of times the `actions.init` function has been executed.
Used to verify that `data-wp-init` works on regions with `attachTo`.
-->
<div
data-wp-interactive="router-regions"
data-testid="init-count"
data-wp-text="state.initCount"
>
NaN
</div>
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
/**
* WordPress dependencies
*/
import { store, getContext, withSyncEvent } from '@wordpress/interactivity';
import {
store,
getContext,
getServerContext,
withSyncEvent,
} from '@wordpress/interactivity';

const { state } = store( 'router-regions', {
state: {
Expand All @@ -15,6 +20,7 @@ const { state } = store( 'router-regions', {
value: 0,
},
items: [ 'item 1', 'item 2', 'item 3' ],
initCount: 0,
},
actions: {
router: {
Expand Down Expand Up @@ -44,9 +50,19 @@ const { state } = store( 'router-regions', {
context.counter.value = context.counter.initialValue;
}
},
updateCounterFromServer() {
const context = getContext();
const serverContext = getServerContext();
context.counter.serverValue = serverContext.counter.serverValue;
},
},
addItem() {
state.items.push( `item ${ state.items.length + 1 }` );
},
},
callbacks: {
init() {
state.initCount += 1;
},
},
} );
4 changes: 4 additions & 0 deletions packages/interactivity-router/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### New Features

- Add support for new router regions with `attachTo` from new pages. ([70421](https://github.com/WordPress/gutenberg/pull/70421))

### Enhancements

- Export `NavigateOptions` and `PrefetchOptions` types. ([#70315](https://github.com/WordPress/gutenberg/pull/70315))
Expand Down
22 changes: 22 additions & 0 deletions packages/interactivity-router/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ const { state, actions } = store( 'core/router', {

It defines a region that is updated on navigation. It requires a unique ID as the value and can only be used in root interactive elements, i.e., elements with `data-wp-interactive` that are not nested inside other elements with `data-wp-interactive`.

The value can be a string with the region ID, or a JSON object containing the `id` and an optional `attachTo` property.

Example:

```html
Expand All @@ -85,6 +87,26 @@ Example:
</div>
```

The `attachTo` property is a CSS selector that points to the parent element where the new router region should be rendered. This is useful for regions that may not exist on the initial page but are present on subsequent pages, like a modal or an overlay.

When navigating between pages:

- If a region exists on both the current and the new page, its content is updated. `attachTo` is ignored in this case.
- If a region without `attachTo` exists on the new page but not on the current one, it is not added to the DOM.
- If a region with `attachTo` exists on the new page but not on the current one, it is created and appended to the parent element specified in `attachTo`.
- If a region exists on the current page but not on the new one, it is removed from the DOM. `attachTo` is ignored in this case.

Example with `attachTo`:

```html
<div
data-wp-interactive="myblock"
data-wp-router-region='{ "id": "myblock/overlay", "attachTo": "body" }'
>
I'm in a new region!
</div>
```

### Actions

#### `navigate`
Expand Down
58 changes: 55 additions & 3 deletions packages/interactivity-router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ interface VdomParams {
interface Page {
url: string;
regions: Record< string, any >;
regionsToAttach: Record< string, string >;
styles: StyleElement[];
scriptModules: ScriptModuleLoad[];
title: string;
Expand All @@ -74,6 +75,22 @@ const getPagePath = ( url: string ) => {
return u.pathname + u.search;
};

/**
* Parses the given region's directive.
*
* @param region Region element.
* @return Data contained in the region directive value.
*/
const parseRegionAttribute = ( region: Element ) => {
const value = region.getAttribute( regionAttr );
try {
const { id, attachTo } = JSON.parse( value );
return { id, attachTo };
} catch ( e ) {
return { id: value };
}
};

/**
* Fetches and prepares a page from a given URL.
*
Expand Down Expand Up @@ -122,11 +139,15 @@ const fetchPage = async ( url: string, { html }: { html: string } ) => {
*/
const preparePage: PreparePage = async ( url, dom, { vdom } = {} ) => {
const regions = {};
const regionsToAttach = {};
dom.querySelectorAll( regionsSelector ).forEach( ( region ) => {
const id = region.getAttribute( regionAttr );
const { id, attachTo } = parseRegionAttribute( region );
regions[ id ] = vdom?.has( region )
? vdom.get( region )
: toVdom( region );
if ( attachTo ) {
regionsToAttach[ id ] = attachTo;
}
} );

const title = dom.querySelector( 'title' )?.innerText;
Expand All @@ -138,7 +159,15 @@ const preparePage: PreparePage = async ( url, dom, { vdom } = {} ) => {
Promise.all( preloadScriptModules( dom ) ),
] );

return { regions, styles, scriptModules, title, initialData, url };
return {
regions,
regionsToAttach,
styles,
scriptModules,
title,
initialData,
url,
};
};

/**
Expand All @@ -150,13 +179,36 @@ const preparePage: PreparePage = async ( url, dom, { vdom } = {} ) => {
const renderPage = ( page: Page ) => {
applyStyles( page.styles );

// Clone regionsToAttach.
const regionsToAttach = { ...page.regionsToAttach };

batch( () => {
populateServerData( page.initialData );
document.querySelectorAll( regionsSelector ).forEach( ( region ) => {
const id = region.getAttribute( regionAttr );
const { id } = parseRegionAttribute( region );
const fragment = getRegionRootFragment( region );
render( page.regions[ id ], fragment );
// If this is an attached region, remove it from the list.
delete regionsToAttach[ id ];
} );

// Render unattached regions.
for ( const id in regionsToAttach ) {
const parent = document.querySelector( regionsToAttach[ id ] );

// Get the type from the vnode. If wrapped with Directives, get the
// original type from `props.type`.
const { props, type } = page.regions[ id ];
const elementType = typeof type === 'function' ? props.type : type;

// Create an element with the obtained type where the region will be
// rendered. The type should match the one of the root vnode.
const region = document.createElement( elementType );
parent.appendChild( region );

const fragment = getRegionRootFragment( region );
render( page.regions[ id ], fragment );
}
} );

if ( page.title ) {
Expand Down
Loading
Loading