Skip to content

Commit

Permalink
SlotFill: use observableMap everywhere, remove manual rerendering (#6…
Browse files Browse the repository at this point in the history
…7400)

* SlotFill: use observableMap in base version

* Add changelog entry
  • Loading branch information
jsnajdr authored Dec 13, 2024
1 parent 9bdbada commit 8066995
Show file tree
Hide file tree
Showing 7 changed files with 143 additions and 145 deletions.
4 changes: 4 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@

- Add new `Badge` component ([#66555](https://github.com/WordPress/gutenberg/pull/66555)).

### Internal

- `SlotFill`: rewrite the non-portal version to use `observableMap` ([#67400](https://github.com/WordPress/gutenberg/pull/67400)).

## 29.0.0 (2024-12-11)

### Breaking Changes
Expand Down
8 changes: 5 additions & 3 deletions packages/components/src/slot-fill/context.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
/**
* WordPress dependencies
*/
import { observableMap } from '@wordpress/compose';
import { createContext } from '@wordpress/element';

/**
* Internal dependencies
*/
import type { BaseSlotFillContext } from './types';

const initialValue: BaseSlotFillContext = {
slots: observableMap(),
fills: observableMap(),
registerSlot: () => {},
unregisterSlot: () => {},
registerFill: () => {},
unregisterFill: () => {},
getSlot: () => undefined,
getFills: () => [],
subscribe: () => () => {},
updateFill: () => {},
};
export const SlotFillContext = createContext( initialValue );

Expand Down
25 changes: 10 additions & 15 deletions packages/components/src/slot-fill/fill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,26 @@ import { useContext, useLayoutEffect, useRef } from '@wordpress/element';
* Internal dependencies
*/
import SlotFillContext from './context';
import useSlot from './use-slot';
import type { FillComponentProps } from './types';

export default function Fill( { name, children }: FillComponentProps ) {
const registry = useContext( SlotFillContext );
const slot = useSlot( name );
const instanceRef = useRef( {} );
const childrenRef = useRef( children );

const ref = useRef( {
name,
children,
} );
useLayoutEffect( () => {
childrenRef.current = children;
}, [ children ] );

useLayoutEffect( () => {
const refValue = ref.current;
refValue.name = name;
registry.registerFill( name, refValue );
return () => registry.unregisterFill( name, refValue );
const instance = instanceRef.current;
registry.registerFill( name, instance, childrenRef.current );
return () => registry.unregisterFill( name, instance );
}, [ registry, name ] );

useLayoutEffect( () => {
ref.current.children = children;
if ( slot ) {
slot.rerender();
}
}, [ slot, children ] );
registry.updateFill( name, instanceRef.current, childrenRef.current );
} );

return null;
}
127 changes: 63 additions & 64 deletions packages/components/src/slot-fill/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,103 +8,102 @@ import { useState } from '@wordpress/element';
*/
import SlotFillContext from './context';
import type {
FillComponentProps,
FillInstance,
FillChildren,
BaseSlotInstance,
BaseSlotFillContext,
SlotFillProviderProps,
SlotKey,
Rerenderable,
} from './types';
import { observableMap } from '@wordpress/compose';

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

function registerSlot( name: SlotKey, slot: Rerenderable ) {
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.rerender();
}
}

function registerFill( name: SlotKey, instance: FillComponentProps ) {
fills[ name ] = [ ...( fills[ name ] || [] ), instance ];
forceUpdateSlot( name );
const slots = observableMap< SlotKey, BaseSlotInstance >();
const fills = observableMap<
SlotKey,
{ instance: FillInstance; children: FillChildren }[]
>();

function registerSlot( name: SlotKey, instance: BaseSlotInstance ) {
slots.set( name, instance );
}

function unregisterSlot( name: SlotKey, instance: Rerenderable ) {
function unregisterSlot( name: SlotKey, instance: BaseSlotInstance ) {
// 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 ) {
if ( slots.get( name ) !== instance ) {
return;
}

delete slots[ name ];
triggerListeners();
slots.delete( name );
}

function unregisterFill( name: SlotKey, instance: FillComponentProps ) {
fills[ name ] =
fills[ name ]?.filter( ( fill ) => fill !== instance ) ?? [];
forceUpdateSlot( name );
function registerFill(
name: SlotKey,
instance: FillInstance,
children: FillChildren
) {
fills.set( name, [
...( fills.get( name ) || [] ),
{ instance, children },
] );
}

function getSlot( name: SlotKey ): Rerenderable | undefined {
return slots[ name ];
function unregisterFill( name: SlotKey, instance: FillInstance ) {
const fillsForName = fills.get( name );
if ( ! fillsForName ) {
return;
}

fills.set(
name,
fillsForName.filter( ( fill ) => fill.instance !== instance )
);
}

function getFills(
function updateFill(
name: SlotKey,
slotInstance: Rerenderable
): FillComponentProps[] {
// Fills should only be returned for the current instance of the slot
// in which they occupy.
if ( slots[ name ] !== slotInstance ) {
return [];
instance: FillInstance,
children: FillChildren
) {
const fillsForName = fills.get( name );
if ( ! fillsForName ) {
return;
}
return fills[ name ];
}

function forceUpdateSlot( name: SlotKey ) {
const slot = getSlot( name );

if ( slot ) {
slot.rerender();
const fillForInstance = fillsForName.find(
( f ) => f.instance === instance
);
if ( ! fillForInstance ) {
return;
}
}

function triggerListeners() {
listeners.forEach( ( listener ) => listener() );
}

function subscribe( listener: () => void ) {
listeners.push( listener );
if ( fillForInstance.children === children ) {
return;
}

return () => {
listeners = listeners.filter( ( l ) => l !== listener );
};
fills.set(
name,
fillsForName.map( ( f ) => {
if ( f.instance === instance ) {
// Replace with new record with updated `children`.
return { instance, children };
}

return f;
} )
);
}

return {
slots,
fills,
registerSlot,
unregisterSlot,
registerFill,
unregisterFill,
getSlot,
getFills,
subscribe,
updateFill,
};
}

Expand Down
63 changes: 38 additions & 25 deletions packages/components/src/slot-fill/slot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import type { ReactElement, ReactNode, Key } from 'react';
/**
* WordPress dependencies
*/
import { useObservableValue } from '@wordpress/compose';
import {
useContext,
useEffect,
useReducer,
useRef,
Children,
cloneElement,
Expand All @@ -32,41 +32,48 @@ function isFunction( maybeFunc: any ): maybeFunc is Function {
return typeof maybeFunc === 'function';
}

function addKeysToChildren( children: ReactNode ) {
return Children.map( children, ( 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 instanceRef = useRef( {} );

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

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

const fills: ReactNode[] = ( registry.getFills( name, ref.current ) ?? [] )
let fills = useObservableValue( registry.fills, name ) ?? [];
const currentSlot = useObservableValue( registry.slots, name );

// Fills should only be rendered in the currently registered instance of the slot.
if ( currentSlot !== instanceRef.current ) {
fills = [];
}

const renderedFills = fills
.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,
} );
} );
return addKeysToChildren( fillChildren );
} )
.filter(
// In some cases fills are rendered only when some conditions apply.
Expand All @@ -75,7 +82,13 @@ function Slot( props: Omit< SlotComponentProps, 'bubblesVirtually' > ) {
( element ) => ! isEmptyElement( element )
);

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

export default Slot;
Loading

1 comment on commit 8066995

@github-actions
Copy link

Choose a reason for hiding this comment

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

Flaky tests detected in 8066995.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/12323234912
📝 Reported issues:

Please sign in to comment.