148 lines
4.1 KiB
JavaScript
148 lines
4.1 KiB
JavaScript
// @ts-nocheck
|
|
|
|
/**
|
|
* 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 );
|
|
} );
|
|
|
|
return { regions };
|
|
};
|
|
|
|
// 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, 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 );
|
|
} );
|
|
};
|
|
|
|
// Variable to store the current navigation.
|
|
let navigatingTo = '';
|
|
|
|
// Navigate to a new page.
|
|
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 ) )
|
|
);
|
|
};
|