Skip to content

Feat: Freeze Signals by Default#150

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

Feat: Freeze Signals by Default#150
jlukic wants to merge 50 commits intomainfrom
perf/signal-safety-v2-finalize

Conversation

@jlukic
Copy link
Copy Markdown
Member

@jlukic jlukic commented Apr 16, 2026

Implements signal-safety-v2. Replaces clone-on-read with deep-freeze-on-set — clone was safe but expensive, turning it off bred bugs, freeze sits between. Mutations throw at the call site; reads don't allocate.

Changes

  • Three presets — freeze (default), reference, none — replace allowClone.
  • Adds signal.clone() for libraries that mutate their inputs.
  • Adds globals to configure Signals library-wide: Signal.defaultEquality, Signal.defaultClone, and a bulk helper Signal.configure.
  • Adds identity for (x) => x and modifies noop to properly return nothing. Breaking.
  • Dev mode catches get-mutate-set with a labeled error.
  • Migration surfaced and fixed latent in-place mutation bugs in mobile-menu and nav-menu.
  • LSP no longer mistypes state declared with { value, options } signal-config shape.

Performance

Surprisingly fewer wins than you'd expect from dropping clone-on-read by default. Freeze improves bulk reactive ops over clone but seems to regress on bulk list replacement.

Risk

7/10 — public API redesign on the reactivity layer; perf story not fully understood.

Failure modes:

  • Dev-mode messages gate on Signal.tracing (off by default). Users hit raw V8 errors first.
  • Removed legacy exports (setTracing, isTracing, setStackCapture, isStackCapture) have no compat shim. Importers break at module load.
  • Static rename (equalityFunctiondefaultEquality) has no shim; overriders silently fail.
  • Class-instance signals under mutate() reference mode have a latent bug. Followup filed.

How to Test

  • Confirm dev-mode messages fire only when Signal.tracing = true.

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.
@semantic-performance-bot
Copy link
Copy Markdown

semantic-performance-bot Bot commented Apr 16, 2026

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

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

Feat: Freeze Signals by Default

Warning

This PR improves ✅ 9 tests while regressing on ❌ 2 tests.

✅ 9 faster · ❌ 2 slower · 🔍 8 unsure · ⚪ 17 no change · 📜 17 reopened


✅ Faster (9)

Metrics where this PR confidently improved performance compared to main.

metric Improvement
signal-reactive-list-filter-1000x300 -93% (105ms) 🏆
signal-reactive-set-index-300 -88% (90ms) 🏆
signal-reactive-set-property-by-id-200 -85% (172ms) 🏆
signal-reactive-push-2000x20 -64% (135ms) 🌟
signal-computed-chain-10x60k -9% (19ms)
remove-first-10 -5% (9ms)
toggle-middle-10 -5% (8ms)
remove-last-10 -4% (7ms)
bulk-add-500 -3% (7ms)

❌ Slower (2)

Metrics where this PR confidently regressed performance compared to main.

metric Regression
signal-reactive-list-replace-1000x1000 +21% (65ms) ❗
remove-5-front +14% (11ms)

📜 Regressions from peak (17)

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 378.0ms 248.1ms @ 9a9db17 +52% 30713d3, c978797
signal-reactive-fanout-500x1200 90.0ms 71.8ms @ 9a9db17 +25% 30713d3, c978797
signal-reactive-set-index-300 12.5ms 10.1ms @ 5e12782 +24% 9a9db17, 30713d3, c978797
filter-cycle-20 406.8ms 335.0ms @ 30713d3 +21% c978797
signal-reactive-multi-read-5x160k 189.6ms 162.6ms @ 9a9db17 +17% 30713d3, c978797
append-1k 119.5ms 102.7ms @ 5e12782 +16% 9a9db17, 30713d3, c978797
signal-computed-chain-10x60k 181.6ms 160.9ms @ 9a9db17 +13% 30713d3, c978797
signal-reactive-list-filter-1000x300 7.6ms 7.1ms @ 5e12782 +7% 9a9db17, 30713d3, c978797
remove-5-front 89.7ms 84.7ms @ f006b4a +6% ffa81f6, 5b5fe12, 5e12782 +3 more
toggle-last-10 164.6ms 155.8ms @ 30713d3 +6% c978797
toggle-first-10 165.3ms 157.4ms @ 30713d3 +5% c978797
toggle-10 164.1ms 159.3ms @ 9a9db17 +3% 30713d3, c978797
swap-rows-20 998.8ms 977.6ms @ 9a9db17 +2% 30713d3, c978797
remove-10-middle 160.7ms 157.3ms @ c978797 +2%
toggle-all-20 333.8ms 328.1ms @ 9a9db17 +2% 30713d3, c978797
create-1k 134.8ms 132.8ms @ 5e12782 +1% 9a9db17, 30713d3, c978797
create-10k 1143.6ms 1131.7ms @ 9a9db17 +1% 30713d3, c978797
⚪ No Change (17)

Metrics where this PR measured within ±2% of main — no meaningful performance change detected.

metric Change
add-20 -0.2% – +0.0%
clear-completed-250 +0.2% – +1.9%
create-10k -0.7% – +0.1%
create-1k -1.7% – +0.7%
edit-cycle-5 -0.2% – +0.9%
filter-cycle-20 -2.0% – -0.8%
remove-10-middle -1.4% – -0.2%
remove-middle-10 -0.2% – +0.8%
remove-row-back-10 -1.1% – +0.6%
remove-row-middle-20 -2.0% – +1.0%
replace-1k -1.9% – +1.3%
signal-reactive-fanout-500x1200 +0.1% – +1.6%
swap-rows-20 -1.2% – +0.8%
toggle-10 +0.6% – +0.8%
toggle-all-20 +0.6% – +1.2%
toggle-first-10 -0.2% – +0.8%
toggle-last-10 +0.7% – +1.3%
🔍 Unsure (8)

Inconclusive (7)

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.9% – +2.6% ±1%
clear-10k -7.3% – -2.0% ±1%
edit-start-10 -2.5% – -0.1% ±1%
remove-row-front-20 -2.2% – -0.5% ±0%
select-40 -2.7% – -0.5% ±0%
signal-reactive-multi-read-5x160k -4.7% – -1.0% ±1%
update-10th-10 -2.5% – +2.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
remove-5-back -3.0% – +0.6% ~71ms ±2%

Sample size: 50 · Resolution floor: ±2% · Timeout: 3min · Wall-clock: 17m44s

…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
@jlukic jlukic changed the title Signals: Add Additional Safety with Freeze & Dev Only Warnings BREAKING: Freeze Signals by Default Apr 28, 2026
@jlukic jlukic changed the title BREAKING: Freeze Signals by Default Feat: Freeze Signals by Default Apr 28, 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