diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..34c463bb --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,25 @@ +name: Playwright Tests +on: pull_request +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 14 + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Build + run: npm run build + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v3 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 427fe02a..bf7218b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ node_modules build vendor -composer.lock \ No newline at end of file +composer.lock +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/e2e/tovdom-full.html b/e2e/tovdom-full.html new file mode 100644 index 00000000..b46b7596 --- /dev/null +++ b/e2e/tovdom-full.html @@ -0,0 +1,20 @@ + + + + toVdom - full + + + +
+ + + This should not be shown because we are in full mode. + + +
+ + + + + + diff --git a/e2e/tovdom-full.spec.ts b/e2e/tovdom-full.spec.ts new file mode 100644 index 00000000..fc5ca7d0 --- /dev/null +++ b/e2e/tovdom-full.spec.ts @@ -0,0 +1,13 @@ +import { join } from 'path'; +import { test, expect } from '@playwright/test'; + +test.describe('toVdom - full', () => { + test.beforeEach(async ({ page }) => { + await page.goto('file://' + join(__dirname, 'tovdom-full.html')); + }); + + test('it should stop when it founds wp-ignore', async ({ page }) => { + const el = page.getByTestId('inside wp-ignore'); + await expect(el).toBeVisible(); + }); +}); diff --git a/e2e/tovdom-islands.html b/e2e/tovdom-islands.html new file mode 100644 index 00000000..efaadbba --- /dev/null +++ b/e2e/tovdom-islands.html @@ -0,0 +1,70 @@ + + + + toVdom - islands + + +
+ + + This should be shown because it is inside an island. + + + +
+ + + This should not be shown because it is inside an island. + + +
+ +
+
+ + + This should be shown because it is inside an inner + block of an isolated island. + + +
+
+ +
+
+ + + This should not have two template wrappers because + that means we hydrated twice. + + +
+
+ +
+
+
+ + + This should not be shown because even though it + is inside an inner block of an isolated island, + it's inside an new island. + + +
+
+
+
+ + + + + + diff --git a/e2e/tovdom-islands.spec.ts b/e2e/tovdom-islands.spec.ts new file mode 100644 index 00000000..10f530d6 --- /dev/null +++ b/e2e/tovdom-islands.spec.ts @@ -0,0 +1,48 @@ +import { join } from 'path'; +import { test, expect } from '@playwright/test'; + +test.describe('toVdom - isands', () => { + test.beforeEach(async ({ page }) => { + await page.goto('file://' + join(__dirname, 'tovdom-islands.html')); + }); + + test('directives that are not inside islands should not be hydrated', async ({ + page, + }) => { + const el = page.getByTestId('not inside an island'); + await expect(el).toBeVisible(); + }); + + test('directives that are inside islands should be hydrated', async ({ + page, + }) => { + const el = page.getByTestId('inside an island'); + await expect(el).toBeHidden(); + }); + + test('directives that are inside inner blocks of isolated islands should not be hydrated', async ({ + page, + }) => { + const el = page.getByTestId( + 'inside an inner block of an isolated island' + ); + await expect(el).toBeVisible(); + }); + + test('directives inside islands should not be hydrated twice', async ({ + page, + }) => { + const el = page.getByTestId('island inside another island'); + const templates = el.locator('template'); + expect(await templates.count()).toEqual(1); + }); + + test('islands inside inner blocks of isolated islands should be hydrated', async ({ + page, + }) => { + const el = page.getByTestId( + 'island inside inner block of isolated island' + ); + await expect(el).toBeHidden(); + }); +}); diff --git a/e2e/tovdom.js b/e2e/tovdom.js new file mode 100644 index 00000000..06e44fd5 --- /dev/null +++ b/e2e/tovdom.js @@ -0,0 +1,7 @@ +import wpx from '../src/runtime/wpx'; + +wpx({ + state: { + falseValue: false, + }, +}); diff --git a/package-lock.json b/package-lock.json index d70f9c5a..1445f016 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2034,6 +2034,24 @@ "fastq": "^1.6.0" } }, + "@playwright/test": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.29.0.tgz", + "integrity": "sha512-gp5PVBenxTJsm2bATWDNc2CCnrL5OaA/MXQdJwwkGQtqTjmY+ZOqAdLqo49O9MLTDh2vYh+tHWDnmFsILnWaeA==", + "dev": true, + "requires": { + "@types/node": "*", + "playwright-core": "1.29.0" + }, + "dependencies": { + "playwright-core": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.29.0.tgz", + "integrity": "sha512-pboOm1m0RD6z1GtwAbEH60PYRfF87vKdzOSRw2RyO0Y0a7utrMyWN2Au1ojGvQr4umuBMODkKTv607YIRypDSQ==", + "dev": true + } + } + }, "@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.8", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.8.tgz", diff --git a/package.json b/package.json index f02a0ad1..1495863a 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "devDependencies": { "@babel/core": "^7.17.10", "@babel/preset-env": "^7.17.10", + "@playwright/test": "^1.29.0", "@prettier/plugin-php": "^0.18.9", "@types/jest": "^27.5.1", "@wordpress/env": "^4.4.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..6747b3a6 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,107 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: './e2e', + /* Maximum time one test can run for. */ + timeout: 30 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + }, + }, + + { + name: 'webkit', + use: { + ...devices['Desktop Safari'], + }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { + // ...devices['Pixel 5'], + // }, + // }, + // { + // name: 'Mobile Safari', + // use: { + // ...devices['iPhone 12'], + // }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { + // channel: 'msedge', + // }, + // }, + // { + // name: 'Google Chrome', + // use: { + // channel: 'chrome', + // }, + // }, + ], + + /* Folder for test artifacts such as screenshots, videos, traces, etc. */ + // outputDir: 'test-results/', + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // port: 3000, + // }, +}; + +export default config; diff --git a/src/admin/admin-page.php b/src/admin/admin-page.php index ffdea070..0435cd29 100644 --- a/src/admin/admin-page.php +++ b/src/admin/admin-page.php @@ -11,16 +11,16 @@ function wp_directives_register_menu() { } add_action( 'admin_menu', 'wp_directives_register_menu' ); -function wp_directives_render_admin_page() {?> +function wp_directives_render_admin_page() { ?>
-

WP Directives

-
- WP Directives + + - -
+ +
> diff --git a/src/runtime/components.js b/src/runtime/components.js index acc75298..07a6e05f 100644 --- a/src/runtime/components.js +++ b/src/runtime/components.js @@ -1,6 +1,7 @@ import { useMemo } from 'preact/hooks'; import { deepSignal } from './deepsignal'; import { component } from './hooks'; +import { getCallback } from './utils'; export default () => { const WpContext = ({ children, data, context: { Provider } }) => { @@ -8,4 +9,16 @@ export default () => { return {children}; }; component('wp-context', WpContext); + + const WpShow = ({ children, when }) => { + const cb = getCallback(when); + const value = + typeof cb === 'function' ? cb({ state: window.wpx.state }) : cb; + if (value) { + return children; + } else { + return ; + } + }; + component('wp-show', WpShow); }; diff --git a/src/runtime/deepsignal.js b/src/runtime/deepsignal.js index 84901785..08742367 100644 --- a/src/runtime/deepsignal.js +++ b/src/runtime/deepsignal.js @@ -1,25 +1,27 @@ import { signal } from '@preact/signals'; -import { knownSymbols, shouldWrap } from './utils'; const proxyToSignals = new WeakMap(); const objToProxy = new WeakMap(); -const returnSignal = /^\$/; export const deepSignal = (obj) => new Proxy(obj, handlers); +export const options = { returnSignal: /^\$/ }; const handlers = { get(target, prop, receiver) { - if (typeof prop === 'symbol' && knownSymbols.has(prop)) - return Reflect.get(target, prop, receiver); - const shouldReturnSignal = returnSignal.test(prop); - const key = shouldReturnSignal ? prop.replace(returnSignal, '') : prop; + const returnSignal = options.returnSignal.test(prop); + const key = returnSignal + ? prop.replace(options.returnSignal, '') + : prop; if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map()); const signals = proxyToSignals.get(receiver); if (!signals.has(key)) { let val = Reflect.get(target, key, receiver); - if (typeof val === 'object' && val !== null && shouldWrap(val)) - val = new Proxy(val, handlers); + if (typeof val === 'object' && val !== null) { + if (!objToProxy.has(val)) + objToProxy.set(val, new Proxy(val, handlers)); + val = objToProxy.get(val); + } signals.set(key, signal(val)); } return returnSignal ? signals.get(key) : signals.get(key).value; @@ -27,7 +29,7 @@ const handlers = { set(target, prop, val, receiver) { let internal = val; - if (typeof val === 'object' && val !== null && shouldWrap(val)) { + if (typeof val === 'object' && val !== null) { if (!objToProxy.has(val)) objToProxy.set(val, new Proxy(val, handlers)); internal = objToProxy.get(val); diff --git a/src/runtime/hooks.js b/src/runtime/hooks.js index 617f6009..080accfd 100644 --- a/src/runtime/hooks.js +++ b/src/runtime/hooks.js @@ -43,9 +43,7 @@ options.vnode = (vnode) => { { ...vnode.props, context }, vnode.props.children ); - } - - if (wp) { + } else if (wp) { const props = vnode.props; delete props.wp; if (!props._wrapped) { diff --git a/src/runtime/router.js b/src/runtime/router.js index 94c6f659..3347a813 100644 --- a/src/runtime/router.js +++ b/src/runtime/router.js @@ -1,5 +1,5 @@ import { hydrate, render } from 'preact'; -import toVdom from './vdom'; +import { toVdom, hydratedIslands } from './vdom'; import { createRootFragment } from './utils'; // The root to render the vdom (document.body). @@ -94,13 +94,25 @@ window.addEventListener('popstate', async () => { // 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); - if (hasClientSideTransitions(document.head)) { + // Create the root fragment to hydrate everything. + rootFragment = createRootFragment( + document.documentElement, + document.body + ); + + const body = toVdom(document.body); + hydrate(body, rootFragment); + const head = await fetchHead(document.head); pages.set(cleanUrl(window.location), Promise.resolve({ body, head })); + } else { + document.querySelectorAll('[wp-island]').forEach((node) => { + if (!hydratedIslands.has(node)) { + const fragment = createRootFragment(node.parentNode, node); + const vdom = toVdom(node); + hydrate(vdom, fragment); + } + }); } }; diff --git a/src/runtime/vdom.js b/src/runtime/vdom.js index 104b6fda..754146ce 100644 --- a/src/runtime/vdom.js +++ b/src/runtime/vdom.js @@ -1,11 +1,15 @@ import { h } from 'preact'; +export const hydratedIslands = new WeakSet(); + // Recursive function that transfoms a DOM tree into vDOM. -export default function toVdom(node) { +export function toVdom(node) { const props = {}; const { attributes, childNodes } = node; const wpDirectives = {}; let hasWpDirectives = false; + let ignore = false; + let island = false; if (node.nodeType === 3) return node.data; if (node.nodeType === 4) { @@ -16,14 +20,20 @@ export default function toVdom(node) { for (let i = 0; i < attributes.length; i++) { const n = attributes[i].name; if (n[0] === 'w' && n[1] === 'p' && n[2] === '-' && n[3]) { - hasWpDirectives = true; - let val = attributes[i].value; - try { - val = JSON.parse(val); - } catch (e) {} - const [, prefix, suffix] = /wp-([^:]+):?(.*)$/.exec(n); - wpDirectives[prefix] = wpDirectives[prefix] || {}; - wpDirectives[prefix][suffix || 'default'] = val; + if (n === 'wp-ignore') { + ignore = true; + } else if (n === 'wp-island') { + island = true; + } else { + hasWpDirectives = true; + let val = attributes[i].value; + try { + val = JSON.parse(val); + } catch (e) {} + const [, prefix, suffix] = /wp-([^:]+):?(.*)$/.exec(n); + wpDirectives[prefix] = wpDirectives[prefix] || {}; + wpDirectives[prefix][suffix || 'default'] = val; + } } else if (n === 'ref') { continue; } else { @@ -31,6 +41,12 @@ export default function toVdom(node) { } } + if (ignore && !island) + return h(node.localName, { + dangerouslySetInnerHTML: { __html: node.innerHTML }, + }); + if (island) hydratedIslands.add(node); + if (hasWpDirectives) props.wp = wpDirectives; const children = []; diff --git a/src/runtime/wpx.js b/src/runtime/wpx.js new file mode 100644 index 00000000..6f7c1b47 --- /dev/null +++ b/src/runtime/wpx.js @@ -0,0 +1,10 @@ +import { deepSignal } from './deepsignal'; +import { deepMerge } from './utils'; + +const rawState = {}; +window.wpx = { state: deepSignal(rawState) }; + +export default ({ state, ...block }) => { + deepMerge(window.wpx, block); + deepMerge(rawState, state); +}; diff --git a/webpack.config.js b/webpack.config.js index c2070f52..d6eecafa 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,54 +1,60 @@ +const defaultConfig = require('@wordpress/scripts/config/webpack.config'); const { resolve } = require('path'); -module.exports = { - entry: { - runtime: './src/runtime', - }, - output: { - filename: '[name].js', - path: resolve(process.cwd(), 'build'), - }, - optimization: { - runtimeChunk: { - name: 'vendors', +module.exports = [ + defaultConfig, + { + ...defaultConfig, + entry: { + runtime: './src/runtime', + 'e2e/tovdom': './e2e/tovdom', + }, + output: { + filename: '[name].js', + path: resolve(process.cwd(), 'build'), }, - splitChunks: { - cacheGroups: { - vendors: { - test: /[\\/]node_modules[\\/]/, - name: 'vendors', - minSize: 0, - chunks: 'all', + optimization: { + runtimeChunk: { + name: 'vendors', + }, + splitChunks: { + cacheGroups: { + vendors: { + test: /[\\/]node_modules[\\/]/, + name: 'vendors', + minSize: 0, + chunks: 'all', + }, }, }, }, - }, - module: { - rules: [ - { - test: /\.(j|t)sx?$/, - exclude: /node_modules/, - use: [ - { - loader: require.resolve('babel-loader'), - options: { - cacheDirectory: - process.env.BABEL_CACHE_DIRECTORY || true, - babelrc: false, - configFile: false, - presets: [ - [ - '@babel/preset-react', - { - runtime: 'automatic', - importSource: 'preact', - }, + module: { + rules: [ + { + test: /\.(j|t)sx?$/, + exclude: /node_modules/, + use: [ + { + loader: require.resolve('babel-loader'), + options: { + cacheDirectory: + process.env.BABEL_CACHE_DIRECTORY || true, + babelrc: false, + configFile: false, + presets: [ + [ + '@babel/preset-react', + { + runtime: 'automatic', + importSource: 'preact', + }, + ], ], - ], + }, }, - }, - ], - }, - ], + ], + }, + ], + }, }, -}; +]; diff --git a/wp-directives.php b/wp-directives.php index 4ec17a65..64e13ac3 100644 --- a/wp-directives.php +++ b/wp-directives.php @@ -10,20 +10,27 @@ * Text Domain: wp-directives */ - // Check if Gutenberg plugin is active +// Check if Gutenberg plugin is active. if ( ! function_exists( 'is_plugin_active' ) ) { - include_once( ABSPATH . 'wp-admin/includes/plugin.php' ); + include_once ABSPATH . 'wp-admin/includes/plugin.php'; } + if ( ! is_plugin_active( 'gutenberg/gutenberg.php' ) ) { - // Show an error message + // Show an error message. add_action( 'admin_notices', - function() { - echo sprintf( '

%s

', __( 'This plugin requires the Gutenberg plugin to be installed and activated.', 'wp-directives' ) ); + function () { + echo sprintf( + '

%s

', + __( + 'This plugin requires the Gutenberg plugin to be installed and activated.', + 'wp-directives' + ) + ); } ); - // Deactivate the plugin + // Deactivate the plugin. deactivate_plugins( plugin_basename( __FILE__ ) ); return; } @@ -91,8 +98,14 @@ function wp_directives_add_wp_link_attribute( $block_content ) { $link = parse_url( $w->get_attribute( 'href' ) ); if ( ! isset( $link['host'] ) || $link['host'] === $site_url['host'] ) { $classes = $w->get_attribute( 'class' ); - if ( str_contains( $classes, 'query-pagination' ) || str_contains( $classes, 'page-numbers' ) ) { - $w->set_attribute( 'wp-link', '{ "prefetch": true, "scroll": false }' ); + if ( + str_contains( $classes, 'query-pagination' ) || + str_contains( $classes, 'page-numbers' ) + ) { + $w->set_attribute( + 'wp-link', + '{ "prefetch": true, "scroll": false }' + ); } else { $w->set_attribute( 'wp-link', '{ "prefetch": true }' ); } @@ -101,22 +114,85 @@ function wp_directives_add_wp_link_attribute( $block_content ) { return (string) $w; } // We go only through the Query Loops and the template parts until we find a better solution. -add_filter( 'render_block_core/query', 'wp_directives_add_wp_link_attribute', 10, 1 ); -add_filter( 'render_block_core/template-part', 'wp_directives_add_wp_link_attribute', 10, 1 ); +add_filter( + 'render_block_core/query', + 'wp_directives_add_wp_link_attribute', + 10, + 1 +); +add_filter( + 'render_block_core/template-part', + 'wp_directives_add_wp_link_attribute', + 10, + 1 +); + -function wp_directives_client_site_transitions_meta_tag() { - if ( apply_filters( 'client_side_transitions', false ) ) { +function wp_directives_get_client_side_transitions() { + static $client_side_transitions = null; + if ( is_null( $client_side_transitions ) ) { + $client_side_transitions = apply_filters( 'client_side_transitions', false ); + } + return $client_side_transitions; +} + +function wp_directives_add_client_side_transitions_meta_tag() { + if ( wp_directives_get_client_side_transitions() ) { echo ''; } } -add_action( 'wp_head', 'wp_directives_client_site_transitions_meta_tag', 10, 0 ); +add_action( 'wp_head', 'wp_directives_add_client_side_transitions_meta_tag' ); -/* User code */ function wp_directives_client_site_transitions_option() { $options = get_option( 'wp_directives_plugin_settings' ); return $options['client_side_transitions']; } add_filter( 'client_side_transitions', - 'wp_directives_client_site_transitions_option' + 'wp_directives_client_site_transitions_option', + 9 ); + +function wp_directives_mark_interactive_blocks( $block_content, $block, $instance ) { + if ( wp_directives_get_client_side_transitions() ) { + return $block_content; + } + + // Append the `wp-ignore` attribute for inner blocks of interactive blocks. + if ( isset( $instance->parsed_block['isolated'] ) ) { + $w = new WP_HTML_Tag_Processor( $block_content ); + $w->next_tag(); + $w->set_attribute( 'wp-ignore', '' ); + $block_content = (string) $w; + } + + // Return if it's not interactive. + if ( ! block_has_support( $instance->block_type, array( 'interactivity' ) ) ) { + return $block_content; + } + + // Add the `wp-island` attribute if it's interactive. + $w = new WP_HTML_Tag_Processor( $block_content ); + $w->next_tag(); + $w->set_attribute( 'wp-island', '' ); + + return (string) $w; +} +add_filter( 'render_block', 'wp_directives_mark_interactive_blocks', 10, 3 ); + +/** + * Add a flag to mark inner blocks of isolated interactive blocks. + */ +function bhe_inner_blocks( $parsed_block, $source_block, $parent_block ) { + if ( + isset( $parent_block ) && + block_has_support( + $parent_block->block_type, + array( 'interactivity', 'isolated' ) + ) + ) { + $parsed_block['isolated'] = true; + } + return $parsed_block; +} +add_filter( 'render_block_data', 'bhe_inner_blocks', 10, 3 );