diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 6a56144118fe53..5dba744711f7e0 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Internal + +- `SlotFill`: Refactor SlotFill base ([#51385](https://github.com/WordPress/gutenberg/pull/51385)). + ## 25.1.0 (2023-06-07) ### Enhancements diff --git a/packages/components/src/slot-fill/context.js b/packages/components/src/slot-fill/context.js deleted file mode 100644 index 0f7961a8ffd3b6..00000000000000 --- a/packages/components/src/slot-fill/context.js +++ /dev/null @@ -1,17 +0,0 @@ -// @ts-nocheck -/** - * WordPress dependencies - */ -import { createContext } from '@wordpress/element'; - -export const SlotFillContext = createContext( { - registerSlot: () => {}, - unregisterSlot: () => {}, - registerFill: () => {}, - unregisterFill: () => {}, - getSlot: () => {}, - getFills: () => {}, - subscribe: () => {}, -} ); - -export default SlotFillContext; diff --git a/packages/components/src/slot-fill/context.ts b/packages/components/src/slot-fill/context.ts new file mode 100644 index 00000000000000..c4839462fbce0c --- /dev/null +++ b/packages/components/src/slot-fill/context.ts @@ -0,0 +1,21 @@ +/** + * WordPress dependencies + */ +import { createContext } from '@wordpress/element'; +/** + * Internal dependencies + */ +import type { BaseSlotFillContext } from './types'; + +const initialValue: BaseSlotFillContext = { + registerSlot: () => {}, + unregisterSlot: () => {}, + registerFill: () => {}, + unregisterFill: () => {}, + getSlot: () => undefined, + getFills: () => [], + subscribe: () => () => {}, +}; +export const SlotFillContext = createContext( initialValue ); + +export default SlotFillContext; diff --git a/packages/components/src/slot-fill/fill.js b/packages/components/src/slot-fill/fill.ts similarity index 93% rename from packages/components/src/slot-fill/fill.js rename to packages/components/src/slot-fill/fill.ts index e7ff943df07bff..10e56fba9fd720 100644 --- a/packages/components/src/slot-fill/fill.js +++ b/packages/components/src/slot-fill/fill.ts @@ -10,8 +10,9 @@ import { useContext, useLayoutEffect, useRef } from '@wordpress/element'; */ import SlotFillContext from './context'; import useSlot from './use-slot'; +import type { FillComponentProps } from './types'; -export default function Fill( { name, children } ) { +export default function Fill( { name, children }: FillComponentProps ) { const { registerFill, unregisterFill } = useContext( SlotFillContext ); const slot = useSlot( name ); diff --git a/packages/components/src/slot-fill/provider.js b/packages/components/src/slot-fill/provider.js deleted file mode 100644 index 94c83c3c350111..00000000000000 --- a/packages/components/src/slot-fill/provider.js +++ /dev/null @@ -1,119 +0,0 @@ -// @ts-nocheck -/** - * WordPress dependencies - */ -import { Component } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import SlotFillContext from './context'; - -export default class SlotFillProvider extends Component { - constructor() { - super( ...arguments ); - - this.registerSlot = this.registerSlot.bind( this ); - this.registerFill = this.registerFill.bind( this ); - this.unregisterSlot = this.unregisterSlot.bind( this ); - this.unregisterFill = this.unregisterFill.bind( this ); - this.getSlot = this.getSlot.bind( this ); - this.getFills = this.getFills.bind( this ); - this.subscribe = this.subscribe.bind( this ); - - this.slots = {}; - this.fills = {}; - this.listeners = []; - this.contextValue = { - registerSlot: this.registerSlot, - unregisterSlot: this.unregisterSlot, - registerFill: this.registerFill, - unregisterFill: this.unregisterFill, - getSlot: this.getSlot, - getFills: this.getFills, - subscribe: this.subscribe, - }; - } - - registerSlot( name, slot ) { - const previousSlot = this.slots[ name ]; - this.slots[ name ] = slot; - this.triggerListeners(); - - // Sometimes the fills are registered after the initial render of slot - // But before the registerSlot call, we need to rerender the slot. - this.forceUpdateSlot( name ); - - // If a new instance of a slot is being mounted while another with the - // same name exists, force its update _after_ the new slot has been - // assigned into the instance, such that its own rendering of children - // will be empty (the new Slot will subsume all fills for this name). - if ( previousSlot ) { - previousSlot.forceUpdate(); - } - } - - registerFill( name, instance ) { - this.fills[ name ] = [ ...( this.fills[ name ] || [] ), instance ]; - this.forceUpdateSlot( name ); - } - - unregisterSlot( name, instance ) { - // If a previous instance of a Slot by this name unmounts, do nothing, - // as the slot and its fills should only be removed for the current - // known instance. - if ( this.slots[ name ] !== instance ) { - return; - } - - delete this.slots[ name ]; - this.triggerListeners(); - } - - unregisterFill( name, instance ) { - this.fills[ name ] = - this.fills[ name ]?.filter( ( fill ) => fill !== instance ) ?? []; - this.forceUpdateSlot( name ); - } - - getSlot( name ) { - return this.slots[ name ]; - } - - getFills( name, slotInstance ) { - // Fills should only be returned for the current instance of the slot - // in which they occupy. - if ( this.slots[ name ] !== slotInstance ) { - return []; - } - return this.fills[ name ]; - } - - forceUpdateSlot( name ) { - const slot = this.getSlot( name ); - - if ( slot ) { - slot.forceUpdate(); - } - } - - triggerListeners() { - this.listeners.forEach( ( listener ) => listener() ); - } - - subscribe( listener ) { - this.listeners.push( listener ); - - return () => { - this.listeners = this.listeners.filter( ( l ) => l !== listener ); - }; - } - - render() { - return ( - - { this.props.children } - - ); - } -} diff --git a/packages/components/src/slot-fill/provider.tsx b/packages/components/src/slot-fill/provider.tsx new file mode 100644 index 00000000000000..659ec667e69917 --- /dev/null +++ b/packages/components/src/slot-fill/provider.tsx @@ -0,0 +1,131 @@ +/** + * WordPress dependencies + */ +import type { Component } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import SlotFillContext from './context'; +import type { + BaseFillObject, + BaseSlotFillContext, + SlotComponentProps, +} from './types'; +import { useState } from '@wordpress/element'; +/** + * External dependencies + */ +import type { ReactNode } from 'react'; + +export function createSlotRegistory(): BaseSlotFillContext { + const slots: Record< string, Component< SlotComponentProps > > = {}; + const fills: Record< string, BaseFillObject[] > = {}; + let listeners: Array< () => void > = []; + + function registerSlot( + name: string, + slot: Component< SlotComponentProps > + ) { + const previousSlot = slots[ name ]; + slots[ name ] = slot; + triggerListeners(); + + // Sometimes the fills are registered after the initial render of slot + // But before the registerSlot call, we need to rerender the slot. + forceUpdateSlot( name ); + + // If a new instance of a slot is being mounted while another with the + // same name exists, force its update _after_ the new slot has been + // assigned into the instance, such that its own rendering of children + // will be empty (the new Slot will subsume all fills for this name). + if ( previousSlot ) { + previousSlot.forceUpdate(); + } + } + + function registerFill( name: string, instance: BaseFillObject ) { + fills[ name ] = [ ...( fills[ name ] || [] ), instance ]; + forceUpdateSlot( name ); + } + + function unregisterSlot( + name: string, + instance: Component< SlotComponentProps > + ) { + // If a previous instance of a Slot by this name unmounts, do nothing, + // as the slot and its fills should only be removed for the current + // known instance. + if ( slots[ name ] !== instance ) { + return; + } + + delete slots[ name ]; + triggerListeners(); + } + + function unregisterFill( name: string, instance: BaseFillObject ) { + fills[ name ] = + fills[ name ]?.filter( ( fill ) => fill !== instance ) ?? []; + forceUpdateSlot( name ); + } + + function getSlot( + name: string + ): Component< SlotComponentProps > | undefined { + return slots[ name ]; + } + + function getFills( + name: string, + slotInstance: Component< SlotComponentProps > + ): BaseFillObject[] { + // Fills should only be returned for the current instance of the slot + // in which they occupy. + if ( slots[ name ] !== slotInstance ) { + return []; + } + return fills[ name ]; + } + + function forceUpdateSlot( name: string ) { + const slot = getSlot( name ); + + if ( slot ) { + slot.forceUpdate(); + } + } + + function triggerListeners() { + listeners.forEach( ( listener ) => listener() ); + } + + function subscribe( listener: () => void ) { + listeners.push( listener ); + + return () => { + listeners = listeners.filter( ( l ) => l !== listener ); + }; + } + + return { + registerSlot, + unregisterSlot, + registerFill, + unregisterFill, + getSlot, + getFills, + subscribe, + }; +} + +export function SlotFillProvider( { children }: { children: ReactNode } ) { + const [ contextValue ] = useState( createSlotRegistory ); + return ( + + { children } + + ); +} + +export default SlotFillProvider; diff --git a/packages/components/src/slot-fill/slot.js b/packages/components/src/slot-fill/slot.tsx similarity index 68% rename from packages/components/src/slot-fill/slot.js rename to packages/components/src/slot-fill/slot.tsx index a960647c3ab64e..bd844f72647773 100644 --- a/packages/components/src/slot-fill/slot.js +++ b/packages/components/src/slot-fill/slot.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck /** * WordPress dependencies */ @@ -13,6 +12,11 @@ import { * Internal dependencies */ import SlotFillContext from './context'; +import type { BaseSlotProps, SlotComponentProps } from './types'; +/** + * External dependencies + */ +import type { ReactElement } from 'react'; /** * Whether the argument is a function. @@ -20,13 +24,15 @@ import SlotFillContext from './context'; * @param {*} maybeFunc The argument to check. * @return {boolean} True if the argument is a function, false otherwise. */ -function isFunction( maybeFunc ) { +function isFunction( maybeFunc: any ): maybeFunc is Function { return typeof maybeFunc === 'function'; } -class SlotComponent extends Component { - constructor() { - super( ...arguments ); +class SlotComponent extends Component< SlotComponentProps > { + private isUnmounted: boolean; + + constructor( props: SlotComponentProps ) { + super( props ); this.isUnmounted = false; } @@ -43,11 +49,12 @@ class SlotComponent extends Component { unregisterSlot( this.props.name, this ); } - componentDidUpdate( prevProps ) { + componentDidUpdate( prevProps: SlotComponentProps ) { const { name, unregisterSlot, registerSlot } = this.props; if ( prevProps.name !== name ) { - unregisterSlot( prevProps.name ); + // TODO: Check if adding `this` works correctly. @torounit + unregisterSlot( prevProps.name, this ); registerSlot( name, this ); } } @@ -62,33 +69,37 @@ class SlotComponent extends Component { render() { const { children, name, fillProps = {}, getFills } = this.props; - const fills = ( getFills( name, this ) ?? [] ) + const fills: ( ReactElement | string )[][] = ( + getFills( name, this ) ?? [] + ) .map( ( fill ) => { const fillChildren = isFunction( fill.children ) ? fill.children( fillProps ) : fill.children; - return Children.map( fillChildren, ( child, childIndex ) => { if ( ! child || typeof child === 'string' ) { return child; } - const childKey = child.key || childIndex; - return cloneElement( child, { key: childKey } ); + const childKey = child?.key || childIndex; + return cloneElement( child as ReactElement, { + key: childKey, + } ); } ); } ) .filter( // In some cases fills are rendered only when some conditions apply. // This ensures that we only use non-empty fills when rendering, i.e., // it allows us to render wrappers only when the fills are actually present. - ( element ) => ! isEmptyElement( element ) + ( element ): element is Exclude< typeof element, undefined > => + ! isEmptyElement( element ) ); return <>{ isFunction( children ) ? children( fills ) : fills }; } } -const Slot = ( props ) => ( +const Slot = ( props: BaseSlotProps ) => ( { ( { registerSlot, unregisterSlot, getFills } ) => ( + ) => void; + unregisterSlot: ( + name: string, + slot: Component< SlotComponentProps > + ) => void; + registerFill: ( name: string, instance: BaseFillObject ) => void; + unregisterFill: ( name: string, instance: BaseFillObject ) => void; + getSlot: ( name: string ) => Component< SlotComponentProps > | undefined; + getFills: ( + name: string, + slotInstance: Component< SlotComponentProps > + ) => BaseFillObject[]; + subscribe: ( listener: () => void ) => () => void; +}; + +export type BaseSlotProps = { + name: string; + fillProps?: any; + children?: ( fills: ( string | ReactElement )[][] ) => ReactNode; +}; + +export type SlotComponentProps = { + registerSlot: ( + name: string, + slot: Component< SlotComponentProps > + ) => void; + unregisterSlot: ( + name: string, + slot: Component< SlotComponentProps > + ) => void; + getFills: ( + name: string, + slotInstance: Component< SlotComponentProps > + ) => BaseFillObject[]; +} & BaseSlotProps; + +export type BaseFillObject = { + name: string; + children?: + | ReactElement + | string + | ( ( props: any ) => ReactElement | string ); +}; + +export type FillComponentProps = { + name: string; + children?: + | ReactElement + | string + | ( ( props: any ) => ReactElement | string ); +}; diff --git a/packages/components/src/slot-fill/use-slot.js b/packages/components/src/slot-fill/use-slot.ts similarity index 67% rename from packages/components/src/slot-fill/use-slot.js rename to packages/components/src/slot-fill/use-slot.ts index fce7c651495d5b..0cc2163ff3fd40 100644 --- a/packages/components/src/slot-fill/use-slot.js +++ b/packages/components/src/slot-fill/use-slot.ts @@ -1,4 +1,8 @@ -// @ts-nocheck +/** + * External dependencies + */ +import type { Component } from 'react'; + /** * WordPress dependencies */ @@ -13,11 +17,11 @@ import SlotFillContext from './context'; * React hook returning the active slot given a name. * * @param {string} name Slot name. - * @return {Object} Slot object. + * @return {Component|undefined} Slot object. */ -const useSlot = ( name ) => { +const useSlot = ( name: string ) => { const { getSlot, subscribe } = useContext( SlotFillContext ); - return useSyncExternalStore( + return useSyncExternalStore< Component | undefined >( subscribe, () => getSlot( name ), () => getSlot( name )