-
Notifications
You must be signed in to change notification settings - Fork 4.2k
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
Conversation
56eb04b
to
3956f58
Compare
Flaky tests detected in 2f69488. 🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/12123240256
|
c0cae70
to
f2321f4
Compare
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 If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.
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: () => () => {}, |
There was a problem hiding this comment.
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 ); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The removed useSlot
hook was essentially a useSyncExternalStore
call. Now when the slot registry uses observableMap
, it can be fully replaced by useObservableMap
, which is also a thin wrapper around useSyncExternalStore
.
const ref = useRef( { | ||
name, | ||
children, | ||
} ); |
There was a problem hiding this comment.
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 ] ); |
There was a problem hiding this comment.
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(); | ||
} | ||
} |
There was a problem hiding this comment.
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 inslots.set
.- the
forceUpdateSlot
logic is built-in intouseSyncExternalStore
. 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 thecurrentSlot = useObservableValue( slots, name )
hook and gets notified when a new slot is registered under the same name.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now when we use an observable store for slots
and observe it with useSyncExternalStore
, there isn't really much to document. You just use a native hook and it does all the work for you.
The previous implementation was convoluted because:
- the
Slot
component was not subscribed to changes in theslots
registry. OnlyFill
was. That's why functions in the provider had to manually callslot.rerender()
on every relevant event. That's not replaced withuseSyncExternalStore
/useObservableMap
with automatically rerenders theSlot
component whenever the entry inslots
changes. - it manually handled the situation when store was updated between a render (reading a value from store) and the effect that subscribed to updates. Then the update was missed. Before
useSyncExternalStore
, you needed to be aware of such gotchas. For example, the Reduxconnect
implementation had to handle this, too. But today we have a native hook that does it all for us. It handles it in this code:
// Fills should only be returned for the current instance of the slot | ||
// in which they occupy. | ||
if ( slots[ name ] !== slotInstance ) { | ||
return []; |
There was a problem hiding this comment.
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, | ||
} ); | ||
} ); | ||
} |
There was a problem hiding this comment.
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 ); |
There was a problem hiding this comment.
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.
There was a problem hiding this 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 ); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, the purpose of the updateFill
is to store the latest children
into the fills
map in the registry on every rerender of the Fill
. It might have been rerendered with new children
.
It's an effect because it's a side effect. We want to "commit" the updateFill
change only when the output render is really committed to the DOM. That's why it can't be called directly during render.
if ( previousSlot ) { | ||
previousSlot.rerender(); | ||
} | ||
} |
There was a problem hiding this comment.
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 }[] |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FillComponentProps
also includes the name
. But we don't need name
in the value in the map, because name
is already the key. Also FillComponentsProps
don't include the instance
field.
I should give some nice name to the { instance, children }
type. It's similar to FillComponentProps
, but not identical.
I think that would be wonderful. A good unit test coverage for SlotFills is crucial. |
The existing tests for SlotFill are not bad. They caught one mistake I made in this PR. And as all the complications are being removed and replaced with |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I personally don't have more feedback at this point, and I think we could go ahead and merge this PR (this potential change is non-blocking IMO).
The existing tests for SlotFill are not bad. They caught one mistake I made in this PR.
And as all the complications are being removed and replaced with
useSyncExternalStore
anduseObservableMap
, there is less motivation to do unit testing. The more subtle unit tests would be in fact testing the nativeuseSyncExternalStore
hook, which obviously works 🙂
I'd still like to have the unit tests refactored to TS as a nice follow-up
f2321f4
to
441ca28
Compare
Rewrites the base (
bubblesVirtually=false
, non-portal) version ofSlotFill
to useobservableMap
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:
children
that the slot should renderfillProps
that the fill uses during rendering, and the DOM element into which the fill is renderedThe context has methods for registering/unregistering slots and fills, and additional methods:
updateFill
method that updates thechildren
in registry onFill
rerenderupdateSlot
method that updates the element/fillProps
in registry onSlot
rerender