Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Commit

Permalink
Serialize the Interactivity API's store from PHP and hydrate it on th…
Browse files Browse the repository at this point in the history
…e client (#8447)

* Update Interactivity API

* Change `wp` prefixes to `woo`

* Use `woo` prefix for the directives runtime bundle

* Update Interactivity API runtime

* Hardcode php from interactivity API

* Temporarily add gutenberg plugin as dependency

* Exclude Interactivity API files from phpcs checks

* Update Interactivity API js files

* Update Interactivity API php files

* Remove gutenberg from wp-env plugins

* Fix registered runtime paths

* Fix prefixes when getting attributes in directives

* Fix directive prefix in constants

* Avoid a Fatal error when importing `wp-html`

* Remove TODO comments from Interactivity API files

* Add missing prefix to some global functions

* Use true as value for boolean attributes

* Add `wp-html` file

* Change requires in `wp-html` with includes
  • Loading branch information
DAreRodz authored Feb 28, 2023
1 parent b73fbca commit 72ed66a
Show file tree
Hide file tree
Showing 20 changed files with 556 additions and 73 deletions.
2 changes: 1 addition & 1 deletion assets/js/interactivity/constants.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export const cstMetaTagItemprop = 'woo-client-side-transitions';
export const csnMetaTagItemprop = 'woo-client-side-navigation';
export const componentPrefix = 'woo-';
export const directivePrefix = 'data-woo-';
32 changes: 16 additions & 16 deletions assets/js/interactivity/directives.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import { useContext, useMemo, useEffect } from 'preact/hooks';
import { useSignalEffect } from '@preact/signals';
import { deepSignal, peek } from 'deepsignal';
import { directive } from './hooks';
import { prefetch, navigate, hasClientSideTransitions } from './router';
import { prefetch, navigate, canDoClientSideNavigation } from './router';

// Until useSignalEffects is fixed:
// https://github.com/preactjs/signals/issues/228
const raf = window.requestAnimationFrame;
const tick = () => new Promise( ( r ) => raf( () => raf( r ) ) );

// Check if current page has client-side transitions enabled.
const clientSideTransitions = hasClientSideTransitions( document.head );
// Check if current page can do client-side navigation.
const clientSideNavigation = canDoClientSideNavigation( document.head );

const isObject = ( item ) =>
item && typeof item === 'object' && ! Array.isArray( item );
Expand Down Expand Up @@ -91,19 +91,19 @@ export default () => {
className: name,
context: contextValue,
} );
const currentClass = element.props.class || '';
const classFinder = new RegExp(
`(^|\\s)${ name }(\\s|$)`,
'g'
);
if ( ! result )
element.props.class = element.props.class
.replace(
new RegExp( `(^|\\s)${ name }(\\s|$)`, 'g' ),
' '
)
element.props.class = currentClass
.replace( classFinder, ' ' )
.trim();
else if (
! new RegExp( `(^|\\s)${ name }(\\s|$)` ).test(
element.props.class
)
)
element.props.class += ` ${ name }`;
else if ( ! classFinder.test( currentClass ) )
element.props.class = currentClass
? `${ currentClass } ${ name }`
: name;

