Skip to content

Commit fe1f7f0

Browse files
authored
refactor(useSlots): extract helpers, deduplicate matching logic
1 parent b168efd commit fe1f7f0

File tree

1 file changed

+57
-52
lines changed

1 file changed

+57
-52
lines changed

packages/react/src/hooks/useSlots.ts

Lines changed: 57 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@ import type {SlotMarker} from '../utils/types'
2323
* calls this 101 times per render). Key optimizations:
2424
*
2525
* 1. for-loop matching instead of findIndex (no closure allocation per child)
26-
* 2. Pre-computed isArrayMatcher[] (avoids Array.isArray in hot loop)
27-
* 3. Short-circuit: once all slots filled, skip matching entirely
26+
* 2. Short-circuit: once all slots filled, skip matching entirely
2827
* - In production: single integer comparison, straight to rest
2928
* - In dev: scans for duplicates to warn, then to rest
29+
* 3. Extracted childMatchesSlot() for shared matching logic (deduplicates
30+
* the match check across main loop, short-circuit, and duplicate paths)
3031
*
3132
* Flow per child:
3233
*
@@ -54,16 +55,15 @@ import type {SlotMarker} from '../utils/types'
5455
* 2. Component + test fn: { block: [Description, props => props.variant === 'block'] }
5556
*/
5657

57-
// Slot config: Component reference, or [Component, testFn] tuple
58+
// --- Types ---
59+
60+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
61+
type Props = any
5862
type ComponentMatcher = React.ElementType<Props>
5963
type ComponentAndPropsMatcher = [ComponentMatcher, (props: Props) => boolean]
6064

6165
export type SlotConfig = Record<string, ComponentMatcher | ComponentAndPropsMatcher>
6266

63-
// We don't know what the props are yet, we set them later based on slot config
64-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
65-
type Props = any
66-
6767
type SlotElements<Config extends SlotConfig> = {
6868
[Property in keyof Config]: SlotValue<Config, Property>
6969
}
@@ -78,31 +78,35 @@ type SlotValue<Config, Property extends keyof Config> = Config[Property] extends
7878
? React.ReactElement<React.ComponentPropsWithoutRef<ElementType>, ElementType>
7979
: never
8080

81+
// --- Matching ---
82+
83+
/** Check if a child element matches a slot config entry. */
84+
function childMatchesSlot(child: React.ReactElement, slotValue: ComponentMatcher | ComponentAndPropsMatcher): boolean {
85+
if (Array.isArray(slotValue)) {
86+
const [component, testFn] = slotValue
87+
return (child.type === component || isSlot(child, component as SlotMarker)) && testFn(child.props)
88+
}
89+
return child.type === slotValue || isSlot(child, slotValue as SlotMarker)
90+
}
91+
92+
// --- Hook ---
93+
8194
/** Extract slot components from children. See file header for details. */
8295
export function useSlots<Config extends SlotConfig>(
8396
children: React.ReactNode,
8497
config: Config,
8598
): [Partial<SlotElements<Config>>, React.ReactNode[]] {
86-
// Array of elements that are not slots
8799
const rest: React.ReactNode[] = []
88-
89100
const keys = Object.keys(config) as Array<keyof Config>
90101
const values = Object.values(config)
91102
const totalSlots = keys.length
92103

93-
// Object mapping slot names to their elements, initialized with undefined for each key
104+
// Initialize all slot keys to undefined so callers can check `slots.x === undefined`
94105
const slots: Partial<SlotElements<Config>> = {} as Partial<SlotElements<Config>>
95106
for (let i = 0; i < totalSlots; i++) {
96107
slots[keys[i]] = undefined
97108
}
98109

99-
// Pre-compute which slots use the [Component, testFn] matcher pattern
100-
// to avoid Array.isArray() checks in the hot inner loop
101-
const isArrayMatcher: boolean[] = new Array(totalSlots)
102-
for (let i = 0; i < totalSlots; i++) {
103-
isArrayMatcher[i] = Array.isArray(values[i])
104-
}
105-
106110
let slotsFound = 0
107111

108112
// eslint-disable-next-line github/array-foreach
@@ -112,65 +116,66 @@ export function useSlots<Config extends SlotConfig>(
112116
return
113117
}
114118

115-
// Fast path: once all slots are filled, skip matching entirely in production.
116-
// In dev, check for duplicates to warn.
119+
// Short-circuit: all slots filled, no more matching needed
117120
if (slotsFound === totalSlots) {
118-
if (__DEV__) {
119-
for (let i = 0; i < totalSlots; i++) {
120-
if (isArrayMatcher[i]) {
121-
const [component, testFn] = values[i] as ComponentAndPropsMatcher
122-
if ((child.type === component || isSlot(child, component as SlotMarker)) && testFn(child.props)) {
123-
warning(true, `Found duplicate "${String(keys[i])}" slot. Only the first will be rendered.`)
124-
return
125-
}
126-
} else {
127-
if (child.type === values[i] || isSlot(child, values[i] as SlotMarker)) {
128-
warning(true, `Found duplicate "${String(keys[i])}" slot. Only the first will be rendered.`)
129-
return
130-
}
131-
}
132-
}
121+
if (__DEV__ && warnIfDuplicate(child, keys, values, totalSlots)) {
122+
return
133123
}
134124
rest.push(child)
135125
return
136126
}
137127

138-
let matchedIndex = -1
139-
for (let i = 0; i < totalSlots; i++) {
140-
if (isArrayMatcher[i]) {
141-
const [component, testFn] = values[i] as ComponentAndPropsMatcher
142-
if ((child.type === component || isSlot(child, component as SlotMarker)) && testFn(child.props)) {
143-
matchedIndex = i
144-
break
145-
}
146-
} else {
147-
if (child.type === values[i] || isSlot(child, values[i] as SlotMarker)) {
148-
matchedIndex = i
149-
break
150-
}
151-
}
152-
}
128+
// Try to match child against an unfilled slot
129+
const matchedIndex = findMatchingSlot(child, values, totalSlots)
153130

154-
// If the child is not a slot, add it to the `rest` array
155131
if (matchedIndex === -1) {
156132
rest.push(child)
157133
return
158134
}
159135

160136
const slotKey = keys[matchedIndex]
161137

162-
// If slot is already filled, ignore duplicates
138+
// Duplicate: slot already filled by an earlier child
163139
if (slots[slotKey] !== undefined) {
164140
if (__DEV__) {
165141
warning(true, `Found duplicate "${String(slotKey)}" slot. Only the first will be rendered.`)
166142
}
167143
return
168144
}
169145

170-
// If the child is a slot, add it to the `slots` object
171146
slots[slotKey] = child as SlotValue<Config, keyof Config>
172147
slotsFound++
173148
})
174149

175150
return [slots, rest]
176151
}
152+
153+
// --- Helpers ---
154+
155+
/** Find the index of the first slot config entry that matches this child, or -1. */
156+
function findMatchingSlot(
157+
child: React.ReactElement,
158+
values: Array<ComponentMatcher | ComponentAndPropsMatcher>,
159+
totalSlots: number,
160+
): number {
161+
for (let i = 0; i < totalSlots; i++) {
162+
if (childMatchesSlot(child, values[i])) return i
163+
}
164+
return -1
165+
}
166+
167+
/** Warn in dev if a child matches any slot (used after all slots are filled). Returns true if duplicate found. */
168+
function warnIfDuplicate(
169+
child: React.ReactElement,
170+
keys: Array<string | number | symbol>,
171+
values: Array<ComponentMatcher | ComponentAndPropsMatcher>,
172+
totalSlots: number,
173+
): boolean {
174+
for (let i = 0; i < totalSlots; i++) {
175+
if (childMatchesSlot(child, values[i])) {
176+
warning(true, `Found duplicate "${String(keys[i])}" slot. Only the first will be rendered.`)
177+
return true
178+
}
179+
}
180+
return false
181+
}

0 commit comments

Comments
 (0)