Skip to content

Commit 8f048b0

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

File tree

1 file changed

+65
-56
lines changed

1 file changed

+65
-56
lines changed

packages/react/src/hooks/useSlots.ts

Lines changed: 65 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,11 @@ import type {SlotMarker} from '../utils/types'
2020
* +----------------+
2121
*
2222
* Performance-sensitive: called per item in lists (e.g. 100-item ActionList
23-
* calls this 101 times per render). Key optimizations:
23+
* calls this 101 times per render).
2424
*
25-
* 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
28-
* - In production: single integer comparison, straight to rest
29-
* - In dev: scans for duplicates to warn, then to rest
25+
* Once all slots are filled, remaining children skip matching entirely in
26+
* production (single integer comparison). In dev, we still scan for duplicates
27+
* to emit a warning.
3028
*
3129
* Flow per child:
3230
*
@@ -54,16 +52,15 @@ import type {SlotMarker} from '../utils/types'
5452
* 2. Component + test fn: { block: [Description, props => props.variant === 'block'] }
5553
*/
5654

57-
// Slot config: Component reference, or [Component, testFn] tuple
55+
// --- Types ---
56+
57+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
58+
type Props = any
5859
type ComponentMatcher = React.ElementType<Props>
5960
type ComponentAndPropsMatcher = [ComponentMatcher, (props: Props) => boolean]
6061

6162
export type SlotConfig = Record<string, ComponentMatcher | ComponentAndPropsMatcher>
6263

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-
6764
type SlotElements<Config extends SlotConfig> = {
6865
[Property in keyof Config]: SlotValue<Config, Property>
6966
}
@@ -78,31 +75,35 @@ type SlotValue<Config, Property extends keyof Config> = Config[Property] extends
7875
? React.ReactElement<React.ComponentPropsWithoutRef<ElementType>, ElementType>
7976
: never
8077

78+
// --- Matching ---
79+
80+
/** Check if a child element matches a slot config entry, either by direct type comparison or slot symbol. */
81+
function childMatchesSlot(child: React.ReactElement, slotValue: ComponentMatcher | ComponentAndPropsMatcher): boolean {
82+
if (Array.isArray(slotValue)) {
83+
const [component, testFn] = slotValue
84+
return (child.type === component || isSlot(child, component as SlotMarker)) && testFn(child.props)
85+
}
86+
return child.type === slotValue || isSlot(child, slotValue as SlotMarker)
87+
}
88+
89+
// --- Hook ---
90+
8191
/** Extract slot components from children. See file header for details. */
8292
export function useSlots<Config extends SlotConfig>(
8393
children: React.ReactNode,
8494
config: Config,
8595
): [Partial<SlotElements<Config>>, React.ReactNode[]] {
86-
// Array of elements that are not slots
8796
const rest: React.ReactNode[] = []
88-
8997
const keys = Object.keys(config) as Array<keyof Config>
9098
const values = Object.values(config)
9199
const totalSlots = keys.length
92100

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

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-
106107
let slotsFound = 0
107108

108109
// eslint-disable-next-line github/array-foreach
@@ -112,65 +113,73 @@ export function useSlots<Config extends SlotConfig>(
112113
return
113114
}
114115

115-
// Fast path: once all slots are filled, skip matching entirely in production.
116-
// In dev, check for duplicates to warn.
116+
// Short-circuit: all slots filled, no more matching needed
117117
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-
}
118+
if (__DEV__ && warnIfDuplicate(child, keys, values, totalSlots)) {
119+
return
133120
}
134121
rest.push(child)
135122
return
136123
}
137124

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-
}
125+
// Try to match child against a slot
126+
const matchedIndex = findMatchingSlot(child, values, totalSlots)
153127

154-
// If the child is not a slot, add it to the `rest` array
155128
if (matchedIndex === -1) {
156129
rest.push(child)
157130
return
158131
}
159132

160133
const slotKey = keys[matchedIndex]
161134

162-
// If slot is already filled, ignore duplicates
135+
// Duplicate: slot already filled by an earlier child
163136
if (slots[slotKey] !== undefined) {
164137
if (__DEV__) {
165138
warning(true, `Found duplicate "${String(slotKey)}" slot. Only the first will be rendered.`)
166139
}
167140
return
168141
}
169142

170-
// If the child is a slot, add it to the `slots` object
171143
slots[slotKey] = child as SlotValue<Config, keyof Config>
172144
slotsFound++
173145
})
174146

175147
return [slots, rest]
176148
}
149+
150+
// --- Helpers ---
151+
152+
/**
153+
* Find the first slot config entry matching this child.
154+
* Returns the config index, or -1 if no match.
155+
*/
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+
/**
168+
* Dev-only: check if a child duplicates an already-filled slot.
169+
* Returns true (and warns) if a duplicate is found, false otherwise.
170+
* Used in the short-circuit path where all slots are already filled.
171+
*/
172+
function warnIfDuplicate(
173+
child: React.ReactElement,
174+
keys: Array<string | number | symbol>,
175+
values: Array<ComponentMatcher | ComponentAndPropsMatcher>,
176+
totalSlots: number,
177+
): boolean {
178+
for (let i = 0; i < totalSlots; i++) {
179+
if (childMatchesSlot(child, values[i])) {
180+
warning(true, `Found duplicate "${String(keys[i])}" slot. Only the first will be rendered.`)
181+
return true
182+
}
183+
}
184+
return false
185+
}

0 commit comments

Comments
 (0)