fix(signals): treat a plain merge() result as an ordinary source (#3384) - #3401
Conversation
merge()'s plain-object form stamped its flattened sources on the result
under the $SOURCES symbol, and a later merge() flattened through them
instead of reading the object. The result is a real object callers copy
(Reflect.ownKeys descriptor copies, {...props}) or mutate afterwards
(@solidjs/html assigns props and a children getter after spreading), so
the re-merge resurrected removed keys and dropped added or overwritten
ones. Nothing short of re-walking the object can validate the sources,
so the plain form now records none and is read like any other source;
only merge proxies, whose writes are no-ops, are still flattened.
Side effect: merge(defaults, props) with props a plain merge result that
covers every default now returns props instead of always allocating —
flattening used to defeat the covered-source shortcut.
Interleaved A/B (old vs new dist, same process): plain construct -3%,
nested-not-covered construct +5–10%, nested-covered construct -48%,
reads and proxy paths within noise.
Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: 9692c81 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Coverage Report for CI Build 34748640945Warning Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes. Coverage remained the same at 71.842%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
Merging this PR will regress 4 benchmarks
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | merge |
264.5 µs | 330.4 µs | -19.93% |
| ❌ | merge |
227.2 µs | 267.3 µs | -15.02% |
| ❌ | merge |
265 µs | 287.2 µs | -7.73% |
| ❌ | merge |
74 µs | 78 µs | -5.18% |
| ⚡ | projection derive: write one NESTED field (reference) |
830.9 µs | 222 µs | ×3.7 |
| ⚡ | merge |
45 µs | 31 µs | +44.93% |
| ⚡ | construct |
56.5 µs | 39.2 µs | +44.23% |
| ⚡ | merge |
46.6 µs | 32.4 µs | +43.66% |
| ⚡ | construct |
56.6 µs | 39.6 µs | +42.96% |
| ⚡ | merge |
45.4 µs | 31.8 µs | +42.72% |
| ⚡ | construct |
56.7 µs | 39.7 µs | +42.6% |
| ⚡ | construct |
58.6 µs | 41.9 µs | +39.85% |
| ⚡ | merge |
49.2 µs | 35.6 µs | +38.25% |
| ⚡ | merge |
48.6 µs | 35.2 µs | +37.99% |
| ⚡ | merge |
67.6 µs | 54.4 µs | +24.27% |
| ⚡ | merge |
68 µs | 54.8 µs | +24.06% |
| ⚡ | merge |
72.2 µs | 60.4 µs | +19.54% |
| ⚡ | merge |
55.1 µs | 46.4 µs | +18.85% |
| ⚡ | merge |
54.3 µs | 45.7 µs | +18.71% |
| ⚡ | merge |
87.6 µs | 74.4 µs | +17.73% |
| ... | ... | ... | ... | ... |
ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing fix/merge-stale-sources (9692c81) with next (042b540)
7b50875 to
9692c81
Compare
|
Fixes #3384.
Problem
merge()'s plain-object form recorded its flattened sources on the result under the$SOURCESsymbol, and a latermerge()flattened through them instead of reading the object. But that result is a real object callers copy or mutate afterwards:Reflect.ownKeyscarry the symbol along (this is what@yak/solidPR What about to use `lastChild` and `previousSibling` as well? #644'scopyPropsdoes, so$-prefixed props leaked onto DOM elements and the computedclasswas lost whenever the target spread{...props});{...props}copies carry it too (enumerable symbol);@solidjs/html(tagged-jsx.ts) callsmergeProps(props, spread)and then assigns later props and achildrengetter onto the result beforecreateComponent— a component's ownmerge(defaults, props)then dropped them.No cheap check validates the recorded sources (a same-key value overwrite is invisible to key counts or owner identity), so the plain form can't be trusted at all.
Fix
The plain-object result records nothing and is treated as an ordinary source by a nested
merge. Only merge proxies, whose writes are no-ops, are still flattened through — that is also allmergeSources()(used byspread()) ever returned, so no consumer changes.omit()proxies keep answering$SOURCESwithundefined(#3014).Side effect worth knowing:
merge(defaults, props)wherepropsis a plain merge result covering every default now returnspropsdirectly — flattening used to defeat the "all keys covered → return the source" shortcut, so that common component pattern always allocated before.Tests
Four new tests in
utilities.test.ts; three fail onnext:$activegone,classpresent;{...props}copy with a deleted and an overridden key;defineProperty children);mergeSources(outer)is the flat list), plain results yieldundefined.Suites: signals 1769, solid 595, html 200, web dom 735 / server 790 / hydrate 169 — all green.
Performance
Interleaved A/B, old vs new
dist/prodin one process, 41 alternating rounds, ratio new/old:merge(defaults, props5)constructmerge(props12, {children})constructmerge(inner, {c}), not coveredmerge(defaults, inner), coveredomitThe one regression is the non-covered nested plain merge: it walks the intermediate result (bound getters, re-bound once more) instead of the original sources. The covered case, which is the
merge(defaults, props)shape, no longer allocates at all.