Skip to content

Signals: Add Additional Safety with Freeze & Dev Only Warnings#150

Open
jlukic wants to merge 49 commits intomainfrom
perf/signal-safety-v2-finalize
Open

Signals: Add Additional Safety with Freeze & Dev Only Warnings#150
jlukic wants to merge 49 commits intomainfrom
perf/signal-safety-v2-finalize

Conversation

@jlukic
Copy link
Copy Markdown
Member

@jlukic jlukic commented Apr 16, 2026

Executes ai/workspace/plans/signal-safety-v2.md. Review against perf/signal-safety-v2 as the baseline.

Changes

  • Default flipped to freeze — silent get-mutate-set failures are a worse agentic trap than loud third-party-boundary errors with a documented escape.
  • Dev-mode same-reference set warning — catches const arr = signal.get(); arr.push(x); signal.set(arr) at the call site, zero prod cost.
  • Dev-mode proxy on .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 on config.mode !== 'off'+this.safety === 'freeze'` + plain-object/array only; zero prod cost, no Map/Set false-positives.
  • Notify/invalidate hot-path guards hoisted — two function calls per notify saved under prod (config.mode === 'off'). Shipped for readability; perf claim deferred to CI bench.
  • allowClone compat shim removed — constructor, bench files, docs, examples, and skills all migrated to safety presets.
  • Framework-internal opt-outs restoredeach item signals and settingsVars hold user-owned references, so they opt out to safety: 'reference'. Previous drop of these was correct when default was reference; default flip back to freeze requires them back.
  • template.data shallow-copied on constructionsetDataContext merges in place via assignInPlace, which can't write to frozen incoming data. Fresh container on construction fixes it.
  • global-search fixrawResults signal explicitly safety: 'reference' so pagefind's internal mutations on its own returned objects don't throw. Also the worked example in the new docs section.
  • Docs — new "Signals and foreign references" section in the signals guide with the user-facing heuristic. Signal-options guide rewritten around the three-preset model. API reference updated.
  • Item 8 filed as followupai/workspace/plans/signal-default-clone-class-instances.md. Latent snapshot-corruption in mutate() reference mode for class-instance signals; not blocking.

jlukic added 30 commits April 15, 2026 15:51
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).
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.
@github-actions github-actions Bot added UI Components Modifies UI components Reactivity Modifies reactivity package Tests Modifies tests Docs Modifies documentation labels Apr 16, 2026
@semantic-performance-bot
Copy link
Copy Markdown

semantic-performance-bot Bot commented Apr 16, 2026

🟡 Mixed Performance (Net Positive) for c978797 on Benchmark Suite 📊

Base: main · Action: #24605139001 · Raw: bench-report.json

Signals: Add Additional Safety with Freeze & Dev Only Warnings

Warning

This PR improves ✅ 8 tests while regressing on ❌ 4 tests.

✅ 8 faster · ❌ 4 slower · 🔍 7 unsure · ⚪ 17 no change · 📜 26 reopened


✅ Faster (8)

Metrics where this PR confidently improved performance compared to main.

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.

metric current peak vs peak bisect candidates
signal-reactive-list-replace-1000x1000 377.0ms 248.1ms @ 9a9db17 +52% 30713d3
filter-cycle-20 459.5ms 335.0ms @ 30713d3 +37%
signal-reactive-set-index-300 13.0ms 10.1ms @ 5e12782 +28% 9a9db17, 30713d3
signal-reactive-fanout-500x1200 90.9ms 71.8ms @ 9a9db17 +27% 30713d3
signal-reactive-multi-read-5x160k 191.8ms 162.6ms @ 9a9db17 +18% 30713d3
append-1k 119.5ms 102.7ms @ 5e12782 +16% 9a9db17, 30713d3
signal-computed-chain-10x60k 184.7ms 160.9ms @ 9a9db17 +15% 30713d3
signal-reactive-list-filter-1000x300 8.1ms 7.1ms @ 5e12782 +14% 9a9db17, 30713d3
remove-5-back 75.7ms 69.5ms @ 30713d3 +9%
remove-5-front 91.4ms 84.7ms @ f006b4a +8% ffa81f6, 5b5fe12, 5e12782 +2 more
update-10th-10 189.4ms 177.8ms @ 9a9db17 +7% 30713d3
toggle-all-20 349.1ms 328.1ms @ 9a9db17 +6% 30713d3
remove-last-10 163.9ms 156.1ms @ 30713d3 +5%
remove-first-10 167.5ms 159.7ms @ 30713d3 +5%
edit-cycle-5 166.8ms 159.8ms @ 30713d3 +4%
swap-rows-20 1020.1ms 977.6ms @ 9a9db17 +4% 30713d3
clear-completed-250 58.7ms 56.4ms @ 9a9db17 +4% 30713d3
remove-row-middle-20 383.6ms 369.6ms @ 9a9db17 +4% 30713d3
toggle-middle-10 163.1ms 157.3ms @ 30713d3 +4%
toggle-first-10 163.1ms 157.4ms @ 30713d3 +4%
edit-start-10 165.1ms 159.4ms @ 30713d3 +4%
toggle-last-10 161.1ms 155.8ms @ 30713d3 +3%
remove-middle-10 163.3ms 158.6ms @ 30713d3 +3%
bulk-add-500 224.6ms 220.2ms @ 30713d3 +2%
toggle-10 160.6ms 159.3ms @ 9a9db17 +1% 30713d3
create-10k 1139.6ms 1131.7ms @ 9a9db17 +1% 30713d3
⚪ 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.
@jlukic jlukic changed the base branch from perf/signal-safety-v2 to main April 17, 2026 18:52
@github-actions github-actions Bot added Query Modifies query package Component Component Rendering Utils Modifies utilities package labels Apr 17, 2026
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.
@jlukic jlukic changed the title Signal safety v2 — finalize defaults, dev diagnostics, allowClone removal Signals: Add Additional Safety with Freeze & Dev Only Warnings Apr 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Component Component Rendering Docs Modifies documentation Query Modifies query package Reactivity Modifies reactivity package Templating Modifies templating package Tests Modifies tests UI Components Modifies UI components Utils Modifies utilities package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant