/** * Composing props from several sources. * * Forwarding a component's leftover props onto its root element is plain JS — * `const { color, ...rest } = ctx.props` then `; * ``` * * The returned object resolves on read, so thunk sources stay reactive: a * render that spreads it reads through to `ctx.props` and tracks as usual. * Calling `mergeProps` **once in setup** also keeps the derived `ref` and * chained handlers identity-stable across renders — rebuilding them per render * hands the renderer a fresh function every time, which makes it tear down and * re-apply refs for no reason. */ export function mergeProps(...sources: MergeSource[]): Record { // Cache for derived values that must keep a stable identity while their // contributors do. Keyed by output key; invalidated by comparing the // contributing functions one by one. const derived = new Map(); function cachedDerive(key: string, parts: any[], build: () => any): any { const hit = derived.get(key); if (hit && hit.parts.length === parts.length && hit.parts.every((p, i) => p === parts[i])) { return hit.value; } const value = build(); derived.set(key, { parts, value }); return value; } /** * Walk every source once, resolving what the merged object looks like * right now. Reads happen here, so a spread of the result tracks whatever * the thunks touch. */ function collect(): { plain: Map; classParts: string[]; styleParts: unknown[]; handlers: Map; refs: any[]; } { const plain = new Map(); const classParts: string[] = []; const styleParts: unknown[] = []; const handlers = new Map(); const refs: any[] = []; // `Object.keys`, not `for…in`: object spread copies own enumerable // properties only, and this promises to behave exactly like a spread. // `for…in` would walk the prototype chain and forward inherited keys // the spread it replaces never would. const resolved = sources.map(resolve); // Pass 1 — which events actually have a handler? A key only joins a // handler group if SOME source gave that event a function. Without // this a plain data prop that happens to start with `on` (`once`) // would be grouped by its accidental event name, and — worse — a key // could land in both `plain` and a group depending on source order, // making `ownKeys` return it twice, which throws. const eventsWithHandlers = new Set(); for (const props of resolved) { if (!props) continue; for (const key of Object.keys(props)) { if (isHandlerShapedKey(key) && typeof props[key] === 'function') { eventsWithHandlers.add(eventNameOf(key)); } } } // Pass 2 — bucket every key exactly once. for (const props of resolved) { if (!props) continue; for (const key of Object.keys(props)) { const value = props[key]; if (isClassKey(key)) { if (value) classParts.push(String(value)); continue; } if (key === 'style') { if (value) styleParts.push(value); continue; } if (key === 'ref') { if (value) refs.push(value); continue; } if (isHandlerShapedKey(key) && eventsWithHandlers.has(eventNameOf(key))) { const event = eventNameOf(key); const group = handlers.get(event); // First spelling seen owns the output key, so the merged // object carries one handler prop per event no matter how // many spellings arrived. Non-function values are kept in // the same ordered list — see `read`, where a later one // overwrites the handlers before it, exactly as a spread // would. if (group) group.entries.push(value); else handlers.set(event, { key, entries: [value] }); continue; } // Exact spread semantics: an own key wins even when its value // is undefined. plain.set(key, value); } } return { plain, classParts, styleParts, handlers, refs }; } function keysOf(state: ReturnType): string[] { const keys = [...state.plain.keys()]; if (state.classParts.length) keys.push('class'); if (state.styleParts.length) keys.push('style'); if (state.refs.length) keys.push('ref'); for (const group of state.handlers.values()) keys.push(group.key); return keys; } function read(key: string, state: ReturnType): any { if (isClassKey(key)) { return state.classParts.length ? state.classParts.join(' ') : undefined; } if (key === 'style') { if (!state.styleParts.length) return undefined; const merged: Record = {}; for (const part of state.styleParts) Object.assign(merged, toStyleObject(part)); return merged; } if (key === 'ref') { const refs = state.refs; if (!refs.length) return undefined; if (refs.length === 1) return refs[0]; return cachedDerive('ref', refs, () => (value: any) => { for (const ref of refs) applyRef(ref, value); }); } for (const group of state.handlers.values()) { if (group.key !== key) continue; // A non-function value overwrites everything before it — that is // what a spread would do — so only the run of functions after the // last one chains. `[fn, undefined]` is `undefined`; // `['x', fn1, fn2]` chains fn1 then fn2. const entries = group.entries; let from = entries.length; while (from > 0 && typeof entries[from - 1] === 'function') from--; if (from === entries.length) return entries[from - 1]; const fns = from === 0 ? entries : entries.slice(from); if (fns.length === 1) return fns[0]; return cachedDerive(key, fns, () => (...args: any[]) => { for (const fn of fns) fn(...args); }); } return state.plain.get(key); } return new Proxy({} as Record, { get(_target, key) { if (typeof key === 'symbol') return undefined; return read(key, collect()); }, has(_target, key) { if (typeof key === 'symbol') return false; return keysOf(collect()).includes(key); }, ownKeys() { return keysOf(collect()); }, getOwnPropertyDescriptor(_target, key) { if (typeof key === 'symbol') return undefined; const state = collect(); if (!keysOf(state).includes(key)) return undefined; return { value: read(key, state), enumerable: true, configurable: true, writable: false }; } }); }