diff --git a/assets/js/atomic/utils/render-parent-block.tsx b/assets/js/atomic/utils/render-parent-block.tsx index bb6b2ecdf..155bbba7d 100644 --- a/assets/js/atomic/utils/render-parent-block.tsx +++ b/assets/js/atomic/utils/render-parent-block.tsx @@ -142,6 +142,20 @@ const renderInnerBlocks = ( { return null; } return Array.from( children ).map( ( node: Node, index: number ) => { + /** + * Do not process the node if its a `` element. + */ + const WpBlock = window.customElements.get( 'wp-block' ); + if ( + WpBlock && + node instanceof HTMLElement && + node instanceof WpBlock + ) { + const reactElement = parse( node.outerHTML ); + + if ( isValidElement( reactElement ) ) + return cloneElement( reactElement ); + } /** * This will grab the blockName from the data- attributes stored in block markup. Without a blockName, we cannot * convert the HTMLElement to a React component. @@ -151,7 +165,6 @@ const renderInnerBlocks = ( { ...( node instanceof HTMLElement ? node.dataset : {} ), className: node instanceof Element ? node?.className : '', }; - const InnerBlockComponent = getBlockComponentFromMap( blockName, blockMap @@ -169,7 +182,6 @@ const renderInnerBlocks = ( { node?.textContent || '' ); - // Returns text nodes without manipulation. if ( typeof parsedElement === 'string' && !! parsedElement ) { return parsedElement; diff --git a/assets/js/base/utils/bhe-blocks.tsx b/assets/js/base/utils/bhe-blocks.tsx new file mode 100644 index 000000000..bfe6feaba --- /dev/null +++ b/assets/js/base/utils/bhe-blocks.tsx @@ -0,0 +1,31 @@ +/** + * External dependencies + */ +import { ReactElement } from 'react'; +import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor'; +import { registerBlockType as gutenbergRegisterBlockType } from '@wordpress/blocks'; + +const Wrapper = + ( Comp: ReactElement ) => + ( { attributes } ) => + ( + <> + { /* Block Context is not available during save + https://wordpress.slack.com/archives/C02QB2JS7/p1649347999484329 */ } + + + + + ); + +export const registerBlockType = ( name, { edit, view, ...rest } ) => { + gutenbergRegisterBlockType( name, { + edit, + save: Wrapper( view ), + ...rest, + } ); +}; diff --git a/assets/js/base/utils/bhe-element.tsx b/assets/js/base/utils/bhe-element.tsx index fa1397bbb..4554d52da 100644 --- a/assets/js/base/utils/bhe-element.tsx +++ b/assets/js/base/utils/bhe-element.tsx @@ -1,23 +1,59 @@ +/* eslint-disable react-hooks/rules-of-hooks */ + /** * External dependencies */ +import { + createContext, + useContext as useReactContext, + useEffect as useReactEffect, + useState as useReactState, +} from '@wordpress/element'; import { hydrate as ReactHydrate } from 'react-dom'; -import { ReactElement } from 'react'; -type HydrateOptions = { - technique?: 'media' | 'view' | 'idle'; - media?: string; +export const EnvContext = createContext( null ); + +/** + * A React hook that returns the name of the environment. + * + * This is still a bit hacky. Ideally, Save components should support React + * hooks and all the environments (Edit, Save and Frontend) should populate a + * normal context. Also, more environments could be added in the future. + * + * @return A string with the environment the component is loaded, can be {"edit" | "save" | "frontend"} + */ +export const useBlockEnvironment = () => { + try { + const env = useReactContext( EnvContext ); + if ( env === 'frontend' ) { + return 'frontend'; + } + return 'edit'; + } catch ( e ) { + return 'save'; + } }; -export const hydrate = ( - element: ReactElement, - container: Element, - hydrationOptions: HydrateOptions = {} -) => { - const { technique, media } = hydrationOptions; +const noop = () => null; + +export const useState = ( init ) => + useBlockEnvironment() !== 'save' ? useReactState( init ) : [ init, noop ]; + +export const useEffect = ( ...args ) => + useBlockEnvironment() !== 'save' ? useReactEffect( ...args ) : noop; + +export const useContext = ( Context ) => + useBlockEnvironment() !== 'save' + ? useReactContext( Context ) + : Context._currentValue; + +export const hydrate = ( element, container, hydrationOptions ) => { + const { technique, media } = hydrationOptions || {}; + const cb = () => { ReactHydrate( element, container ); }; + switch ( technique ) { case 'media': if ( media ) { @@ -29,8 +65,10 @@ export const hydrate = ( } } break; + // Hydrate the element when is visible in the viewport. // https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API + case 'view': try { const io = new IntersectionObserver( ( entries ) => { @@ -41,7 +79,7 @@ export const hydrate = ( // As soon as we hydrate, disconnect this IntersectionObserver. io.disconnect(); cb(); - break; // break loop on first match + break; // Break loop on first match. } } ); io.observe( container.children[ 0 ] ); @@ -49,15 +87,19 @@ export const hydrate = ( cb(); } break; + case 'idle': - // Safari does not support requestIdleCalback, we use a timeout instead. https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback + // Safari does not support requestIdleCalback, we use a timeout instead. + // https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback if ( 'requestIdleCallback' in window ) { window.requestIdleCallback( cb ); } else { setTimeout( cb, 200 ); } break; + // Hydrate this component immediately. + default: cb(); } diff --git a/assets/js/base/utils/bhe-frontend.tsx b/assets/js/base/utils/bhe-frontend.tsx index 649d55a78..57161d228 100644 --- a/assets/js/base/utils/bhe-frontend.tsx +++ b/assets/js/base/utils/bhe-frontend.tsx @@ -7,8 +7,9 @@ import { unmountComponentAtNode } from 'react-dom'; /** * Internal dependencies */ -import { matcherFromSource, pickKeys } from './utils'; -import { hydrate } from './bhe-element'; +import { Consumer, createProvider } from './bhe-react-context'; +import { createGlobal, matcherFromSource } from './utils'; +import { EnvContext, hydrate } from './bhe-element'; declare global { interface Window { @@ -29,126 +30,188 @@ declare global { } } -// We assign `blockTypes` to window to make sure it's a global singleton. -// -// Have to do this because of the way we are currently bundling the code -// in this repo, each block gets its own copy of this file. -// -// We COULD fix this by doing some webpack magic to spit out the code in -// `gutenberg-packages` to a shared chunk but assigning `blockTypes` to window -// is a cheap hack for now that will be fixed once we can merge this code into Gutenberg. -if ( typeof window.blockTypes === 'undefined' ) { - window.blockTypes = new Map(); -} +const blockTypes = createGlobal( 'wpBlockTypes', new Map() ); -export const registerBlockType = ( name: string, Comp: ReactElement ) => { - window.blockTypes.set( name, Comp ); +export const registerBlockType = ( + name: string, + Component: ReactElement, + options +) => { + blockTypes.set( name, { Component, options } ); }; -const Children = ( { value, providedContext } ) => { - if ( ! value ) { - return null; - } - return ( - { - if ( el !== null ) { - // listen for the ping from the child - el.addEventListener( 'wp-block-context', ( event ) => { - // We have to also destructure `event.detail.context` because there can - // already exist a property in the context with the same name. - event.detail.context = { - ...providedContext, - ...event?.detail?.context, - }; - } ); - } - } } - suppressHydrationWarning={ true } - dangerouslySetInnerHTML={ { __html: value } } - /> - ); -}; +const Children = ( { value } ) => ( + +); Children.shouldComponentUpdate = () => false; +const Wrappers = ( { wrappers, children } ) => { + let result = children; + wrappers.forEach( ( wrapper ) => { + result = wrapper( { children: result } ); + } ); + return result; +}; + class WpBlock extends HTMLElement { connectedCallback() { setTimeout( () => { - // ping the parent for the context - const event = new CustomEvent( 'wp-block-context', { - detail: {}, - bubbles: true, - cancelable: true, - } ); - this.dispatchEvent( event ); - - const usesContext = JSON.parse( - this.getAttribute( - 'data-wp-block-uses-block-context' - ) as string - ); - const providesContext = JSON.parse( - this.getAttribute( - 'data-wp-block-provides-block-context' - ) as string - ); + // Get the block attributes. const attributes = JSON.parse( - this.getAttribute( 'data-wp-block-attributes' ) as string + this.getAttribute( 'data-wp-block-attributes' ) ); + + // Add the sourced attributes to the attributes object. const sourcedAttributes = JSON.parse( - this.getAttribute( - 'data-wp-block-sourced-attributes' - ) as string + this.getAttribute( 'data-wp-block-sourced-attributes' ) ); - for ( const attr in sourcedAttributes ) { attributes[ attr ] = matcherFromSource( sourcedAttributes[ attr ] )( this ); } - // pass the context to children if needed - const providedContext = - providesContext && - pickKeys( attributes, Object.keys( providesContext ) ); - - // select only the parts of the context that the block declared in - // the `usesContext` of its block.json - const context = pickKeys( event.detail.context, usesContext ); - - const blockType = this.getAttribute( 'data-wp-block-type' ); - const blockProps = { - className: this.children[ 0 ].className, - style: this.children[ 0 ].style, - }; - - const innerBlocks = this.querySelector( 'wp-inner-blocks' ); - const Comp = window.blockTypes.get( blockType ); - const technique = this.getAttribute( 'data-wp-block-hydrate' ); - const media = this.getAttribute( 'data-wp-block-media' ); - const hydrationOptions = { technique, media }; - hydrate( - <> - - + ( blockContext[ key ] = event.detail.context[ key ] ) + ); + } + + // Share the Block Context with their children. + const providesBlockContext = JSON.parse( + this.getAttribute( 'data-wp-block-provides-block-context' ) + ); + if ( providesBlockContext ) { + this.addEventListener( 'wp-block-context', ( event ) => { + // Select only the parts of the context that the block declared in + // the `providesContext` of its block.json. + Object.entries( providesBlockContext ).forEach( + ( [ key, attribute ] ) => { + if ( ! event.detail.context[ key ] ) { + event.detail.context[ key ] = + attributes[ attribute ]; + } + } + ); + } ); + } + + // Hydrate the interactive blocks. + const hydration = this.getAttribute( 'data-wp-block-hydration' ); + + if ( hydration ) { + const Providers = []; + + // Get the block type, block props (class and style), inner blocks, + // frontend component and options. + const blockType = this.getAttribute( 'data-wp-block-type' ); + const innerBlocks = this.querySelector( 'wp-inner-blocks' ); + const { Component, options } = blockTypes.get( blockType ); + const { class: className, style } = JSON.parse( + this.getAttribute( 'data-wp-block-props' ) + ); + // Temporary element to translate style strings to style objects. + const el = document.createElement( 'div' ); + el.style.cssText = style; + const blockProps = { className, style: el.style }; + el.remove(); + + // Get the React Context from their parents. + options?.usesContext?.forEach( ( context ) => { + const event = new CustomEvent( 'wp-react-context', { + detail: { context }, + bubbles: true, + cancelable: true, + } ); + this.dispatchEvent( event ); + if ( typeof event.detail.Provider === 'function' ) { + Providers.push( event.detail.Provider ); + } + } ); + + // Share the React Context with their children. + if ( options?.providesContext?.length > 0 ) { + this.addEventListener( 'wp-react-context', ( event ) => { + for ( const context of options.providesContext ) { + // We compare the provided context with the received context. + if ( event.detail.context === context ) { + // If there's a match, we stop propagation. + event.stopPropagation(); + + // We return a Provider that is subscribed to the parent Provider. + event.detail.Provider = createProvider( { + element: this, + context, + } ); + + // We can stop the iteration. + break; + } + } + } ); + } + + const media = this.getAttribute( + 'data-wp-block-hydration-media' + ); + + hydrate( + + { /* Wrap the component with all the React Providers */ } + + + { /* Update the value each time one of the React Contexts changes */ } + { options?.providesContext?.map( + ( context, index ) => ( + + ) + ) } + + { /* Render the inner blocks */ } + { innerBlocks && ( + + ) } + + + +