Skip to content
Open
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 @@ -166,6 +166,14 @@
<button data-testid="async navigate" data-wp-on--click="actions.asyncNavigate">Async Navigate</button>
</div>

<!-- Count of succesfull client-side navigations -->
<div
data-testid="navigation count"
data-wp-interactive="directive-context-navigate"
data-wp-watch="callbacks.updateNavigationCount"
data-wp-text="state.navigationCount"
></div>

<div
data-wp-interactive='{"namespace": "directive-context-non-default"}'
data-wp-context--non-default='{ "text": "non default" }'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,14 @@ const html = `
<button data-testid="async navigate" data-wp-on--click="actions.asyncNavigate">Async Navigate</button>
</div>`;

const { actions } = store( 'directive-context-navigate', {
const { actions, state } = store( 'directive-context-navigate', {
state: {
get navigationCount() {
const { __navigationCount } = state;
return isNaN( __navigationCount ) ? 0 : __navigationCount;
},
__navigationCount: NaN,
},
actions: {
toggleText() {
const ctx = getContext();
Expand Down Expand Up @@ -99,6 +106,15 @@ const { actions } = store( 'directive-context-navigate', {
ctx.newText = 'changed from async action';
},
},
callbacks: {
updateNavigationCount() {
const { state: routerState } = store( 'core/router' );
if ( routerState.url && isNaN( state.__navigationCount ) ) {
state.__navigationCount = 0;
}
state.__navigationCount++;
},
},
} );

store( 'directive-context-watch', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,18 +110,54 @@
</section>

<div data-wp-interactive="router-regions">
<div data-wp-router-region="invalid-region-1">
<p data-testid="invalid-region-text-1">
content from page <?php echo $attributes['page']; ?>
</p>
</div>
<div data-wp-interactive="router-regions" data-wp-router-region="invalid-region-2">
<p data-testid="invalid-region-text-2">
<!-- Router region inside data-wp-interactive -->
<div
data-testid="valid-inside-interactive"
data-wp-interactive="router-regions"
data-wp-router-region="valid-inside-interactive"
data-wp-context='{ "counter": { "value": 0 } }'
>
<p data-testid="text-1">
content from page <?php echo $attributes['page']; ?>
</p>
<button
data-testid="valid-inside-interactive-counter"
data-wp-text="context.counter.value"
data-wp-on--click="actions.counter.increment"
>
NaN
</button>

<!-- Router region inside data-wp-router-region -->
<div
data-testid="valid-inside-router-region"
data-wp-interactive="router-regions"
data-wp-router-region="valid-inside-router-region"
data-wp-context='{ "counter": { "value": 0 } }'
>
<p data-testid="text-2">
content from page <?php echo $attributes['page']; ?>
</p>
<button
data-testid="valid-inside-router-region-counter"
data-wp-text="context.counter.value"
data-wp-on--click="actions.counter.increment"
>
NaN
</button>
</div>
</div>
</div>

<div
data-testid="invalid-outside-interactive"
data-wp-router-region="invalid-outside-interactive"
>
<p data-testid="text-3">
content from page <?php echo $attributes['page']; ?>
</p>
</div>

<div id="regions-with-attach-to" data-testid="regions-with-attach-to">
<?php
/*
Expand Down
127 changes: 104 additions & 23 deletions packages/interactivity-router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ const {
parseServerData,
populateServerData,
batch,
routerRegions,
cloneElement,
} = privateApis(
'I acknowledge that using private APIs means my theme or plugin will inevitably break in the next version of WordPress.'
);

const regionAttr = `data-${ directivePrefix }-router-region`;
const interactiveAttr = `data-${ directivePrefix }-interactive`;
const regionsSelector = `[${ interactiveAttr }][${ regionAttr }]:not([${ interactiveAttr }] [${ interactiveAttr }])`;
const regionsSelector = `[${ interactiveAttr }][${ regionAttr }], [${ interactiveAttr }] [${ interactiveAttr }][${ regionAttr }]`;

export interface NavigateOptions {
force?: boolean;
Expand Down Expand Up @@ -91,6 +93,50 @@ const parseRegionAttribute = ( region: Element ) => {
}
};

/**
* Clones the content of the router region vDOM passed as argument.
*
* The function creates a new VNode instance removing all priority levels up to
* the one containing the router-region directive, which should have evaluated
* in advance.
*
* @param vdom A router region's VNode.
* @return The VNode for the passed router region's content.
*/
const cloneRouterRegionContent = ( vdom: any ) => {
if ( ! vdom ) {
return vdom;
}
const allPriorityLevels: string[][] = vdom.props.priorityLevels;
const routerRegionLevel = allPriorityLevels.findIndex( ( level ) =>
level.includes( 'router-region' )
);
const priorityLevels =
routerRegionLevel !== -1
? allPriorityLevels.slice( routerRegionLevel + 1 )
: allPriorityLevels;

return priorityLevels.length > 0
? cloneElement( vdom, {
...vdom.props,
priorityLevels,
} )
: vdom.props.element;
};

/**
* IDs of router regions with an `attachTo` property pointing to the same parent
* element.
*/
const regionsToAttachByParent = new WeakMap< Element, string[] >();

/**
* Map of root fragments by parent element, used to render router regions with
* the `attachTo` property. Those elements with the same parent are rendered
* together in the corresponding root fragment.
*/
const rootFragmentsByParent = new WeakMap< Element, any >();

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

if ( region.parentElement.closest( `[${ regionAttr }]` ) ) {
regions[ id ] = undefined;
} else {
regions[ id ] = vdom?.has( region )
? vdom.get( region )
: toVdom( region );
}

if ( attachTo ) {
regionsToAttach[ id ] = attachTo;
}
Expand Down Expand Up @@ -187,32 +239,61 @@ const renderPage = ( page: Page ) => {
const regionsToAttach = { ...page.regionsToAttach };

batch( () => {
// Update server data.
populateServerData( page.initialData );
document.querySelectorAll( regionsSelector ).forEach( ( region ) => {
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 ];

// Reset all router regions before setting the actual values.
( routerRegions as Map< string, any > ).forEach( ( signal ) => {
signal.value = null;
} );

// Render unattached regions.
//Init regions with attachTo that don't exist yet.
const parentsToUpdate = new Set< Element >();
for ( const id in regionsToAttach ) {
const parent = document.querySelector( regionsToAttach[ id ] );
if ( ! regionsToAttachByParent.has( parent ) ) {
regionsToAttachByParent.set( parent, [] );
}
const regions = regionsToAttachByParent.get( parent );
if ( ! regions.includes( id ) ) {
regions.push( id );
parentsToUpdate.add( parent );
}
}

// 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 );
//Update all existing regions.
for ( const id in page.regions ) {
if ( routerRegions.has( id ) ) {
routerRegions.get( id ).value = cloneRouterRegionContent(
page.regions[ id ]
);
}
}

// Render regions attached to the same parent in the same fragment.
parentsToUpdate.forEach( ( parent ) => {
const ids = regionsToAttachByParent.get( parent );
const vdoms = ids.map( ( id ) => page.regions[ id ] );

if ( ! rootFragmentsByParent.has( parent ) ) {
const regions = vdoms.map( ( { props, type } ) => {
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 );
return region;
} );
rootFragmentsByParent.set(
parent,
getRegionRootFragment( regions )
);
}
const fragment = rootFragmentsByParent.get( parent );
render( vdoms, fragment );
} );
} );

if ( page.title ) {
Expand Down
52 changes: 51 additions & 1 deletion packages/interactivity/src/directives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@
/**
* External dependencies
*/
import { h as createElement, type VNode, type RefObject } from 'preact';
import {
h as createElement,
cloneElement,
type VNode,
type RefObject,
} from 'preact';
import { useContext, useMemo, useRef } from 'preact/hooks';
import { signal, type Signal } from '@preact/signals';

/**
* Internal dependencies
Expand Down Expand Up @@ -199,6 +205,20 @@ const getGlobalAsyncEventDirective = (
};
};

/**
* Relates each router region with its current vDOM content. Used by the
* `router-region` directive.
*
* Keys are router region IDs, and values are signals with the corresponding
* VNode rendered inside. If the value is `null`, that means the regions should
* not be rendered. If the value is `undefined`, the region is already contained
* inside another router region and does not need to change its children.
*/
export const routerRegions = new Map<
string,
Signal< VNode | null | undefined >
>();

export default () => {
// data-wp-context
directive(
Expand Down Expand Up @@ -718,4 +738,34 @@ export default () => {
);

directive( 'each-child', () => null, { priority: 1 } );

directive(
'router-region',
( { directives: { 'router-region': routerRegion } } ) => {
const entry = routerRegion.find( isDefaultDirectiveSuffix );
if ( ! entry ) {
return;
}

const regionId =
typeof entry.value === 'string'
? entry.value
: ( entry.value as any ).id;

if ( ! routerRegions.has( regionId ) ) {
routerRegions.set( regionId, signal() );
}

// Get the content of this router region.
const vdom = routerRegions.get( regionId )!.value;

if ( vdom && typeof vdom.type !== 'string' ) {
// The scope needs to be injected.
const previousScope = getScope();
return cloneElement( vdom, { previousScope } );
}
return vdom;
},
{ priority: 1 }
);
};
3 changes: 2 additions & 1 deletion packages/interactivity/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { batch } from '@preact/signals';
/**
* Internal dependencies
*/
import registerDirectives from './directives';
import registerDirectives, { routerRegions } from './directives';
import { init, getRegionRootFragment, initialVdom } from './init';
import { directivePrefix } from './constants';
import { toVdom } from './vdom';
Expand Down Expand Up @@ -57,6 +57,7 @@ export const privateApis = ( lock ): any => {
parseServerData,
populateServerData,
batch,
routerRegions,
};
}

Expand Down
7 changes: 5 additions & 2 deletions packages/interactivity/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@ import { directivePrefix } from './constants';

// Keep the same root fragment for each interactive region node.
const regionRootFragments = new WeakMap();
export const getRegionRootFragment = ( region: Element ): ContainerNode => {
export const getRegionRootFragment = (
regions: Element | Element[]
): ContainerNode => {
const region = Array.isArray( regions ) ? regions[ 0 ] : regions;
if ( ! region.parentElement ) {
throw Error( 'The passed region should be an element with a parent.' );
}
if ( ! regionRootFragments.has( region ) ) {
regionRootFragments.set(
region,
createRootFragment( region.parentElement, region )
createRootFragment( region.parentElement, regions )
);
}
return regionRootFragments.get( region );
Expand Down
3 changes: 3 additions & 0 deletions packages/interactivity/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,9 @@ export const createRootFragment = (
removeChild( c: Node ) {
parent.removeChild( c );
},
contains( c: Node ) {
parent.contains( c );
},
} );
};

Expand Down
Loading
Loading