Signals: Add Additional Safety with Freeze & Dev Only Warnings#150
Signals: Add Additional Safety with Freeze & Dev Only Warnings#150
Conversation
Foundation for three-mode signal safety (freeze/reference/none). defaultSafety starts at 'freeze'; setDefaultSafety validates against the valid set. No consumers yet — the Signal class will wire up in a follow-up.
Three-mode safety: 'freeze' (default), 'reference', 'none'. Reads return the protected ref directly; no more per-read cloning. Mutations on frozen values throw at the call site instead of being silently swallowed. - protect() gate: freeze → deepFreeze, otherwise pass-through - value getter/peek() return currentValue directly (no readCopy) - get() loses its options form — use .peek() or .clone() for escapes - new .clone() method returns a deep copy; tracked like .get() - in-place helpers (push/unshift/splice/setIndex/removeIndex/ setArrayProperty/setProperty) branch on safety so reference/none retain O(1) mutation; freeze path rebuilds via spread - mutate(fn): return-value path sets; undefined path notifies (in-place mutation only works under reference/none; throws on freeze) - legacy allowClone: false maps to 'reference' for back-compat Reactivity package tests pass. Downstream callsites that mutated through peek() or the data context will surface as loud failures under freeze; those need per-callsite fixes in follow-up commits.
Replace mutate-in-map with spread-in-map so enrichment helpers don't mutate the caller's input. Two callsites surfaced under freeze-on-set: - mobile-menu addNavIcons: was mutating item.navIcon on items from settings.menu, silently modifying the caller's menu tree - nav-menu addSelectedIndex: was mutating item.selectedIndex on items from settings.menu, same issue; inner .map passes now use the returned value instead of relying on side effects
Build: Discover runs all benchmarkable packages, not just touched ones PR #148 (signal-safety refactor under packages/reactivity) exposed the per-package scoping bug in discover. The prior logic looked up tachometer configs inside the specific changed package and ran their matrix cells — but every tachometer config in this repo lives under packages/renderer, so a PR that only changes packages/reactivity resolved to an empty matrix and the bench workflow skipped silently. This is wrong in the base case: reactivity imports renderer consumes via workspace symlinks, so a signal-path change moves renderer's bench numbers. Same for templating → rendering, utils → everything, etc. The benches are end-to-end measurements that pull the whole framework through a workload; treating them as scoped-by-touched-package was premature. New logic: any trigger that reaches this workflow (push to main, or a pull_request that matched our path filter) runs every tachometer-ci*.json it finds under packages/*/bench/tachometer/. Path filter already restricts triggers to perf-relevant changes, so this doesn't over-fire. The push-branch and pull-request branches collapse to the same one-liner, which is a good sign. Ship.
Surfaces when a signal-backed data blob flows into the templating
boundary under freeze-default:
- extend was copying source property descriptors verbatim, so a frozen
source produced a fresh target with {writable: false, configurable:
false} on every property. Target was extensible but key-level writes
threw. Now extend installs a fresh writable descriptor for data
properties and only preserves descriptors for accessor (getter/setter)
properties.
- template.setDataContext thaws this.data lazily when it arrives frozen
(via a Template clone with the frozen data argument); assignInPlace
then mutates in place as before, preserving reference identity and
key-level diffing semantics that the spurious-reactivity guards
depend on.
- ssr-hydration test updated to clone the array before mutating (the
old pattern silently mutated the peek() return).
…tic-Next into perf/signal-safety-v2
Static surface for configuring defaults, discoverable via Signal.* tab
completion and validated on assignment:
- Signal.equalityFunction / Signal.cloneFunction — accessors with
typeof check; throw TypeError on non-function
- Signal.safety / Signal.tracing / Signal.stackCapture — accessors
delegating to helpers module-level state
- Signal.configure({...}) — bulk wrapper; forwards each key through
its accessor so validation runs
- Signal.defaults — snapshot getter of current settings
Drop the allowClone option. The transitional mapping from
allowClone: false → safety: 'reference' is removed. Callsites migrated:
- lit/renderer.js dataVersion — collapses cleanly to safety: 'none'
(allowClone:false + equalityFunction always-false)
- native/blocks/each.js itemSignal — safety: 'reference'
- templating/src/template.js settingsVars — safety: 'reference'
- bench.js / bench-todo.js / tools/benchmark — safety: 'none'
Drop legacy function-form statics (Signal.setTracing, Signal.isTracing,
Signal.setStackCapture, Signal.isStackCapture). The tracing flag
feature was never released; no compat needed.
noop becomes a true no-op — returns undefined, swallows arguments. identity is the new export for the "return first argument unchanged" semantic the old noop was secretly providing. Callsites that depended on the identity behavior migrated to identity: - utils/src/strings.js joinWords transform — the return value feeds the quoting path - component/define-component.js createComponent — return becomes the instance methods (and when no createComponent is provided, the identity default carries callParams into the instance, which the SSR env-guard path depends on) - query/behavior.js createBehavior — same shape as createComponent - query/behavior.js customInvocation — fallback method-not-found handler; return value is the found result All other `= noop` callsites are lifecycle side-effect callbacks (onCreated, onRendered, etc.) whose return values are discarded — true noop is correct there. CHANGELOG entries added for the Reactivity + Utils breaking changes that landed in this release.
The defineProperty-every-prop branch introduced in the frozen-descriptor
fix cost ~3-5x per key vs plain assignment. extend is called in the
render hot path (getDataContext, call(), etc.) with typical data
contexts of 10-20 keys — the cost compounded across every render.
Toggle-middle regressed +135%, filter-completed +42%, edit-start +24%.
Fast path now used when source is a normal writable data property
AND target doesn't already have a blocking accessor. Falls back to
defineProperty only when:
- source is an accessor (preserve getter/setter semantics)
- source descriptor is non-writable (frozen source — the original fix)
- target already has an accessor (later sources overriding earlier
ones, e.g., settings-proxy + data context merge)
Restore plain assignment as the common-case path in extend — the previous unconditional defineProperty cost caused render-path regressions (toggle-middle +135%, filter-completed +42%, edit-start +24%). Still handles the edge cases that justify extend over Object.assign: - Accessor source: preserved via defineProperty so getters/setters carry through the merge - Non-writable data source (frozen/sealed): target gets a fresh writable descriptor — source's lifecycle state shouldn't bleed into the merge result - Target already has accessor: defineProperty replaces with fresh writable data, matching the "later source wins" semantic users expect from a merge utility (and that the original descriptor- preserving extend already provided) Common case — target is fresh or has writable data for the prop — takes the plain assignment fast path.
Render-path benchmarks regressed net-negative under freeze-default even with signal-level wins intact (toggle-middle +94%, filter-completed +34%, etc). Node-level signal hot path shows parity; the cost is landing somewhere in the render path that I haven't isolated yet. This commit flips the default to 'reference' so the CI tachometer run tells us empirically whether freeze-on-set is the load-bearing regression source. If render benches recover here, freeze-default is the cost and we ship reference-default with an opt-in freeze. If benches stay regressed, the cost is elsewhere in the changeset and I keep hunting. Tests using default-freeze assumptions updated to pass explicit safety: 'freeze' so they still exercise that path.
Testing whether the Object.isFrozen check + conditional reassignment was contributing to render-path regressions. Under safety: 'reference' default, signal values aren't frozen, so the check always returned false — but Object.isFrozen is a native call per render that might have forced V8 deopts or ICs to go polymorphic at this callsite. Removing to measure. If toggle-middle and other render regressions recover, this was part of the cost. If not, keep hunting.
CI rebuilds the baseline by swapping `packages/*/src/` to the base
branch's revision but keeps THIS PR's bench files. When the bench file
passes `{ safety: 'none' }`, a base-branch Signal that doesn't know
`safety` silently falls back to defaults — `allowClone=true` (clone-on-
read) and `isEqual` (dedupe) — instead of the no-protection path the
new bench intended.
Result: the baseline runs orders-of-magnitude more expensive clone-on-
read traffic than the PR head, and toggle-middle/list-replace benches
report 100%+ "regressions" that are really inflation of the baseline
cost. This inverts the sign of signal microbenches (87-99% "wins") too.
Fix: pass both the new and legacy option shapes side-by-side. Branch
Signal reads `safety`, ignores `allowClone`/`equalityFunction`. Main
Signal reads `allowClone`/`equalityFunction`, ignores `safety`. Both
produce identical semantics (no freeze/clone, no dedupe for todo/krausest;
no freeze/clone with dedupe for signal microbench).
Local apples-to-apples (before/after this fix, 50-sample tachometer):
- toggle-middle: +136% → -1.3% (noise)
- filter-completed: +49% → -4.9%
- edit-start: +12% → +0.8%
- remove-last: -10% → +5.8%
- set-index-300: -99% → +25% (tiny absolute, 1.35ms vs 1.07ms)
- list-replace: -8% → -9%
Reverts the A/B experiment commit (a395a26) — 'reference' default was only flipped to measure whether freeze was the source of the reported render regressions. With fair bench methodology (previous commit) those regressions vanish, so keep 'freeze' as the intended default: writes are protected, downstream mutations throw loudly instead of silently corrupting signal state. Template constructor now shallow-copies its `data` argument. The lit render-template directive passes a Signal's unwrapped value directly as `data:` when a subtemplate uses `data=expression`. Under safety='freeze' that value is frozen, so later setDataContext → assignInPlace writes (e.g. merging the template's own instance methods) would TypeError on the non-extensible target. A shallow copy gives us a mutable working context while leaving nested values (which we read but never mutate) untouched. Fixes 'verbose data=expression re-evaluates all expressions' in renderer/test/browser/subtree-spurious.test.js under freeze default.
The bench harness is the controlled evaluator — the PR adapts to it, not the other way around. Restore `allowClone: false` → `safety: 'reference'` mapping in Signal constructor so main's bench files work unchanged with branch's Signal. Revert all bench file modifications from commits 442446e and f686dd1.
- extend: use simple assignment for data properties (faster than defineProperty), only use defineProperty for accessors or when overriding an existing target accessor. Removes second GOPD call on target for new properties. - template: lazy-thaw frozen this.data in setDataContext instead of defensive spread in constructor. Only allocates when actually needed. - global-search: opt rawResults/results out of freeze since pagefind internally mutates its result objects after loadFragment.
…aths - template.js setDataContext: remove Object.isExtensible check. Ran 200-300x per each-block reconcile on subtemplate renders. Local 4-sample A/B measurements show removing it eliminates the remove-middle +11.7% and remove-last +13% regressions identified from aggregated CI artifacts. - signal.js: revert inlined protect() in value setter (speculative, not diagnostic-backed). Keep protect() as a method. - signal.js: revert setArrayProperty single-index fast-path (speculative). - bench-todo.js: scale short benches (toggle/edit/remove single-ops) 10x so they run 120-360ms instead of 6-20ms. Short benches were in tachometer's noise band and rotated between confident-regressed and unsure across runs. With the default safety now 'reference', no signal values are frozen under default use, so template.data will be mutable from the start and setDataContext never needs to detect+replace a frozen target. Users who opt into safety='freeze' can still hit this edge case — if that surfaces, fix it at the signal boundary (don't pass frozen values to Template constructor) rather than defensive checks in a per-call path.
…xtend docstring
hasProperty becomes a thin re-export of Object.hasOwn (ES2022); proxyObject default switches from noop to an explicit () => ({}); extend gets a docstring capturing its modern-JS shallow-merge semantics.
Shared mutable state now lives on a single config object in helpers.js; Signal's tracing/safety/stackCapture accessors touch it directly instead of routing through setter helpers. Housekeeping statics moved to the bottom of signal.js so the constructor and happy path read at the top, and the mutate() reference/none dedupe path restores its snapshot+equality guard.
Captures decisions for the next utils release window (returnsSelf/returnsTrue/etc. family, remove `any` alias, fix first/last return-type polymorphism). Agent lessons gains "Quiet Code Over Ornamented Code" warning against SCREAMING_CAPS, underscore-prefixes, and dispatcher-layer patterns.
The test enumerated packages/ raw, so stray harness dirs like .claude/ broke the dist/cdn/ existence check.
Bracket assignment already routes through the safety/tracing/stackCapture setters, so the explicit if-ladder was ornament. Agent lessons updated to frame the quiet-code check as a changeset review pass, not just a write-time discipline.
Drops session-scaffolding narration from signal.js, helpers.js, and siblings; keeps load-bearing WHY notes (WeakRef self-stop, captureStackTrace cost, mutate behavior by safety preset). Signal class reorganized with section dividers (Core / Complex / Mutation Helpers / Tracing / Instance of / Configuration), signalTag moved to helpers.js, scheduler's flushTask wrapper replaced by direct Scheduler.flush reference.
🟡 Mixed Performance (Net Positive) for
|
| metric | Improvement |
|---|---|
signal-reactive-list-filter-1000x300 |
-93% (105ms) 🏆 |
signal-reactive-set-index-300 |
-87% (91ms) 🏆 |
signal-reactive-set-property-by-id-200 |
-85% (175ms) 🏆 |
signal-reactive-push-2000x20 |
-64% (138ms) 🌟 |
signal-computed-chain-10x60k |
-10% (20ms) |
remove-first-10 |
-6% (11ms) |
signal-reactive-multi-read-5x160k |
-4% (9ms) |
bulk-add-500 |
-3% (8ms) |
❌ Slower (4)
Metrics where this PR confidently regressed performance compared to main.
| metric | Regression |
|---|---|
signal-reactive-list-replace-1000x1000 |
+18% (59ms) ❗ |
remove-5-front |
+10% (9ms) |
toggle-all-20 |
+6% (21ms) |
clear-completed-250 |
+6% (3ms) |
📜 Regressions from peak (26)
These metrics were better on a prior commit than they are now. The peak CI dominates current CI — not attributable to per-sample noise. Bisect candidates are the commits between the peak and HEAD; nearest-to-peak is usually the best bet.
⚪ No Change (17)
Metrics where this PR measured within ±2% of main — no meaningful performance change detected.
| metric | Change |
|---|---|
add-20 |
-0.1% – -0.0% |
create-10k |
-0.9% – -0.2% |
create-1k |
-1.2% – +1.1% |
edit-start-10 |
-0.4% – +1.9% |
filter-cycle-20 |
-0.6% – +0.7% |
remove-10-middle |
-1.3% – 0.0% |
remove-last-10 |
+0.9% – +1.3% |
remove-middle-10 |
-0.3% – +0.6% |
remove-row-back-10 |
-0.3% – +1.4% |
remove-row-middle-20 |
-1.0% – +1.5% |
replace-1k |
-1.9% – +0.2% |
signal-reactive-fanout-500x1200 |
-0.4% – +1.7% |
swap-rows-20 |
-1.1% – +0.5% |
toggle-10 |
+0.7% – +1.1% |
toggle-first-10 |
+0.9% – +1.2% |
toggle-last-10 |
+0.8% – +1.3% |
toggle-middle-10 |
+0.7% – +1.4% |
🔍 Unsure (7)
Inconclusive (6)
The measured difference is small, and our sampling couldn't confidently place it above or below zero. Running more samples in a future run might settle these metrics.
| metric | Change | Expected Noise |
|---|---|---|
append-1k |
-2.7% – +1.9% | ±1% |
clear-10k |
-3.6% – +2.8% | ±1% |
remove-5-back |
-2.8% – +3.3% | ±2% |
remove-row-front-20 |
-2.9% – -1.0% | ±0% |
select-40 |
-2.4% – -0.3% | ±0% |
update-10th-10 |
-2.4% – +1.1% | ±1% |
Too Fast to Measure Precisely (1)
On benches this short, system jitter (scheduling, GC, JIT) masks sub-4% changes; larger deltas still resolve cleanly.
| metric | Change | Test Time | Expected Noise |
|---|---|---|---|
edit-cycle-5 |
+1.5% – +2.5% | ~167ms | ±1% |
Sample size: 50 · Resolution floor: ±2% · Timeout: 3min · Wall-clock: 17m46s
…ary instead
Previous extend({}, data) in Template constructor broke visual (uiClasses
not picked up, cards lost horizontal layout). Reverted to this.data = data || {}.
Instead, unpackData in the lit render-template directive shallow-copies
frozen values — thaws at the pipeline boundary; any downstream .set() refreezes.
Open: "unclear perf" per Jack — bounded to subtemplate renders but worth measuring.
Also: global-search perf may be affected (reported but unclear symptom).
rawResults, results, displayResults, and selectedResult all hold pagefind-owned fragment objects (directly or transitively via mapResult's rawResult field). Pagefind mutates its internal fragment cache on subsequent .data() calls, so any freeze along this chain poisons the cache and throws on the next search.
Bench files are not swapped during CI baseline build (only packages/*/src/ is). With safety: 'none' alone, main's source sees unknown option and falls through to allowClone: true, putting baseline on the slow clone-on-read path while the PR side gets the reference fast path. Dual options route both source trees to reference semantics: main uses allowClone, the PR uses safety. Note: bench-signal.js uses bare new Signal() — those benches measure each side's default cost and intentionally don't normalize.
Executes
ai/workspace/plans/signal-safety-v2.md. Review againstperf/signal-safety-v2as the baseline.Changes
freeze— silent get-mutate-set failures are a worse agentic trap than loud third-party-boundary errors with a documented escape.const arr = signal.get(); arr.push(x); signal.set(arr)at the call site, zero prod cost..value— frozen objects throw SUI-authored errors ("Signal value is frozen — cannot set property \items`. Use signal.set(...), a mutation helper, or safety: 'reference' for third-party data.") instead of the cryptic native V8 message. Gated onconfig.mode !== 'off'+this.safety === 'freeze'` + plain-object/array only; zero prod cost, no Map/Set false-positives.config.mode === 'off'). Shipped for readability; perf claim deferred to CI bench.allowClonecompat shim removed — constructor, bench files, docs, examples, and skills all migrated tosafetypresets.eachitem signals andsettingsVarshold user-owned references, so they opt out tosafety: 'reference'. Previous drop of these was correct when default wasreference; default flip back tofreezerequires them back.template.datashallow-copied on construction —setDataContextmerges in place viaassignInPlace, which can't write to frozen incoming data. Fresh container on construction fixes it.rawResultssignal explicitlysafety: 'reference'so pagefind's internal mutations on its own returned objects don't throw. Also the worked example in the new docs section.ai/workspace/plans/signal-default-clone-class-instances.md. Latent snapshot-corruption inmutate()reference mode for class-instance signals; not blocking.