Skip to content

Commit

Permalink
Interactivity API: add wp-run directive and useInit & useWatch
Browse files Browse the repository at this point in the history
…hooks (#57805)

* Implement tests for wp-run

* Implement wp-run

* Rename useSignalEffect to useWatch and add useInit

* Export useLayoutEffect

* Expose cloneElement

* Pass the scope to useWatch and useInit

* Add tests for hooks inside wp-run

* Document useWatch and useInit

* Update changelog

* Always reset scope inside `withScope`

* Expose scoped versions of preact hooks

* Add docs for `wp-run`

* Removed `runs` from `wp-run` store

* Improve `wp-init` documentation

* Expose `useState` and `useRef` from preact
  • Loading branch information
DAreRodz authored Jan 15, 2024
1 parent cb71ab8 commit 4782f64
Show file tree
Hide file tree
Showing 9 changed files with 452 additions and 14 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "test/directive-run",
"title": "E2E Interactivity tests - directive run",
"category": "text",
"icon": "heart",
"description": "",
"supports": {
"interactivity": true
},
"textdomain": "e2e-interactivity",
"viewScript": "directive-run-view",
"render": "file:./render.php"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php
/**
* HTML for testing the directive `data-wp-run`.
*
* @package gutenberg-test-interactive-blocks
*/

gutenberg_enqueue_module( 'directive-run-view' );
?>

<div
data-wp-interactive='{ "namespace": "directive-run" }'
data-wp-navigation-id='test-directive-run'
>
<div data-testid="hydrated" data-wp-text="state.isHydrated"></div>
<div data-testid="mounted" data-wp-text="state.isMounted"></div>
<div data-testid="renderCount" data-wp-text="state.renderCount"></div>
<div data-testid="navigated">no</div>

<div
data-wp-run--hydrated="callbacks.updateIsHydrated"
data-wp-run--renderCount="callbacks.updateRenderCount"
data-wp-text="state.clickCount"
></div>
</div>

<div data-wp-interactive='{ "namespace": "directive-run" }' >
<button data-testid="toggle" data-wp-on--click="actions.toggle">
Toggle
</button>

<button data-testid="increment" data-wp-on--click="actions.increment">
Increment
</button>

<button data-testid="navigate" data-wp-on--click="actions.navigate">
Navigate
</button>

<!-- Hook execution results are stored in this element as attributes. -->
<div
data-testid="wp-run hooks results"
data-wp-show-children="state.isOpen"
data-init=""
data-watch=""
>
<div
data-wp-run--mounted="callbacks.updateIsMounted"
data-wp-run--hooks="callbacks.useHooks"
>
Element with wp-run using hooks
</div>
</div>
</div>
108 changes: 108 additions & 0 deletions packages/e2e-tests/plugins/interactive-blocks/directive-run/view.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* WordPress dependencies
*/
import {
store,
directive,
navigate,
useInit,
useWatch,
cloneElement,
getElement,
} from '@wordpress/interactivity';

// Custom directive to show hide the content elements in which it is placed.
directive(
'show-children',
( { directives: { 'show-children': showChildren }, element, evaluate } ) => {
const entry = showChildren.find(
( { suffix } ) => suffix === 'default'
);
return evaluate( entry )
? element
: cloneElement( element, { children: null } );
},
{ priority: 9 }
);

const html = `
<div
data-wp-interactive='{ "namespace": "directive-run" }'
data-wp-navigation-id='test-directive-run'
>
<div data-testid="hydrated" data-wp-text="state.isHydrated"></div>
<div data-testid="mounted" data-wp-text="state.isMounted"></div>
<div data-testid="renderCount" data-wp-text="state.renderCount"></div>
<div data-testid="navigated">yes</div>
<div
data-wp-run--hydrated="callbacks.updateIsHydrated"
data-wp-run--renderCount="callbacks.updateRenderCount"
data-wp-text="state.clickCount"
></div>
</div>
`;

const { state } = store( 'directive-run', {
state: {
isOpen: false,
isHydrated: 'no',
isMounted: 'no',
renderCount: 0,
clickCount: 0
},
actions: {
toggle() {
state.isOpen = ! state.isOpen;
},
increment() {
state.clickCount = state.clickCount + 1;
},
navigate() {
navigate( window.location, {
force: true,
html,
} );
},
},
callbacks: {
updateIsHydrated() {
setTimeout( () => ( state.isHydrated = 'yes' ) );
},
updateIsMounted() {
setTimeout( () => ( state.isMounted = 'yes' ) );
},
updateRenderCount() {
setTimeout( () => ( state.renderCount = state.renderCount + 1 ) );
},
useHooks() {
// Runs only on first render.
useInit( () => {
const { ref } = getElement();
ref
.closest( '[data-testid="wp-run hooks results"]')
.setAttribute( 'data-init', 'initialized' );
return () => {
ref
.closest( '[data-testid="wp-run hooks results"]')
.setAttribute( 'data-init', 'cleaned up' );
};
} );

// Runs whenever a signal consumed inside updates its value. Also
// executes for the first render.
useWatch( () => {
const { ref } = getElement();
const { clickCount } = state;
ref
.closest( '[data-testid="wp-run hooks results"]')
.setAttribute( 'data-watch', clickCount );
return () => {
ref
.closest( '[data-testid="wp-run hooks results"]')
.setAttribute( 'data-watch', 'cleaned up' );
};
} );
}
}
} );
4 changes: 4 additions & 0 deletions packages/interactivity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### New Features

- Add the `data-wp-run` directive along with the `useInit` and `useWatch` hooks. ([57805](https://github.com/WordPress/gutenberg/pull/57805))

### Bug Fix

- Fix namespaces when there are nested interactive regions. ([#57029](https://github.com/WordPress/gutenberg/pull/57029))
Expand Down
59 changes: 58 additions & 1 deletion packages/interactivity/docs/2-api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ DOM elements are connected to data stored in the state and context through direc
- [`wp-on`](#wp-on) ![](https://img.shields.io/badge/EVENT_HANDLERS-afd2e3.svg)
- [`wp-watch`](#wp-watch) ![](https://img.shields.io/badge/SIDE_EFFECTS-afd2e3.svg)
- [`wp-init`](#wp-init) ![](https://img.shields.io/badge/SIDE_EFFECTS-afd2e3.svg)
- [`wp-run`](#wp-run) ![](https://img.shields.io/badge/SIDE_EFFECTS-afd2e3.svg)
- [`wp-key`](#wp-key) ![](https://img.shields.io/badge/TEMPLATING-afd2e3.svg)
- [Values of directives are references to store properties](#values-of-directives-are-references-to-store-properties)
- [The store](#the-store)
Expand Down Expand Up @@ -471,6 +472,62 @@ store( "myPlugin", {

The `wp-init` can return a function. If it does, the returned function will run when the element is removed from the DOM.

#### `wp-run`

It runs the passed callback **during node's render execution**.

You can use and compose hooks like `useState`, `useWatch` or `useEffect` inside inside the passed callback and create your own logic, providing more flexibility than previous directives.

You can attach several `wp-run` to the same DOM element by using the syntax `data-wp-run--[unique-id]`. _The unique id doesn't need to be unique globally, it just needs to be different than the other unique ids of the `wp-run` directives of that DOM element._

_Example of `data-wp-run` directive_

```html
<div data-wp-run="callbacks.logInView">
<p>Hi!</p>
</div>
```

<details>
<summary><em>See store used with the directive above</em></summary>

```js
import { store, useState, useEffect } from '@wordpress/interactivity';

// Unlike `data-wp-init` and `data-wp-watch`, you can use any hooks inside
// `data-wp-run` callbacks.
const useInView = ( ref ) => {
const [ inView, setInView ] = useState( false );
useEffect( () => {
const observer = new IntersectionObserver( ( [ entry ] ) => {
setInView( entry.isIntersecting );
} );
if ( ref ) observer.observe( ref );
return () => ref && observer.unobserve( ref );
}, []);
return inView;
};

store( 'myPlugin', {
callbacks: {
logInView: () => {
const { ref } = getElement();
const isInView = useInView( ref );
useEffect( () => {
if ( isInView ) {
console.log( 'Inside' );
} else {
console.log( 'Outside' );
}
});
}
},
} );
```

</details>
<br/>

#### `wp-key`

The `wp-key` directive assigns a unique key to an element to help the Interactivity API identify it when iterating through arrays of elements. This becomes important if your array elements can move (e.g., due to sorting), get inserted, or get deleted. A well-chosen key value helps the Interactivity API infer what exactly has changed in the array, allowing it to make the correct updates to the DOM.
Expand Down Expand Up @@ -528,7 +585,7 @@ In the example below, we get `state.isPlaying` from `otherPlugin` instead of `my

```html
<div data-wp-interactive='{ "namespace": "myPlugin" }'>
<div data-bind--hidden="otherPlugin::!state.isPlaying" ... >
<div data-bind--hidden="otherPlugin::!state.isPlaying" ... >
<iframe ...></iframe>
</div>
</div>
Expand Down
23 changes: 14 additions & 9 deletions packages/interactivity/src/directives.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { deepSignal, peek } from 'deepsignal';
* Internal dependencies
*/
import { createPortal } from './portals';
import { useSignalEffect } from './utils';
import { useWatch, useInit } from './utils';
import { directive } from './hooks';
import { SlotProvider, Slot, Fill } from './slots';
import { navigate } from './router';
Expand Down Expand Up @@ -75,14 +75,14 @@ export default () => {
// data-wp-watch--[name]
directive( 'watch', ( { directives: { watch }, evaluate } ) => {
watch.forEach( ( entry ) => {
useSignalEffect( () => evaluate( entry ) );
useWatch( () => evaluate( entry ) );
} );
} );

// data-wp-init--[name]
directive( 'init', ( { directives: { init }, evaluate } ) => {
init.forEach( ( entry ) => {
useEffect( () => evaluate( entry ), [] );
useInit( () => evaluate( entry ) );
} );
} );

Expand Down Expand Up @@ -118,7 +118,7 @@ export default () => {
? `${ currentClass } ${ name }`
: name;

useEffect( () => {
useInit( () => {
// 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.
Expand All @@ -127,7 +127,7 @@ export default () => {
} else {
element.ref.current.classList.add( name );
}
}, [] );
} );
} );
}
);
Expand Down Expand Up @@ -182,7 +182,7 @@ export default () => {
if ( ! result ) delete element.props.style[ key ];
else element.props.style[ key ] = result;

useEffect( () => {
useInit( () => {
// This seems necessary because Preact doesn't change the styles 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.
Expand All @@ -191,7 +191,7 @@ export default () => {
} else {
element.ref.current.style[ key ] = result;
}
}, [] );
} );
} );
} );

Expand All @@ -217,7 +217,7 @@ export default () => {
// This seems necessary because Preact doesn't change the attributes
// 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.
useEffect( () => {
useInit( () => {
const el = element.ref.current;

// We set the value directly to the corresponding
Expand Down Expand Up @@ -260,7 +260,7 @@ export default () => {
} else {
el.removeAttribute( attribute );
}
}, [] );
} );
}
);
} );
Expand Down Expand Up @@ -390,4 +390,9 @@ export default () => {
),
{ priority: 4 }
);

// data-wp-run
directive( 'run', ( { directives: { run }, evaluate } ) => {
run.forEach( ( entry ) => evaluate( entry ) );
} );
};
13 changes: 11 additions & 2 deletions packages/interactivity/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,17 @@ import { init } from './router';
export { store } from './store';
export { directive, getContext, getElement } from './hooks';
export { navigate, prefetch } from './router';
export { h as createElement } from 'preact';
export { useEffect, useContext, useMemo } from 'preact/hooks';
export {
useWatch,
useInit,
useEffect,
useLayoutEffect,
useCallback,
useMemo,
} from './utils';

export { h as createElement, cloneElement } from 'preact';
export { useContext, useState, useRef } from 'preact/hooks';
export { deepSignal } from 'deepsignal';

document.addEventListener( 'DOMContentLoaded', async () => {
Expand Down
Loading

0 comments on commit 4782f64

Please sign in to comment.