diff --git a/package-lock.json b/package-lock.json
index 1995c4c0468470..244113f1cd1074 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7001,6 +7001,28 @@
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.6.0.tgz",
"integrity": "sha512-cPqjjzuFWNK3BSKLm0abspP0sp/IGOli4p5I5fKFAzdS8fvjdOwDCfZqAaIiXd9lPkOWi3SUUfZof3hEb7J/uw=="
},
+ "@preact/signals": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-1.1.3.tgz",
+ "integrity": "sha512-N09DuAVvc90bBZVRwD+aFhtGyHAmJLhS3IFoawO/bYJRcil4k83nBOchpCEoS0s5+BXBpahgp0Mjf+IOqP57Og==",
+ "requires": {
+ "@preact/signals-core": "^1.2.3"
+ }
+ },
+ "@preact/signals-core": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.2.3.tgz",
+ "integrity": "sha512-Kui4p7PMcEQevBgsTO0JBo3gyQ88Q3qzEvsVCuSp11t0JcN4DmGCTJcGRVSCq7Bn7lGxJBO+57jNSzDoDJ+QmA=="
+ },
+ "@preact/signals-react": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@preact/signals-react/-/signals-react-1.2.2.tgz",
+ "integrity": "sha512-GoESQ9n1bns2FD+8yqH7lBvQMavboKLCNEW+s0hs3Wcp5B1VHvVxwJo6aFs6rpxoh1/q8Tvwbi4vIeehBD2mzA==",
+ "requires": {
+ "@preact/signals-core": "^1.2.3",
+ "use-sync-external-store": "^1.2.0"
+ }
+ },
"@react-native-clipboard/clipboard": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@react-native-clipboard/clipboard/-/clipboard-1.9.0.tgz",
@@ -25174,7 +25196,7 @@
"array-ify": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz",
- "integrity": "sha1-nlKHYrSpBmrRY6aWKjZEGOlibs4=",
+ "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==",
"dev": true
},
"array-includes": {
@@ -28528,7 +28550,7 @@
"co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
- "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==",
+ "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=",
"dev": true
},
"code-point-at": {
@@ -30094,7 +30116,7 @@
"css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
- "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true
},
"cssesc": {
@@ -30323,7 +30345,7 @@
"debuglog": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz",
- "integrity": "sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI=",
+ "integrity": "sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw==",
"dev": true
},
"decache": {
@@ -30438,6 +30460,16 @@
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
"dev": true
},
+ "deepsignal": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/deepsignal/-/deepsignal-1.3.0.tgz",
+ "integrity": "sha512-tMh3g3F7Ka6gKALqu7uOzi/3Xm00mGWgWR3hp1GUzGGnTz2J926ime5aOe1haz233v4encyjTkZESr5R6hr8oQ==",
+ "requires": {
+ "@preact/signals": "^1.0.0",
+ "@preact/signals-core": "^1.0.0",
+ "@preact/signals-react": "^1.0.0"
+ }
+ },
"default-browser-id": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-1.0.4.tgz",
@@ -35159,7 +35191,7 @@
"git-remote-origin-url": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/git-remote-origin-url/-/git-remote-origin-url-2.0.0.tgz",
- "integrity": "sha1-UoJlna4hBxRaERJhEq0yFuxfpl8=",
+ "integrity": "sha512-eU+GGrZgccNJcsDH5LkXR3PB9M958hxc7sbA8DFJjrv9j4L2P/eZfKhM+QD6wyzpiv+b1BpK0XrYCxkovtjSLw==",
"dev": true,
"requires": {
"gitconfiglocal": "^1.0.0",
@@ -35206,7 +35238,7 @@
"gitconfiglocal": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/gitconfiglocal/-/gitconfiglocal-1.0.0.tgz",
- "integrity": "sha1-QdBF84UaXqiPA/JMocYXgRRGS5s=",
+ "integrity": "sha512-spLUXeTAVHxDtKsJc8FkFVgFtMdEN9qPGpL23VfSHx4fP4+Ds097IXLvymbnDH8FnmxX5Nr9bPw3A+AQ6mWEaQ==",
"dev": true,
"requires": {
"ini": "^1.3.2"
@@ -36485,7 +36517,7 @@
"humanize-ms": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
- "integrity": "sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0=",
+ "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
"dev": true,
"requires": {
"ms": "^2.0.0"
@@ -37501,7 +37533,7 @@
"is-text-path": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-1.0.1.tgz",
- "integrity": "sha1-Thqg+1G/vLPpJogAE5cgLBd1tm4=",
+ "integrity": "sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w==",
"dev": true,
"requires": {
"text-extensions": "^1.0.0"
@@ -39268,7 +39300,7 @@
"jsonparse": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
- "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=",
+ "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==",
"dev": true
},
"jsprim": {
@@ -40368,7 +40400,7 @@
"lodash.ismatch": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz",
- "integrity": "sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=",
+ "integrity": "sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==",
"dev": true
},
"lodash.isplainobject": {
@@ -40648,7 +40680,7 @@
"lz-string": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
- "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=",
+ "integrity": "sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==",
"dev": true
},
"macos-release": {
@@ -47031,6 +47063,11 @@
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz",
"integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ=="
},
+ "preact": {
+ "version": "10.13.1",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.13.1.tgz",
+ "integrity": "sha512-KyoXVDU5OqTpG9LXlB3+y639JAGzl8JSBXLn1J9HTSB3gbKcuInga7bZnXLlxmK94ntTs1EFeZp0lrja2AuBYQ=="
+ },
"prelude-ls": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
@@ -47649,7 +47686,7 @@
"promzard": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/promzard/-/promzard-0.3.0.tgz",
- "integrity": "sha1-JqXW7ox97kyxIggwWs+5O6OCqe4=",
+ "integrity": "sha512-JZeYqd7UAcHCwI+sTOeUDYkvEU+1bQ7iE0UT1MgB/tERkAPkesW46MrpIySzODi+owTjZtiF8Ay5j9m60KmMBw==",
"dev": true,
"requires": {
"read": "1"
@@ -47683,7 +47720,7 @@
"proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
- "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=",
+ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
"dev": true
},
"protocols": {
@@ -49217,7 +49254,7 @@
"read": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz",
- "integrity": "sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ=",
+ "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==",
"dev": true,
"requires": {
"mute-stream": "~0.0.4"
@@ -54600,7 +54637,7 @@
"temp-dir": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz",
- "integrity": "sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0=",
+ "integrity": "sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==",
"dev": true
},
"terminal-link": {
diff --git a/package.json b/package.json
index 34d81fe27180ba..1d458d8084661e 100644
--- a/package.json
+++ b/package.json
@@ -84,6 +84,8 @@
"@wordpress/warning": "file:packages/warning",
"@wordpress/widgets": "file:packages/widgets",
"@wordpress/wordcount": "file:packages/wordcount",
+ "deepsignal": "1.3.0",
+ "preact": "10.13.1",
"wicg-inert": "3.1.2"
},
"devDependencies": {
diff --git a/packages/block-library/package.json b/packages/block-library/package.json
index 431ea2cedc4442..10af88a5c1a3ec 100644
--- a/packages/block-library/package.json
+++ b/packages/block-library/package.json
@@ -27,7 +27,7 @@
"sideEffects": [
"build-style/**",
"src/**/*.scss",
- "{src,build,build-module}/*/init.js"
+ "{src,build,build-module}/**/init.js"
],
"dependencies": {
"@babel/runtime": "^7.16.0",
diff --git a/packages/block-library/src/post-comments-form/block.json b/packages/block-library/src/post-comments-form/block.json
index 793d14d74ba7ba..a455f2d7612a35 100644
--- a/packages/block-library/src/post-comments-form/block.json
+++ b/packages/block-library/src/post-comments-form/block.json
@@ -13,6 +13,7 @@
},
"usesContext": [ "postId", "postType" ],
"supports": {
+ "interactivity": true,
"anchor": true,
"html": false,
"color": {
@@ -44,5 +45,6 @@
"wp-block-post-comments-form",
"wp-block-buttons",
"wp-block-button"
- ]
+ ],
+ "viewScript": [ "file:./view.min.js" ]
}
diff --git a/packages/block-library/src/post-comments-form/index.php b/packages/block-library/src/post-comments-form/index.php
index 644b02ae0f1498..0e9f502591a0b2 100644
--- a/packages/block-library/src/post-comments-form/index.php
+++ b/packages/block-library/src/post-comments-form/index.php
@@ -45,11 +45,17 @@ function render_block_core_post_comments_form( $attributes, $content, $block ) {
// to the block is carried along when the comment form is moved to the location
// of the 'Reply' link that the user clicked by Core's `comment-reply.js` script.
$form = str_replace( 'class="comment-respond"', $wrapper_attributes, $form );
+ $form = str_replace( '', '
', $form );
// Enqueue the comment-reply script.
- wp_enqueue_script( 'comment-reply' );
+ // wp_enqueue_script( 'comment-reply' );
- return $form;
+ $tags = new WP_HTML_Tag_Processor( $form );
+
+ $tags->next_tag( array( 'tag_name' => 'FORM', 'id' => 'commentform' ) );
+ $tags->set_attribute( 'data-wp-on.submit', 'actions.core.commentsFormSubmission' );
+
+ return $tags->get_updated_html();
}
/**
diff --git a/packages/block-library/src/post-comments-form/runtime/constants.js b/packages/block-library/src/post-comments-form/runtime/constants.js
new file mode 100644
index 00000000000000..66aa781744c5e7
--- /dev/null
+++ b/packages/block-library/src/post-comments-form/runtime/constants.js
@@ -0,0 +1,3 @@
+export const csnMetaTagItemprop = 'wp-client-side-navigation';
+export const componentPrefix = 'wp-';
+export const directivePrefix = 'data-wp-';
diff --git a/packages/block-library/src/post-comments-form/runtime/directives.js b/packages/block-library/src/post-comments-form/runtime/directives.js
new file mode 100644
index 00000000000000..0c2bbf2bf819ba
--- /dev/null
+++ b/packages/block-library/src/post-comments-form/runtime/directives.js
@@ -0,0 +1,243 @@
+/** @jsx h */
+
+/**
+ * External dependencies
+ */
+import { h } from 'preact';
+
+import { useContext, useMemo, useEffect } from 'preact/hooks';
+import { useSignalEffect } from '@preact/signals';
+import { deepSignal, peek } from 'deepsignal';
+/**
+ * Internal dependencies
+ */
+import { directive } from './hooks';
+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 can do client-side navigation.
+const clientSideNavigation = canDoClientSideNavigation( document.head );
+
+const isObject = ( item ) =>
+ item && typeof item === 'object' && ! Array.isArray( item );
+
+const mergeDeepSignals = ( target, source ) => {
+ for ( const k in source ) {
+ if ( typeof peek( target, k ) === 'undefined' ) {
+ target[ `$${ k }` ] = source[ `$${ k }` ];
+ } else if (
+ isObject( peek( target, k ) ) &&
+ isObject( peek( source, k ) )
+ ) {
+ mergeDeepSignals(
+ target[ `$${ k }` ].peek(),
+ source[ `$${ k }` ].peek()
+ );
+ }
+ }
+};
+
+export default () => {
+ // data-wp-context
+ directive(
+ 'context',
+ ( {
+ directives: {
+ context: { default: context },
+ },
+ props: { children },
+ context: inherited,
+ } ) => {
+ const { Provider } = inherited;
+ const inheritedValue = useContext( inherited );
+ const value = useMemo( () => {
+ const localValue = deepSignal( context );
+ mergeDeepSignals( localValue, inheritedValue );
+ return localValue;
+ }, [ context, inheritedValue ] );
+
+ return { children };
+ }
+ );
+
+ // data-wp-effect.[name]
+ directive( 'effect', ( { directives: { effect }, context, evaluate } ) => {
+ const contextValue = useContext( context );
+ Object.values( effect ).forEach( ( path ) => {
+ useSignalEffect( () => {
+ evaluate( path, { context: contextValue } );
+ } );
+ } );
+ } );
+
+ // data-wp-on.[event]
+ directive( 'on', ( { directives: { on }, element, evaluate, context } ) => {
+ const contextValue = useContext( context );
+ Object.entries( on ).forEach( ( [ name, path ] ) => {
+ element.props[ `on${ name }` ] = ( event ) => {
+ evaluate( path, { event, context: contextValue } );
+ };
+ } );
+ } );
+
+ // data-wp-class.[classname]
+ directive(
+ 'class',
+ ( {
+ directives: { class: className },
+ element,
+ evaluate,
+ context,
+ } ) => {
+ const contextValue = useContext( context );
+ Object.keys( className )
+ .filter( ( n ) => n !== 'default' )
+ .forEach( ( name ) => {
+ const result = evaluate( className[ name ], {
+ className: name,
+ context: contextValue,
+ } );
+ const currentClass = element.props.class || '';
+ const classFinder = new RegExp(
+ `(^|\\s)${ name }(\\s|$)`,
+ 'g'
+ );
+ if ( ! result )
+ element.props.class = currentClass
+ .replace( classFinder, ' ' )
+ .trim();
+ else if ( ! classFinder.test( currentClass ) )
+ element.props.class = currentClass
+ ? `${ currentClass } ${ name }`
+ : name;
+
+ useEffect( () => {
+ // This seems necessary because Preact doesn't change the class names
+ // on the hydration, so we have to do it manually. It doesn't need
+ // deps because it only needs to do it the first time.
+ if ( ! result ) {
+ element.ref.current.classList.remove( name );
+ } else {
+ element.ref.current.classList.add( name );
+ }
+ }, [] );
+ } );
+ }
+ );
+
+ // data-wp-bind.[attribute]
+ directive(
+ 'bind',
+ ( { directives: { bind }, element, context, evaluate } ) => {
+ const contextValue = useContext( context );
+ Object.entries( bind )
+ .filter( ( n ) => n !== 'default' )
+ .forEach( ( [ attribute, path ] ) => {
+ element.props[ attribute ] = evaluate( path, {
+ context: contextValue,
+ } );
+ } );
+ }
+ );
+
+ // data-wp-link
+ directive(
+ 'link',
+ ( {
+ directives: {
+ link: { default: link },
+ },
+ props: { href },
+ element,
+ } ) => {
+ useEffect( () => {
+ // Prefetch the page if it is in the directive options.
+ if ( clientSideNavigation && link?.prefetch ) {
+ prefetch( href );
+ }
+ } );
+
+ // Don't do anything if it's falsy.
+ if ( clientSideNavigation && link !== false ) {
+ element.props.onclick = async ( event ) => {
+ event.preventDefault();
+
+ // Fetch the page (or return it from cache).
+ await navigate( href );
+
+ // Update the scroll, depending on the option. True by default.
+ if ( link?.scroll === 'smooth' ) {
+ window.scrollTo( {
+ top: 0,
+ left: 0,
+ behavior: 'smooth',
+ } );
+ } else if ( link?.scroll !== false ) {
+ window.scrollTo( 0, 0 );
+ }
+ };
+ }
+ }
+ );
+
+ // data-wp-show
+ directive(
+ 'show',
+ ( {
+ directives: {
+ show: { default: show },
+ },
+ element,
+ evaluate,
+ context,
+ } ) => {
+ const contextValue = useContext( context );
+ if ( ! evaluate( show, { context: contextValue } ) )
+ element.props.children = (
+ { element.props.children }
+ );
+ }
+ );
+
+ // data-wp-ignore
+ directive(
+ 'ignore',
+ ( {
+ element: {
+ type: Type,
+ props: { innerHTML, ...rest },
+ },
+ } ) => {
+ // Preserve the initial inner HTML.
+ const cached = useMemo( () => innerHTML, [] );
+ return (
+
+ );
+ }
+ );
+
+ // data-wp-text
+ directive(
+ 'text',
+ ( {
+ directives: {
+ text: { default: text },
+ },
+ element,
+ evaluate,
+ context,
+ } ) => {
+ const contextValue = useContext( context );
+ element.props.children = evaluate( text, {
+ context: contextValue,
+ } );
+ }
+ );
+};
diff --git a/packages/block-library/src/post-comments-form/runtime/hooks.js b/packages/block-library/src/post-comments-form/runtime/hooks.js
new file mode 100644
index 00000000000000..40b9f320952e1c
--- /dev/null
+++ b/packages/block-library/src/post-comments-form/runtime/hooks.js
@@ -0,0 +1,85 @@
+import { h, options, createContext } from 'preact';
+import { useRef } from 'preact/hooks';
+import { rawStore as store } from './store';
+import { componentPrefix } from './constants';
+
+// Main context.
+const context = createContext({});
+
+// WordPress Directives.
+const directiveMap = {};
+export const directive = (name, cb) => {
+ directiveMap[name] = cb;
+};
+
+// WordPress Components.
+const componentMap = {};
+export const component = (name, Comp) => {
+ componentMap[name] = Comp;
+};
+
+// 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]));
+ return current;
+};
+
+// Generate the evaluate function.
+const getEvaluate =
+ ({ ref } = {}) =>
+ (path, extraArgs = {}) => {
+ const value = resolve(path, extraArgs.context);
+ return typeof value === 'function'
+ ? value({
+ state: store.state,
+ ...(ref !== undefined ? { ref } : {}),
+ ...extraArgs,
+ })
+ : value;
+ };
+
+// Directive wrapper.
+const Directive = ({ type, directives, props: originalProps }) => {
+ const ref = useRef(null);
+ const element = h(type, { ...originalProps, ref, _wrapped: true });
+ const props = { ...originalProps, children: element };
+ const evaluate = getEvaluate({ ref: ref.current });
+ const directiveArgs = { directives, props, element, context, evaluate };
+
+ for (const d in directives) {
+ const wrapper = directiveMap[d]?.(directiveArgs);
+ if (wrapper !== undefined) props.children = wrapper;
+ }
+
+ return props.children;
+};
+
+// Preact Options Hook called each time a vnode is created.
+const old = options.vnode;
+options.vnode = (vnode) => {
+ const type = vnode.type;
+ const { directives } = vnode.props;
+
+ if (
+ typeof type === 'string' &&
+ type.slice(0, componentPrefix.length) === componentPrefix
+ ) {
+ vnode.props.children = h(
+ componentMap[type.slice(componentPrefix.length)],
+ { ...vnode.props, context, evaluate: getEvaluate() },
+ vnode.props.children
+ );
+ } else if (directives) {
+ const props = vnode.props;
+ delete props.directives;
+ if (!props._wrapped) {
+ vnode.props = { type: vnode.type, directives, props };
+ vnode.type = Directive;
+ } else {
+ delete props._wrapped;
+ }
+ }
+
+ if (old) old(vnode);
+};
diff --git a/packages/block-library/src/post-comments-form/runtime/index.js b/packages/block-library/src/post-comments-form/runtime/index.js
new file mode 100644
index 00000000000000..926021bb579790
--- /dev/null
+++ b/packages/block-library/src/post-comments-form/runtime/index.js
@@ -0,0 +1,2 @@
+export { store } from './store';
+export { navigate } from './router';
diff --git a/packages/block-library/src/post-comments-form/runtime/init.js b/packages/block-library/src/post-comments-form/runtime/init.js
new file mode 100644
index 00000000000000..772a690c3279fd
--- /dev/null
+++ b/packages/block-library/src/post-comments-form/runtime/init.js
@@ -0,0 +1,11 @@
+/**
+ * Internal dependencies
+ */
+import registerDirectives from './directives';
+import { init } from './router';
+
+document.addEventListener( 'DOMContentLoaded', async () => {
+ registerDirectives();
+ await init();
+ console.log( 'hydrated!' );
+} );
diff --git a/packages/block-library/src/post-comments-form/runtime/router.js b/packages/block-library/src/post-comments-form/runtime/router.js
new file mode 100644
index 00000000000000..e813e1c1f19b92
--- /dev/null
+++ b/packages/block-library/src/post-comments-form/runtime/router.js
@@ -0,0 +1,171 @@
+/**
+ * External dependencies
+ */
+import { hydrate, render } from 'preact';
+/**
+ * Internal dependencies
+ */
+import { toVdom, hydratedIslands } from './vdom';
+import { createRootFragment } from './utils';
+import { csnMetaTagItemprop, directivePrefix } from './constants';
+
+// The root to render the vdom (document.body).
+let rootFragment;
+
+// The cache of visited and prefetched pages, stylesheets and scripts.
+const pages = new Map();
+const stylesheets = new Map();
+const scripts = new Map();
+
+// 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;
+};
+
+// Helper to check if a page can do client-side navigation.
+export const canDoClientSideNavigation = ( dom ) =>
+ dom
+ .querySelector( `meta[itemprop='${ csnMetaTagItemprop }']` )
+ ?.getAttribute( 'content' ) === 'active';
+
+/**
+ * Finds the elements in the document that match the selector and fetch them.
+ * For each element found, fetch the content and store it in the cache.
+ * Returns an array of elements to add to the document.
+ *
+ * @param document
+ * @param {string} selector - CSS selector used to find the elements.
+ * @param {'href'|'src'} attribute - Attribute that determines where to fetch
+ * the styles or scripts from. Also used as the key for the cache.
+ * @param {Map} cache - Cache to use for the elements. Can be `stylesheets` or `scripts`.
+ * @param {'style'|'script'} elementToCreate - Element to create for each fetched
+ * item. Can be 'style' or 'script'.
+ * @return {Promise>} - Array of elements to add to the document.
+ */
+const fetchScriptOrStyle = async (
+ document,
+ selector,
+ attribute,
+ cache,
+ elementToCreate
+) => {
+ const fetchedItems = await Promise.all(
+ [].map.call( document.querySelectorAll( selector ), ( el ) => {
+ const attributeValue = el.getAttribute( attribute );
+ if ( ! cache.has( attributeValue ) )
+ cache.set(
+ attributeValue,
+ fetch( attributeValue ).then( ( r ) => r.text() )
+ );
+ return cache.get( attributeValue );
+ } )
+ );
+
+ return fetchedItems.map( ( item ) => {
+ const element = document.createElement( elementToCreate );
+ element.textContent = item;
+ return element;
+ } );
+};
+
+// Fetch styles of a new page.
+const fetchAssets = async ( document ) => {
+ const stylesFromSheets = await fetchScriptOrStyle(
+ document,
+ 'link[rel=stylesheet]',
+ 'href',
+ stylesheets,
+ 'style'
+ );
+ const scriptTags = await fetchScriptOrStyle(
+ document,
+ 'script[src]',
+ 'src',
+ scripts,
+ 'script'
+ );
+ const moduleScripts = await fetchScriptOrStyle(
+ document,
+ 'script[type=module]',
+ 'src',
+ scripts,
+ 'script'
+ );
+ moduleScripts.forEach( ( script ) =>
+ script.setAttribute( 'type', 'module' )
+ );
+
+ return [
+ ...scriptTags,
+ document.querySelector( 'title' ),
+ ...document.querySelectorAll( 'style' ),
+ ...stylesFromSheets,
+ ];
+};
+
+// Fetch a new page and convert it to a static virtual DOM.
+const fetchPage = async ( url, options = {} ) => {
+ const html =
+ options.html || ( await window.fetch( url ).then( ( r ) => r.text() ) );
+ const dom = new window.DOMParser().parseFromString( html, 'text/html' );
+ const head = await fetchAssets( dom );
+ return { head, body: toVdom( dom.body ) };
+};
+
+// 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 ) );
+ }
+};
+
+// Navigate to a new page.
+export const navigate = async ( href, options = {} ) => {
+ const url = cleanUrl( href );
+ prefetch( url, options );
+ const page = await pages.get( url );
+ if ( page ) {
+ document.head.replaceChildren( ...page.head );
+ render( page.body, rootFragment );
+ window.history.pushState( {}, '', href );
+ } else {
+ window.location.assign( href );
+ }
+};
+
+// 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 ) {
+ document.head.replaceChildren( ...page.head );
+ render( page.body, rootFragment );
+ } else {
+ window.location.reload();
+ }
+} );
+
+// Initialize the router with the initial DOM.
+export const init = async () => {
+ // Create the root fragment to hydrate everything.
+ rootFragment = createRootFragment(
+ document.documentElement,
+ document.body
+ );
+
+ const body = toVdom( document.body );
+ hydrate( body, rootFragment );
+
+ // Cache the scripts. Has to be called before fetching the assets.
+ [].map.call( document.querySelectorAll( 'script[src]' ), ( script ) => {
+ scripts.set( script.getAttribute( 'src' ), script.textContent );
+ } );
+
+ const head = await fetchAssets( document );
+ pages.set( cleanUrl( window.location ), Promise.resolve( { body, head } ) );
+};
diff --git a/packages/block-library/src/post-comments-form/runtime/store.js b/packages/block-library/src/post-comments-form/runtime/store.js
new file mode 100644
index 00000000000000..eca682a194e844
--- /dev/null
+++ b/packages/block-library/src/post-comments-form/runtime/store.js
@@ -0,0 +1,46 @@
+/**
+ * External dependencies
+ */
+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 = () => {
+ // TODO: change the store tag ID for a better one.
+ 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 );
+};
diff --git a/packages/block-library/src/post-comments-form/runtime/utils.js b/packages/block-library/src/post-comments-form/runtime/utils.js
new file mode 100644
index 00000000000000..f1748293cad99f
--- /dev/null
+++ b/packages/block-library/src/post-comments-form/runtime/utils.js
@@ -0,0 +1,20 @@
+// For wrapperless hydration of document.body.
+// See https://gist.github.com/developit/f4c67a2ede71dc2fab7f357f39cff28c
+export 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);
+ },
+ });
+};
diff --git a/packages/block-library/src/post-comments-form/runtime/vdom.js b/packages/block-library/src/post-comments-form/runtime/vdom.js
new file mode 100644
index 00000000000000..f1cd54420a86a4
--- /dev/null
+++ b/packages/block-library/src/post-comments-form/runtime/vdom.js
@@ -0,0 +1,71 @@
+import { h } from 'preact';
+import { directivePrefix as p } from './constants';
+
+const ignoreAttr = `${p}ignore`;
+const islandAttr = `${p}island`;
+const directiveParser = new RegExp(`${p}([^.]+)\.?(.*)$`);
+
+export const hydratedIslands = new WeakSet();
+
+// Recursive function that transfoms a DOM tree into vDOM.
+export function toVdom(node) {
+ const props = {};
+ const { attributes, childNodes } = node;
+ const directives = {};
+ let hasDirectives = false;
+ let ignore = false;
+ let island = false;
+
+ if (node.nodeType === 3) return node.data;
+ if (node.nodeType === 4) {
+ node.replaceWith(new Text(node.nodeValue));
+ return node.nodeValue;
+ }
+
+ for (let i = 0; i < attributes.length; i++) {
+ const n = attributes[i].name;
+ if (n[p.length] && n.slice(0, p.length) === p) {
+ if (n === ignoreAttr) {
+ ignore = true;
+ } else if (n === islandAttr) {
+ island = true;
+ } else {
+ hasDirectives = true;
+ let val = attributes[i].value;
+ try {
+ val = JSON.parse(val);
+ } catch (e) {}
+ const [, prefix, suffix] = directiveParser.exec(n);
+ directives[prefix] = directives[prefix] || {};
+ directives[prefix][suffix || 'default'] = val;
+ }
+ } else if (n === 'ref') {
+ continue;
+ } else {
+ props[n] = attributes[i].value;
+ }
+ }
+
+ if (ignore && !island)
+ return h(node.localName, {
+ ...props,
+ innerHTML: node.innerHTML,
+ directives: { ignore: true },
+ });
+ if (island) hydratedIslands.add(node);
+
+ if (hasDirectives) props.directives = directives;
+
+ const children = [];
+ for (let i = 0; i < childNodes.length; i++) {
+ const child = childNodes[i];
+ if (child.nodeType === 8 || child.nodeType === 7) {
+ child.remove();
+ i--;
+ } else {
+ children.push(toVdom(child));
+ }
+ }
+
+ return h(node.localName, props, children);
+}
diff --git a/packages/block-library/src/post-comments-form/view.js b/packages/block-library/src/post-comments-form/view.js
new file mode 100644
index 00000000000000..f42d16ad749f24
--- /dev/null
+++ b/packages/block-library/src/post-comments-form/view.js
@@ -0,0 +1,50 @@
+/**
+ * Internal dependencies
+ */
+import './runtime/init.js';
+import { store, navigate } from './runtime';
+
+store( {
+ state: {
+ core: {
+ commentsFormError: '',
+ },
+ },
+ actions: {
+ core: {
+ commentsFormSubmission: async ( { event, state } ) => {
+ event.preventDefault();
+
+ state.core.commentsFormError = '';
+
+ const formData = new FormData( event.target );
+
+ const res = await fetch(
+ 'http://localhost:8888/wp-comments-post.php',
+ {
+ method: 'POST',
+ body: formData,
+ }
+ );
+
+ const html = await res.text();
+
+ if ( res.status !== 200 ) {
+ const dom = new window.DOMParser().parseFromString(
+ html,
+ 'text/html'
+ );
+ state.core.commentsFormError =
+ dom.querySelector( 'p' ).innerHTML;
+ } else {
+ navigate( res.url, {
+ html,
+ force: true,
+ } );
+
+ event.target.reset();
+ }
+ },
+ },
+ },
+} );