useEffect( () => {
// This seems necessary because Preact doesn't change the class names
Expand Down Expand Up @@ -146,13 +146,13 @@ export default () => {
} ) => {
useEffect( () => {
// Prefetch the page if it is in the directive options.
if ( clientSideTransitions && link?.prefetch ) {
if ( clientSideNavigation && link?.prefetch ) {
prefetch( href );
}
} );

// Don't do anything if it's falsy.
if ( clientSideTransitions && link !== false ) {
if ( clientSideNavigation && link !== false ) {
element.props.onclick = async ( event ) => {
event.preventDefault();

Expand Down
4 changes: 2 additions & 2 deletions assets/js/interactivity/hooks.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { h, options, createContext } from 'preact';
import { useRef } from 'preact/hooks';
import { store } from './wpx';
import { rawStore as store } from './store';
import { componentPrefix } from './constants';

// Main context.
Expand All @@ -18,7 +18,7 @@ export const component = ( name, Comp ) => {
componentMap[ name ] = Comp;
};

// Resolve the path to some property of the wpx object.
// Resolve the path to some property of the store object.
const resolve = ( path, context ) => {
let current = { ...store, context };
path.split( '.' ).forEach( ( p ) => ( current = current[ p ] ) );
Expand Down
1 change: 1 addition & 0 deletions assets/js/interactivity/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import registerDirectives from './directives';
import registerComponents from './components';
import { init } from './router';
export { store } from './store';

/**
* Initialize the initial vDOM.
Expand Down
12 changes: 6 additions & 6 deletions assets/js/interactivity/router.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { hydrate, render } from 'preact';
import { toVdom, hydratedIslands } from './vdom';
import { createRootFragment } from './utils';
import { cstMetaTagItemprop, directivePrefix } from './constants';
import { csnMetaTagItemprop, directivePrefix } from './constants';

// The root to render the vdom (document.body).
let rootFragment;
Expand All @@ -17,10 +17,10 @@ const cleanUrl = ( url ) => {
return u.pathname + u.search;
};

// Helper to check if a page has client-side transitions activated.
export const hasClientSideTransitions = ( dom ) =>
// Helper to check if a page can do client-side navigation.
export const canDoClientSideNavigation = ( dom ) =>
dom
.querySelector( `meta[itemprop='${ cstMetaTagItemprop }']` )
.querySelector( `meta[itemprop='${ csnMetaTagItemprop }']` )
?.getAttribute( 'content' ) === 'active';

// Fetch styles of a new page.
Expand Down Expand Up @@ -55,7 +55,7 @@ const fetchHead = async ( head ) => {
const fetchPage = async ( url ) => {
const html = await window.fetch( url ).then( ( r ) => r.text() );
const dom = new window.DOMParser().parseFromString( html, 'text/html' );
if ( ! hasClientSideTransitions( dom.head ) ) return false;
if ( ! canDoClientSideNavigation( dom.head ) ) return false;
const head = await fetchHead( dom.head );
return { head, body: toVdom( dom.body ) };
};
Expand Down Expand Up @@ -98,7 +98,7 @@ window.addEventListener( 'popstate', async () => {

// Initialize the router with the initial DOM.
export const init = async () => {
if ( hasClientSideTransitions( document.head ) ) {
if ( canDoClientSideNavigation( document.head ) ) {
// Create the root fragment to hydrate everything.
rootFragment = createRootFragment(
document.documentElement,
Expand Down
42 changes: 42 additions & 0 deletions assets/js/interactivity/store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { deepSignal } from 'deepsignal';

const isObject = ( item ) =>
item && typeof item === 'object' && ! Array.isArray( item );

export const deepMerge = ( target, source ) => {
if ( isObject( target ) && isObject( source ) ) {
for ( const key in source ) {
if ( isObject( source[ key ] ) ) {
if ( ! target[ key ] ) Object.assign( target, { [ key ]: {} } );
deepMerge( target[ key ], source[ key ] );
} else {
Object.assign( target, { [ key ]: source[ key ] } );
}
}
}
};

const getSerializedState = () => {
const storeTag = document.querySelector(
`script[type="application/json"]#store`
);
if ( ! storeTag ) return {};
try {
const { state } = JSON.parse( storeTag.textContent );
if ( isObject( state ) ) return state;
throw Error( 'Parsed state is not an object' );
} catch ( e ) {
console.log( e );
}
return {};
};

const rawState = getSerializedState();
export const rawStore = { state: deepSignal( rawState ) };

if ( typeof window !== 'undefined' ) window.store = rawStore;

export const store = ( { state, ...block } ) => {
deepMerge( rawStore, block );
deepMerge( rawState, state );
};
27 changes: 0 additions & 27 deletions assets/js/interactivity/wpx.js

This file was deleted.

3 changes: 3 additions & 0 deletions phpcs.xml
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,7 @@
<rule ref="Generic.Arrays.DisallowShortArraySyntax.Found">
<exclude-pattern>src/*</exclude-pattern>
</rule>

<!-- Exclude Interactivity API-->
<exclude-pattern>./src/Interactivity/*</exclude-pattern>
</ruleset>
22 changes: 22 additions & 0 deletions src/Interactivity/directives/attributes/woo-bind.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

require_once __DIR__ . '/../utils.php';

function process_woo_bind( $tags, $context ) {
if ( $tags->is_tag_closer() ) {
return;
}

$prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-woo-bind:' );

foreach ( $prefixed_attributes as $attr ) {
list( , $bound_attr ) = explode( ':', $attr );
if ( empty( $bound_attr ) ) {
continue;
}

$expr = $tags->get_attribute( $attr );
$value = woo_directives_evaluate( $expr, $context->get_context() );
$tags->set_attribute( $bound_attr, $value );
}
}
26 changes: 26 additions & 0 deletions src/Interactivity/directives/attributes/woo-class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

require_once __DIR__ . '/../utils.php';

function process_woo_class( $tags, $context ) {
if ( $tags->is_tag_closer() ) {
return;
}

$prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-woo-class:' );

foreach ( $prefixed_attributes as $attr ) {
list( , $class_name ) = explode( ':', $attr );
if ( empty( $class_name ) ) {
continue;
}

$expr = $tags->get_attribute( $attr );
$add_class = woo_directives_evaluate( $expr, $context->get_context() );
if ( $add_class ) {
$tags->add_class( $class_name );
} else {
$tags->remove_class( $class_name );
}
}
}
18 changes: 18 additions & 0 deletions src/Interactivity/directives/attributes/woo-context.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

function process_woo_context_attribute( $tags, $context ) {
if ( $tags->is_tag_closer() ) {
$context->rewind_context();
return;
}

$value = $tags->get_attribute( 'data-woo-context' );
if ( null === $value ) {
// No woo-context directive.
return;
}

$new_context = json_decode( $value, true );

$context->set_context( $new_context );
}
29 changes: 29 additions & 0 deletions src/Interactivity/directives/attributes/woo-style.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

require_once __DIR__ . '/../utils.php';

function process_woo_style( $tags, $context ) {
if ( $tags->is_tag_closer() ) {
return;
}

$prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-woo-style:' );

foreach ( $prefixed_attributes as $attr ) {
list( , $style_name ) = explode( ':', $attr );
if ( empty( $style_name ) ) {
continue;
}

$expr = $tags->get_attribute( $attr );
$style_value = woo_directives_evaluate( $expr, $context->get_context() );
if ( $style_value ) {
$style_attr = $tags->get_attribute( 'style' );
$style_attr = woo_directives_set_style( $style_attr, $style_name, $style_value );
$tags->set_attribute( 'style', $style_attr );
} else {
// $tags->remove_class( $style_name );
}
}
}

73 changes: 73 additions & 0 deletions src/Interactivity/directives/class-woo-directive-context.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php
/**
* Context data implementation.
*
* @package block-hydration-experiments
*/

/**
* This is a data structure to hold the current context.
*
* Whenever encountering a `woo-context` directive, we need to update
* the context with the data found in that directive. Conversely,
* when "leaving" that context (by encountering a closing tag), we
* need to reset the context to its previous state. This means that
* we actually need sort of a stack to keep track of all nested contexts.
*
* Example:
*
* <woo-context data='{ "foo": 123 }'>
* <!-- foo should be 123 here. -->
* <woo-context data='{ "foo": 456 }'>
* <!-- foo should be 456 here. -->
* </woo-context>
* <!-- foo should be reset to 123 here. -->
* </woo-context>
*/
class Woo_Directive_Context {
/**
* The stack used to store contexts internally.
*
* @var array An array of contexts.
*/
protected $stack = array( array() );

/**
* Constructor.
*
* Accepts a context as an argument to initialize this with.
*
* @param array $context A context.
*/
function __construct( $context = array() ) {
$this->set_context( $context );
}

/**
* Return the current context.
*
* @return array The current context.
*/
public function get_context() {
return end( $this->stack );
}

/**
* Set the current context.
*
* @param array $context The context to be set.
* @return void
*/
public function set_context( $context ) {
array_push( $this->stack, array_replace_recursive( $this->get_context(), $context ) );
}

/**
* Reset the context to its previous state.
*
* @return void
*/
public function rewind_context() {
array_pop( $this->stack );
}
}
Loading

0 comments on commit 72ed66a

Please sign in to comment.