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: use observableMap everywhere, remove manual rerendering #67400

Open
wants to merge 2 commits into
base: trunk
Choose a base branch
from

Conversation

jsnajdr
Copy link
Member

@jsnajdr jsnajdr commented Nov 28, 2024

Rewrites the base (bubblesVirtually=false, non-portal) version of SlotFill to use observableMap for storing slots and fills. This removes the need for all manual rerenders. The observable maps will notify their consumers automatically whenever something interesting and rerender-worthy happens.

With this PR, both variants of SlotFill become implemented in a very similar way.

There are observable maps for slots and fills. Each is registering the same base info (instance id) and some additional implementation-specific data:

  • base fill stores children that the slot should render
  • portal slot stores fillProps that the fill uses during rendering, and the DOM element into which the fill is rendered

The context has methods for registering/unregistering slots and fills, and additional methods:

  • base context has updateFill method that updates the children in registry on Fill rerender
  • portal context has updateSlot method that updates the element/fillProps in registry on Slot rerender

@jsnajdr jsnajdr added [Type] Code Quality Issues or PRs that relate to code quality [Package] Components /packages/components labels Nov 28, 2024
@jsnajdr jsnajdr self-assigned this Nov 28, 2024
@jsnajdr jsnajdr force-pushed the update/slotfill-use-observable-map branch from 56eb04b to 3956f58 Compare November 30, 2024 08:51
Copy link

github-actions bot commented Nov 30, 2024

Flaky tests detected in 2f69488.
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/12123240256
📝 Reported issues:

@jsnajdr jsnajdr force-pushed the update/slotfill-use-observable-map branch 2 times, most recently from 2f69488 to c0cae70 Compare December 3, 2024 11:01
@jsnajdr jsnajdr force-pushed the update/slotfill-use-observable-map branch from c0cae70 to f2321f4 Compare December 5, 2024 12:12
@jsnajdr jsnajdr marked this pull request as ready for review December 5, 2024 12:22
@jsnajdr jsnajdr requested a review from ajitbohra as a code owner December 5, 2024 12:22
Copy link

github-actions bot commented Dec 5, 2024

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: jsnajdr <[email protected]>
Co-authored-by: ciampo <[email protected]>
Co-authored-by: Mamaduka <[email protected]>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

registerSlot: () => {},
unregisterSlot: () => {},
registerFill: () => {},
unregisterFill: () => {},
getSlot: () => undefined,
getFills: () => [],
subscribe: () => () => {},
Copy link
Member Author

Choose a reason for hiding this comment

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

getSlot and getFills are replaced by simple map lookups: slots.get( name ). The subscribe method is also a method of the observable map now.

import type { FillComponentProps } from './types';

export default function Fill( { name, children }: FillComponentProps ) {
const registry = useContext( SlotFillContext );
const slot = useSlot( name );
Copy link
Member Author

Choose a reason for hiding this comment

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

The Fill no longer needs the slot value because it no longer calls slot.rerender(). Instead, it updates the value in the fills map (updateFill call) and the relevant slot rerenders automatically because it listens for fills changes.

Copy link
Contributor

Choose a reason for hiding this comment

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

The fact that there were two separate useSlot hooks was definitely confusing. Glad to see that the "non-bubblesVirtually" version is going away

const ref = useRef( {
name,
children,
} );
Copy link
Member Author

Choose a reason for hiding this comment

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

Here we used to store name in the fills map but that was redundant. name is a key in the map. It doesn't need to be stored also in the value.

if ( slot ) {
slot.rerender();
}
}, [ slot, children ] );
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 code used to store the new value of children into a ref.current. But that's a "silent" mutation that doesn't trigger any listeners. That's why the code had to do a manual slot.rerender() call. The new code updates the children using the observableMap.set method (hidden inside the updateFill method). Because the slot listens for fills updates via useObservableValue( fills, name ), it gets notified and rerenders automatically.

if ( previousSlot ) {
previousSlot.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.

All this code collapses into a simple slots.set call:

  • triggerListeners is built-in in slots.set.
  • the forceUpdateSlot logic is built-in into useSyncExternalStore. It handles the situation when the store is updated after the consumer component is rendered and before it subscribes to updates in an effect.
  • previousSlot.rerender() happens automatically because the old slot calls the currentSlot = useObservableValue( slots, name ) hook and gets notified when a new slot is registered under the same name.

Copy link
Contributor

Choose a reason for hiding this comment

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

I love how elegant this is ! I wonder if we should document it somewhere, though, since otherwise there would be a lot of implicit knowledge required to fully understand the component.

// Fills should only be returned for the current instance of the slot
// in which they occupy.
if ( slots[ name ] !== slotInstance ) {
return [];
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 logic where the fills are [] for a slot instance that has been overwritten in the store by a new instance, it has moved into the Slot component.

key: childKey,
} );
} );
}
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 simple refactor where the addKeysToChildren function has been extracted from the big Slot component. Without any change.

@@ -84,6 +84,10 @@ export type SlotComponentProps =
style?: never;
} );

export type FillChildren =
| ReactNode
| ( ( fillProps: FillProps ) => ReactNode );
Copy link
Member Author

Choose a reason for hiding this comment

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

Extracted this as a separate type because it's used in the fills map.

Copy link
Contributor

@ciampo ciampo left a comment

Choose a reason for hiding this comment

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

Thank you for working on this! I find it exciting to see how, little by little, SlotFill code is getting simpler, and the two implementations are getting closer to each other — I'm hoping we will be able to merge them into one single implementation at some point.

Apart from smoke testing Storybook and the editor, I found it hard to test the changes. I wonder if, as a follow-up, we should also spend some time to bolster the suite of unit tests (which is weirdly still written in JS and not in TS).

slot.rerender();
}
}, [ slot, children ] );
registry.updateFill( name, instanceRef.current, childrenRef.current );
Copy link
Contributor

Choose a reason for hiding this comment

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

Is it still necessary to wrap the updateFill call inside a useLayoutEffect, especially given that the hook doesn't have a dependency list anymore?

if ( previousSlot ) {
previousSlot.rerender();
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I love how elegant this is ! I wonder if we should document it somewhere, though, since otherwise there would be a lot of implicit knowledge required to fully understand the component.

const slots = observableMap< SlotKey, BaseSlotInstance >();
const fills = observableMap<
SlotKey,
{ instance: FillInstance; children: FillChildren }[]
Copy link
Contributor

Choose a reason for hiding this comment

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

Any reason why here (and in types.ts) we stopped using FillComponentProps ?

@Mamaduka
Copy link
Member

I wonder if, as a follow-up, we should also spend some time to bolster the suite of unit tests (which is weirdly still written in JS and not in TS).

I think that would be wonderful. A good unit test coverage for SlotFills is crucial.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Package] Components /packages/components [Type] Code Quality Issues or PRs that relate to code quality
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants