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

Experiment: Client side navigations in the Product Query block #7187

Closed
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
61 changes: 61 additions & 0 deletions assets/js/blocks/product-query/frontend.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* External dependencies
*/
import { options } from 'preact';
import { useEffect } from 'preact/hooks';

/**
* Internal dependencies
*/
import { prefetch, navigate, directive } from './runtime';

// The `wp-client-navigation` directive.
directive( 'clientNavigation', ( props ) => {
const {
wp: { clientNavigation },
href,
} = props;
const url = href.startsWith( '/' ) ? href : window.location.pathname + href;

useEffect( () => {
// Prefetch the page if it is in the directive options.
if ( clientNavigation?.prefetch ) {
prefetch( url );
}
}, [ url ] );

// Don't do anything if it's falsy.
if ( clientNavigation !== false ) {
props.onclick = async ( event ) => {
// Stop server-side navigation.
event.preventDefault();
// Start client-side navigation.
await navigate( url, { scroll: clientNavigation?.scroll } );
};
}
} );

// Manually add the `wp-client-navigation` directive to the virtual nodes.
// TODO: Move this to the HTML once WP_HTML_Walker is available.
const clientNavigationClassNames = [
'wp-block-query-pagination-next',
'wp-block-query-pagination-previous',
'page-numbers',
];
const old = options.vnode;
options.vnode = ( vnode ) => {
if ( vnode.type === 'a' ) {
clientNavigationClassNames.forEach( ( className ) => {
if ( vnode.props.class?.includes( className ) ) {
vnode.props.wp = {
clientNavigation: {
prefetch:
className === 'page-numbers' ? false : 'eager',
scroll: false,
},
};
}
} );
}
if ( old ) old( vnode );
};
39 changes: 39 additions & 0 deletions assets/js/blocks/product-query/runtime/directives.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* External dependencies
*/
import { h, options } from 'preact';

// WordPress Directives.
const directives = {};

// Expose function to add directives.
export const directive = ( name, cb ) => {
directives[ name ] = cb;
};

const WpDirective = ( props ) => {
for ( const d in props.wp ) {
directives[ d ]?.( props );
}
props._wrapped = true;
const { wp, tag, children, ...rest } = props;
return h( tag, rest, children );
};

const old = options.vnode;

options.vnode = ( vnode ) => {
const wp = vnode.props.class;
const wrapped = vnode.props._wrapped;

if ( wp ) {
if ( ! wrapped ) {
vnode.props.tag = vnode.type;
vnode.type = WpDirective;
}
} else if ( wrapped ) {
delete vnode.props._wrapped;
}

if ( old ) old( vnode );
};
2 changes: 2 additions & 0 deletions assets/js/blocks/product-query/runtime/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { navigate, prefetch } from './router';
export { directive } from './directives';
94 changes: 94 additions & 0 deletions assets/js/blocks/product-query/runtime/router-helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* External dependencies
*/
import { hydrate, render } from 'preact';

/**
* Internal dependencies
*/
import { toVdom } from './vdom';

// Remove domain and hash from the URL. We are only interesting in the path and
// the query.
export const cleanUrl = ( url ) => {
const u = new URL( url, 'http://a.bc' );
return u.pathname + u.search;
};

// Helper to await until the CPU is idle.
export const idle = () =>
new Promise( ( resolve ) => window.requestIdleCallback( resolve ) );

// Get the id class from a Product Query element.
const getQueryId = ( query ) =>
Array.from( query.classList.values() ).find( ( className ) =>
className.startsWith( 'query-id-' )
);

// Root fragments where we will render the Product Query blocks.
const rootFragments = new Map();

// Create root fragments for each Product Query block.
export const createRootFragments = () => {
document.querySelectorAll( '.woo-product-query' ).forEach( ( query ) => {
rootFragments.set(
getQueryId( query ),
createRootFragment( query.parentElement, query )
);
} );
};

// Fetch a URL and return the HTML string.
const fetchUrl = async ( url ) => {
return await window.fetch( url ).then( ( res ) => res.text() );
};

// Parse a DOM from an HTML string.
const parseDom = ( html ) => {
return new window.DOMParser().parseFromString( html, 'text/html' );
};

// Fetch a page and return the virtual DOM of each Product Query block.
export const fetchPage = async ( url ) => {
const html = await fetchUrl( url );
const dom = parseDom( html );
return toVdoms( dom );
};

// Build a virtual DOM for each Product Query block found in a document.
export const toVdoms = ( dom ) => {
const vdoms = new Map();
dom.querySelectorAll( '.woo-product-query' ).forEach( ( query ) => {
vdoms.set( getQueryId( query ), toVdom( query ) );
} );
return vdoms;
};

