Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SlotFill: rewrite base Slot to functional, unify rerenderable refs #67153

Merged
merged 3 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
### Internal

- `SlotFill`: fix dependencies of `Fill` registration effects ([#67071](https://github.com/WordPress/gutenberg/pull/67071)).
- `SlotFill`: rewrite the `Slot` component from class component to functional ([#67153](https://github.com/WordPress/gutenberg/pull/67153)).

## 28.12.0 (2024-11-16)

Expand Down
27 changes: 5 additions & 22 deletions packages/components/src/slot-fill/bubbles-virtually/fill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
import { useObservableValue } from '@wordpress/compose';
import {
useContext,
useReducer,
useRef,
useState,
useEffect,
createPortal,
} from '@wordpress/element';
Expand All @@ -17,37 +17,20 @@ import SlotFillContext from './slot-fill-context';
import StyleProvider from '../../style-provider';
import type { FillComponentProps } from '../types';

function useForceUpdate() {
const [ , setState ] = useState( {} );
const mountedRef = useRef( true );

useEffect( () => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, [] );

return () => {
if ( mountedRef.current ) {
setState( {} );
}
};
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is extracted to a separate module because the useForceUpdate hook is used by two components now. Also with React 18 we no longer need mountedRef because calling setState after unmount is no longer a problem.


export default function Fill( { name, children }: FillComponentProps ) {
const registry = useContext( SlotFillContext );
const slot = useObservableValue( registry.slots, name );
const rerender = useForceUpdate();
const [ , rerender ] = useReducer( () => [], [] );
const ref = useRef( { rerender } );

useEffect( () => {
// We register fills so we can keep track of their existence.
// Some Slot implementations need to know if there're already fills
// registered so they can choose to render themselves or not.
registry.registerFill( name, ref );
const refValue = ref.current;
registry.registerFill( name, refValue );
return () => {
registry.unregisterFill( name, ref );
registry.unregisterFill( name, refValue );
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of registering the ref object I'm registering directly the Rerenderable value. Which is also constant.

};
}, [ registry, name ] );

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ function createSlotRegistry(): SlotFillBubblesVirtuallyContext {
const slotFills = fills.get( name );
if ( slotFills ) {
// Force update fills.
slotFills.forEach( ( fill ) => fill.current.rerender() );
slotFills.forEach( ( fill ) => fill.rerender() );
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a benefit of registering the Rerenderable value directly: avoids the .current indirection when calling.

}
};

Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/slot-fill/fill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default function Fill( { name, children }: FillComponentProps ) {
useLayoutEffect( () => {
ref.current.children = children;
if ( slot ) {
slot.forceUpdate();
slot.rerender();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

forceUpdate was previously a method of the component class, now slot is the Rerenderable instance.

}
}, [ slot, children ] );

Expand Down
25 changes: 8 additions & 17 deletions packages/components/src/slot-fill/provider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/**
* WordPress dependencies
*/
import type { Component } from '@wordpress/element';
import { useState } from '@wordpress/element';

/**
Expand All @@ -11,20 +10,17 @@ import SlotFillContext from './context';
import type {
FillComponentProps,
BaseSlotFillContext,
BaseSlotComponentProps,
SlotFillProviderProps,
SlotKey,
Rerenderable,
} from './types';

function createSlotRegistry(): BaseSlotFillContext {
const slots: Record< SlotKey, Component< BaseSlotComponentProps > > = {};
const slots: Record< SlotKey, Rerenderable > = {};
const fills: Record< SlotKey, FillComponentProps[] > = {};
let listeners: Array< () => void > = [];

function registerSlot(
name: SlotKey,
slot: Component< BaseSlotComponentProps >
) {
function registerSlot( name: SlotKey, slot: Rerenderable ) {
const previousSlot = slots[ name ];
slots[ name ] = slot;
triggerListeners();
Expand All @@ -38,7 +34,7 @@ function createSlotRegistry(): BaseSlotFillContext {
// 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();
previousSlot.rerender();
}
}

Expand All @@ -47,10 +43,7 @@ function createSlotRegistry(): BaseSlotFillContext {
forceUpdateSlot( name );
}

function unregisterSlot(
name: SlotKey,
instance: Component< BaseSlotComponentProps >
) {
function unregisterSlot( name: SlotKey, instance: Rerenderable ) {
// 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.
Expand All @@ -68,15 +61,13 @@ function createSlotRegistry(): BaseSlotFillContext {
forceUpdateSlot( name );
}

function getSlot(
name: SlotKey
): Component< BaseSlotComponentProps > | undefined {
function getSlot( name: SlotKey ): Rerenderable | undefined {
return slots[ name ];
}

function getFills(
name: SlotKey,
slotInstance: Component< BaseSlotComponentProps >
slotInstance: Rerenderable
): FillComponentProps[] {
// Fills should only be returned for the current instance of the slot
// in which they occupy.
Expand All @@ -90,7 +81,7 @@ function createSlotRegistry(): BaseSlotFillContext {
const slot = getSlot( name );

if ( slot ) {
slot.forceUpdate();
slot.rerender();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also rename the internal forceUpdateSlot function to rerenderSlot or something for consistency?

}
}

Expand Down
133 changes: 48 additions & 85 deletions packages/components/src/slot-fill/slot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import type { ReactElement, ReactNode, Key } from 'react';
* WordPress dependencies
*/
import {
useContext,
useEffect,
useReducer,
useRef,
Children,
Component,
cloneElement,
isEmptyElement,
} from '@wordpress/element';
Expand All @@ -17,7 +20,7 @@ import {
* Internal dependencies
*/
import SlotFillContext from './context';
import type { BaseSlotComponentProps, SlotComponentProps } from './types';
import type { SlotComponentProps } from './types';

/**
* Whether the argument is a function.
Expand All @@ -29,90 +32,50 @@ function isFunction( maybeFunc: any ): maybeFunc is Function {
return typeof maybeFunc === 'function';
}

class SlotComponent extends Component< BaseSlotComponentProps > {
private isUnmounted: boolean;

constructor( props: BaseSlotComponentProps ) {
super( props );

this.isUnmounted = false;
}

componentDidMount() {
const { registerSlot } = this.props;
this.isUnmounted = false;
registerSlot( this.props.name, this );
}

componentWillUnmount() {
const { unregisterSlot } = this.props;
this.isUnmounted = true;
unregisterSlot( this.props.name, this );
}

componentDidUpdate( prevProps: BaseSlotComponentProps ) {
const { name, unregisterSlot, registerSlot } = this.props;

if ( prevProps.name !== name ) {
unregisterSlot( prevProps.name, this );
registerSlot( name, this );
}
}

forceUpdate() {
if ( this.isUnmounted ) {
return;
}
super.forceUpdate();
}

render() {
const { children, name, fillProps = {}, getFills } = this.props;
const fills: ReactNode[] = ( 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;
}
let childKey: Key = childIndex;
if (
typeof child === 'object' &&
'key' in child &&
child?.key
) {
childKey = child.key;
}

return cloneElement( child as ReactElement, {
key: childKey,
} );
function Slot( props: Omit< SlotComponentProps, 'bubblesVirtually' > ) {
const registry = useContext( SlotFillContext );
const [ , rerender ] = useReducer( () => [], [] );
const ref = useRef( { rerender } );

const { name, children, fillProps = {} } = props;

useEffect( () => {
const refValue = ref.current;
registry.registerSlot( name, refValue );
return () => registry.unregisterSlot( name, refValue );
}, [ registry, name ] );

const fills: ReactNode[] = ( registry.getFills( name, ref.current ) ?? [] )
.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;
}
let childKey: Key = childIndex;
if (
typeof child === 'object' &&
'key' in child &&
child?.key
) {
childKey = child.key;
}

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 )
);

return <>{ isFunction( children ) ? children( fills ) : fills }</>;
}
} );
} )
.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 )
);

return <>{ isFunction( children ) ? children( fills ) : fills }</>;
}

const Slot = ( props: Omit< SlotComponentProps, 'bubblesVirtually' > ) => (
<SlotFillContext.Consumer>
{ ( { registerSlot, unregisterSlot, getFills } ) => (
<SlotComponent
{ ...props }
registerSlot={ registerSlot }
unregisterSlot={ unregisterSlot }
getFills={ getFills }
/>
) }
</SlotFillContext.Consumer>
);

export default Slot;
Loading
Loading