// Render the virtual DOM of each Product Query block.
export const renderVdoms = ( vdoms, initial = false ) => {
const r = initial ? hydrate : render;
vdoms.forEach( ( vdom, id ) => {
r( vdom, rootFragments.get( id ) );
} );
};

// We use this for wrapperless hydration.
// See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c
const createRootFragment = ( parent, replaceNode ) => {
replaceNode = [].concat( replaceNode );
const s = replaceNode[ replaceNode.length - 1 ].nextSibling;
function insert( c, r ) {
parent.insertBefore( c, r || s );
}
return ( parent.__k = {
nodeType: 1,
parentNode: parent,
firstChild: replaceNode[ 0 ],
childNodes: replaceNode,
insertBefore: insert,
appendChild: insert,
removeChild( c ) {
parent.removeChild( c );
},
} );
};
68 changes: 68 additions & 0 deletions assets/js/blocks/product-query/runtime/router.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Internal dependencies
*/
import {
createRootFragments,
cleanUrl,
fetchPage,
toVdoms,
renderVdoms,
} from './router-helpers';

// The cache of all the visited or prefetched pages.
const pages = new Map();

// Prefetch a page. We store the promise to avoid triggering a second fetch for
// a page if a fetching has already started.
export const prefetch = ( url ) => {
url = cleanUrl( url );
if ( ! pages.has( url ) ) {
pages.set( url, fetchPage( url ) );
}
};

// Navigate to a new page.
export const navigate = async ( href, { scroll } = { scroll: false } ) => {
const url = cleanUrl( href ); // Remove hash.

// Get the new page and render each Product Query block.
prefetch( url );
const vdoms = await pages.get( url );
renderVdoms( vdoms );

// Change the history.
window.history.pushState( {}, '', href );

// Update the scroll, depending on the option. True by default.
if ( scroll === 'smooth' ) {
window.scrollTo( { top: 0, left: 0, behavior: 'smooth' } );
} else if ( scroll !== false ) {
window.scrollTo( 0, 0 );
}
};

// Listen to the back and forward buttons and restore the page if it's in
// the cache. If not, refresh the page.
window.addEventListener( 'popstate', async () => {
const previousUrl = cleanUrl( window.location ); // Remove hash.
if ( pages.has( previousUrl ) ) {
const vdoms = await pages.get( previousUrl );
renderVdoms( vdoms );
} else {
window.location.reload();
}
} );

// Initialize the router.
document.addEventListener( 'DOMContentLoaded', async () => {
// Create root fragments for each Product Query block.
createRootFragments();

// Get the virtual DOM of each Product Query block and store them in the
// cache.
const vdoms = toVdoms( document.body );
pages.set( cleanUrl( window.location ), Promise.resolve( vdoms ) );

// Render the virtual DOM of each Product Query block.
renderVdoms( vdoms, true );
} );
43 changes: 43 additions & 0 deletions assets/js/blocks/product-query/runtime/vdom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* External dependencies
*/
import { h } from 'preact';

// Convert DOM nodes to static virtual DOM nodes.
export const toVdom = ( node ) => {
if ( node.nodeType === 3 ) return node.data;
if ( node.nodeType === 8 ) return null;
if ( node.localName === 'script' ) return h( 'script', null );

const props = {},
a = node.attributes;

for ( let i = 0; i < a.length; i++ ) {
if ( a[ i ].name.startsWith( 'wp-' ) ) {
props.wp = props.wp || {};
let value = a[ i ].value;
try {
value = JSON.parse( value );
} catch ( e ) {}
props.wp[ renameDirective( a[ i ].name ) ] = value;
} else {
props[ a[ i ].name ] = a[ i ].value;
}
}

return h(
node.localName,
props,
[].map.call( node.childNodes, toVdom ).filter( exists )
);
};

// Rename WordPress Directives from `wp-some-directive` to `someDirective`.
const renameDirective = ( s ) =>
s
.toLowerCase()
.replace( /^wp-/, '' )
.replace( /-(.)/g, ( _, chr ) => chr.toUpperCase() );

// Filter the truthy.
const exists = ( i ) => i;
21 changes: 21 additions & 0 deletions assets/js/blocks/product-query/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
p.animation {
width: 20px;
height: 20px;
background: #f00;
position: relative;
animation: animate 3s infinite;
animation-direction: alternate;
}

@keyframes animate {
0% {
background: #f00;
left: 0;
top: 0;
}
100% {
background: #ff0;
left: 300px;
top: 0;
}
}
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@
"dinero.js": "1.9.1",
"downshift": "6.1.7",
"html-react-parser": "0.14.3",
"preact": "^10.11.0",
"react-number-format": "4.9.3",
"reakit": "1.3.11",
"snakecase-keys": "5.4.2",
Expand Down
Loading