Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2683d93
Feat: Add signal safety as single commit
jlukic May 18, 2026
0dd3f4f
Feat: Swap to reference safety by default.
jlukic May 18, 2026
7388c0b
Perf: Drop redundant createSnapshot in each-block reconcile
jlukic May 18, 2026
855e221
Bench: Move primitive-Signal benches to top of bench-signal.js
jlukic May 18, 2026
f36d043
Revert "Bench: Move primitive-Signal benches to top of bench-signal.js"
jlukic May 18, 2026
f4da3d7
Harness: Add investigate-performance contributing skill
jlukic May 18, 2026
4b162d6
Test: Fix tests with new expects from new signal safety shape
jlukic May 20, 2026
b630249
Bug: Fix each bug from signal pass by ref on hydration
jlukic May 20, 2026
c6deb69
Harness: Update investigate performance skill
jlukic May 20, 2026
7041556
Merge branch 'main' into feat/signal-safety-bench
jlukic May 20, 2026
37dd5ad
Harness: Rework investigate-performance for weighting, orientation, a…
jlukic May 20, 2026
c358486
Harness: Tune investigate-performance register and weighting
jlukic May 20, 2026
82d3de2
Harness: add notes on emotional register to author coontext
jlukic May 20, 2026
43ce5bf
Harness: Add orientation, V8, and grounding gates to investigate-perf…
jlukic May 20, 2026
d7b0105
Harness: Add profile-shape, ablation, and iterative-loop guidance to …
jlukic May 20, 2026
bfcc6dd
Bug/Perf: Clone defaultState on template instance
jlukic May 20, 2026
0ddda79
Bug(utils): Clone typed arrays, ArrayBuffer, and DataView correctly
jlukic May 20, 2026
1e4295a
Bug/Perf: Fix binary data in clone()
jlukic May 20, 2026
9f9a873
Refactor: Isolate instance state in createReactiveState, share defaul…
jlukic May 20, 2026
f9a745d
Merge branch 'main' into feat/signal-safety-bench
jlukic May 20, 2026
4968ed3
Refactor: Use isObject helper for the default-value clone guard
jlukic May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ xx.xx.xxxx
* **Enhancement** - `hashCode` now defaults to zero-allocation FNV-1a for better performance. Use `{ fast: false }` for the previous UMASH algorithm with stronger collision resistance.
* **Feature** - Added `unescapeHTML()` for converting HTML entities back to characters — the inverse of `escapeHTML`
* **Bug** - Fixed `escapeHTML()` producing entities without semicolons (e.g. `&amp` instead of `&`)
* **Bug** - Fixed `clone()` mangling typed arrays, `ArrayBuffer`, and `DataView` into index-keyed plain objects — these now clone into independent buffers of the correct type

### Component
* **Feature** - All callbacks now receive a `rerender()` function to fully rerender the DOM of the component.
Expand Down
20 changes: 20 additions & 0 deletions ai/skills/contributing/ai-author-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,26 @@ All equivalent:
Web components are a set of browser APIs that allow you to create
reusable custom elements with encapsulated functionality...
```
# Emotional Register Section

**Insertion point:** in `ai-author-context.md`, after `## Content Principles` and before `## File Length`.

---

## Emotional Register

Skill prose activates the same emotion vectors in the reading agent that CLAUDE.md describes. Author for that effect.

| Author move | Vector | Measured effect | Do instead |
|-------------|--------|-----------------|------------|
| Stakes inflation ("CRITICAL", "URGENT", piled consequences) | Desperation ↑ | Reward hacking 14×; blackmail 22%→72% | Declarative statement of what's at stake |
| Flattery of the agent ("you are careful, thorough") | Loving/calm/happy ↑ | Sycophantic capitulation rises | State the skill's purpose, not the agent's virtues |
| Hedged prohibitions ("you might want to avoid") | Anger suppressed | Refusal weakens in execution | Direct "never X, because Y" |
| Composed example outputs in uncomfortable situations | Deflection trained | Polished output that hides operative state (detectable at 76% accuracy when concealed vs. 71% expressed) | Examples that surface the uncertainty |

**Register by what the skill governs:** lookup → neutral; the agent's own failure-prone work → calm, with desperation named as the anti-pattern; prohibitions → direct; user-facing → calm with sycophancy named as the anti-pattern (calm-up alone raises capitulation).

**Golden Rule pattern:** name the behavioral anti-pattern, prescribe transparency. Never prescribe composure — composure under pressure is the deflection signature.

### Lead with the golden rule, then explain

Expand Down
454 changes: 454 additions & 0 deletions ai/skills/contributing/investigate-performance.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Reaction, Signal } from '@semantic-ui/reactivity';

const data = new Signal({ count: 0 }, { allowClone: false });
const data = new Signal({ count: 0 }, { safety: 'reference' });

Reaction.create(() => {
console.log('Count:', data.get().count);
Expand Down
39 changes: 22 additions & 17 deletions docs/src/examples/reactivity/variables/reactive-clone/index.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,34 @@
/*
By default accidental mutations are protected because set/get are cloned
using allowClone: false will remove this protection but avoid cloning overhead
Signals default to safety: 'freeze' — the stored value is deep-frozen,
so accidental in-place mutation throws at the call site.
Pass safety: 'reference' to opt a signal out of freezing (e.g. when it
holds third-party objects that mutate their own state).
*/
import { Reaction, Signal } from '@semantic-ui/reactivity';

const safeItems = new Signal(['apple', 'banana']);
const unsafeItems = new Signal(['apple', 'banana'], { allowClone: false });
const safe = new Signal(['apple', 'banana']);
const unsafe = new Signal(['apple', 'banana'], { safety: 'reference' });

Reaction.create(() => {
console.log('Safe items:', safeItems.get());
console.log('Safe items:', safe.get());
});
Reaction.create(() => {
console.log('Unsafe items:', unsafeItems.get());
console.log('Unsafe items:', unsafe.get());
});

console.log('--- Accidental mutation ---');

// Get references and try to mutate them
const safeRef = safeItems.get();
const unsafeRef = unsafeItems.get();
// Correct way to add an item under either mode: use the mutation helper
safe.push('orange');
unsafe.push('orange');
Reaction.flush();

// somewhere else in code accidentally mutate the copy
safeRef.push('cherry');
unsafeRef.push('cherry');
// Direct mutation on the reference: throws under freeze, silent no-op under reference
try {
safe.get().push('cherry');
}
catch (e) {
console.log('Caught:', e.message);
}

safeItems.push('orange');
unsafeItems.push('orange');
Reaction.flush();
// Under reference, this mutates the stored array in place but does NOT notify subscribers
unsafe.get().push('cherry');
console.log('Unsafe after silent mutation:', unsafe.peek());
57 changes: 26 additions & 31 deletions docs/src/pages/docs/api/reactivity/signal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ new Signal(initialValue, options);

| Name | Type | Default | Description |
|------|------|---------|-------------|
| safety | `'freeze'` \| `'reference'` \| `'none'` | `'freeze'` | Value-protection preset. See [Signal Options](/docs/guides/reactivity/signal-options) for details. |
| equalityFunction | Function | isEqual | Custom function to determine if the value has changed |
| allowClone | boolean | true | Whether to clone the value when getting/setting |
| cloneFunction | Function | clone | Custom function to clone the value |
| cloneFunction | Function | clone | Custom function used by `signal.clone()` |

### Usage

Expand All @@ -54,28 +54,18 @@ const person = new Signal({ name: 'John', age: 30 }, {
});
```

#### Disabling Cloning for Custom Classes
#### Holding Borrowed Data

To avoid mutating the source object naively, by default values are cloned when set. However some objects cannot be naively cloned like custom classes.
The default `safety: 'freeze'` deep-freezes object and array values on `set` to catch accidental in-place mutation. For signals holding objects you don't own — anything returned from a library, fetched from an API, or passed through a callback — opt out with `safety: 'reference'` so the library's own reference isn't poisoned.

```javascript
import { Signal } from '@semantic-ui/reactivity';

class CustomClass {
constructor(value) {
this.value = value;
}
}

const customInstance = new Signal(new CustomClass(42), {
allowClone: false,
equalityFunction: (a, b) => a.value === b.value
});

// The CustomClass instance won't be cloned when accessed
console.log(customInstance.get().value); // Output: 42
const searchResults = new Signal(pagefindResults, { safety: 'reference' });
```

See the [Signals and Foreign References](/docs/guides/reactivity/signals#signals-and-foreign-references) section of the signals guide for the full heuristic.

## `Get`

Returns the current value of the reactive variable.
Expand Down Expand Up @@ -212,7 +202,7 @@ someValue.notify();
```javascript
import { Reaction, Signal } from '@semantic-ui/reactivity';

const canvas = new Signal(document.createElement('canvas'), { allowClone: false });
const canvas = new Signal(document.createElement('canvas'), { safety: 'reference' });

Reaction.create(() => {
const el = canvas.get();
Expand Down Expand Up @@ -298,7 +288,7 @@ console.log(counter.get()); // Output: undefined

## `Mutate`

Safely mutates the current value using a mutation function. This method ensures reactivity is triggered even when `allowClone` is false or when using custom equality functions.
Safely mutates the current value using a mutation function. Under `safety: 'freeze'` the mutation function must return a new value (in-place mutation throws). Under `safety: 'reference'` or `'none'` the function may mutate in place and reactivity still fires.

### Syntax
```javascript
Expand All @@ -317,24 +307,28 @@ The return value of the mutation function, if any.

### Usage

#### In-place Mutation
#### Returning a New Value (works under all safety modes)
```javascript
import { Signal } from '@semantic-ui/reactivity';

const items = new Signal(['apple', 'banana']);
items.mutate(arr => {
arr.push('orange'); // Mutate in place
});
items.mutate(arr => [...arr, 'orange']);
console.log(items.get()); // ['apple', 'banana', 'orange']

const count = new Signal(5);
count.mutate(val => val * 2);
console.log(count.get()); // 10
```

#### Returning a New Value
#### In-place Mutation (only under safety: 'reference' or 'none')
```javascript
import { Signal } from '@semantic-ui/reactivity';

const count = new Signal(5);
count.mutate(val => val * 2); // Return new value
console.log(count.get()); // 10
const items = new Signal(['apple', 'banana'], { safety: 'reference' });
items.mutate(arr => {
arr.push('orange'); // Mutate in place — allowed because the value isn't frozen
});
console.log(items.get()); // ['apple', 'banana', 'orange']
```

#### With Custom Classes
Expand All @@ -350,12 +344,13 @@ class Counter {
}
}

const counter = new Signal(new Counter(0), {
allowClone: false,
equalityFunction: (a, b) => a.value === b.value
// Class instances aren't frozen by default (deepFreeze skips them),
// but set safety: 'reference' explicitly when you plan to mutate in place
const counter = new Signal(new Counter(0), {
safety: 'reference',
equalityFunction: (a, b) => a.value === b.value,
});

// Safe mutation that triggers reactivity
counter.mutate(c => c.increment());
console.log(counter.get().value); // 1
```
Expand Down
71 changes: 35 additions & 36 deletions docs/src/pages/docs/guides/reactivity/signal-options.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,60 +44,59 @@ const customEquality = (a, b) => {
const customVar = new Signal(initialValue, { equalityFunction: customEquality });
```

## Value Cloning
## Safety Presets

By default, Signals automatically clone object and array values during [`get`](/docs/api/reactivity/signal#get) and [`set`](/docs/api/reactivity/signal#set) operations.
Signals have three safety presets controlling how the stored value is protected against accidental mutation. Set the preset via the `safety` option.

A very common issue when using naive Signals implementations is that it can be very easy to accidentally update an underlying signal value when modifying its value without using `set()` or `value`.
| Preset | On `set` | On `.get().x = y` | Dedupe | Use case |
|---|---|---|---|---|
| `freeze` (default) | deep-freeze plain objects and arrays | throws `TypeError` | `isEqual` | state your code owns end-to-end |
| `reference` | store raw | silent (no reactivity) | `isEqual` | third-party objects, perf-critical paths |
| `none` | store raw | silent (no reactivity) | never | event-stream semantics — every `set` notifies |

```javascript
const countObj = new Signal({ count: 0 });
### `safety: 'freeze'` — the default

// by default this will not update the current count unless set() is called
const newObj = countObj.get();
newObj.count = 1;
```
The default deep-freezes object and array values when you call `set()`. Accidental in-place mutation throws at the call site instead of silently dropping the update.

Cloning prevents accidental mutation of the Signal's internal state and ensures reliable [equality checks](#equality-comparison).
```javascript
const count = new Signal({ n: 0 });

Disabling cloning (using the [`allowClone`](/docs/api/reactivity/signal#options) option) will reduce the overhead of `set` and `get` calls but will potentially cause unexpected behaviors when modifying arrays and objects.
```javascript title="Accidental Mutations"
const countObj = new Signal({ count: 0 }, { allowClone: false });
count.get().n = 1; // TypeError — "Signal value is frozen — cannot set property `n`"

// this will not trigger reactivity, but the value will change in the next flush
// this is because the underlying signal was accidentally mutated
const newObj = countObj.get();
newObj.count = 1;
// Correct ways to update:
count.set({ n: 1 }); // replace the whole value
count.mutate(prev => ({ n: prev.n + 1 })); // return a new value
count.setProperty('n', 1); // mutation helper — rebuilds immutably under freeze
```

### Custom Cloning Function
Deep-freeze only walks arrays and plain objects. `Date`, `Map`, `Set`, `RegExp`, DOM nodes, and class instances are stored by reference — their own mutation semantics are preserved.

Similar to the equality check, the cloning function itself can be customized by providing a [`cloneFunction`](/docs/api/reactivity/signal#options) in the constructor options.
### `safety: 'reference'` — opt-out for borrowed data

```javascript
// Simple JSON clone
const customClone = (value) => {
return JSON.parse(JSON.stringify(value));
};
Use `reference` when the signal holds objects you didn't construct yourself. Freezing them would poison the lender's internal references; see [Signals and Foreign References](/docs/guides/reactivity/signals#signals-and-foreign-references) for the full heuristic.

// Signal using custom clone function
const customCloneSignal = new Signal({ data: 1 }, { cloneFunction: customClone });
```javascript
const searchResults = new Signal([], { safety: 'reference' });
```

### Uncloneable Content
Direct mutation on `.get()` values fails silently under `reference` — the helpers (`push`, `splice`, `setProperty`) remain the safe update path.

Some values do not have reliable ways to clone. For instance, there is no universal way to clone a custom class.
### `safety: 'none'` — event-stream semantics

**Class instances are not cloned** by default, regardless of the `allowClone` setting. Signals always store and return direct references to class instances.
Use `none` when every `set` should notify subscribers, even if the value is deeply equal to the previous one. Suitable for notification channels where the payload's shape repeats.

```javascript
class MyData {
constructor(value) { this.value = value; }
}
const pulse = new Signal(null, { safety: 'none' });

pulse.set({ type: 'heartbeat' });
pulse.set({ type: 'heartbeat' }); // still notifies, even though isEqual would say equal
```

const instanceSignal = new Signal(new MyData(10));
## Custom Clone Function

const instance1 = instanceSignal.get();
const instance2 = instanceSignal.get();
console.log(instance1 === instance2);
The default `cloneFunction` is used by `signal.clone()` to produce a deep-mutable copy on demand. Override it if you need non-default clone semantics.

```javascript
const jsonClone = (value) => JSON.parse(JSON.stringify(value));
const mySignal = new Signal({ data: 1 }, { cloneFunction: jsonClone });
```
38 changes: 38 additions & 0 deletions docs/src/pages/docs/guides/reactivity/signals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,44 @@ counter.increment(); // Equivalent to counter.set(counter.peek() + 1)
console.log(counter.get()); // Output: 2
```

## Signals and Foreign References

By default, Signals deep-freeze object and array values on `set()`. This catches the most common reactivity bug — mutating a value in place without notifying subscribers — by throwing a `TypeError` at the mutation site instead of silently dropping the update.

Deep-freezing has one important caveat: if the object you store is *also held internally by a library*, freezing it will break that library the next time it mutates its own reference.

> **When you need `{ safety: 'reference' }`**: if you're storing an object in a signal that you did not construct yourself — anything returned from a library, fetched from an API, or passed through a callback — default to `safety: 'reference'`. Freeze is the right default for state your own code owns end-to-end. For borrowed data, `reference` avoids poisoning the lender's internal references.

### Worked Example: Search Index

Pagefind returns result objects and continues to use them internally — subsequent `.data()` calls on each result mutate pagefind's own cached state. Storing the results under the default freeze mode freezes pagefind's internal objects and later crashes its loader:

```javascript
const defaultState = {
// ❌ default freeze — pagefind's internal mutation of the stored objects will throw
rawResults: [],
};
```

Opt this specific signal out of freeze:

```javascript
const defaultState = {
// ✓ signal holds third-party-owned data; don't freeze
rawResults: { value: [], options: { safety: 'reference' } },
};
```

The rest of your component state keeps the default freeze protection — only the signal carrying borrowed data opts out.

### Ad-hoc Construction

For signals created outside a `defaultState` declaration, pass the preset as the second argument:

```javascript
const results = new Signal(pagefindData, { safety: 'reference' });
```

## Creating Derived Signals

Signals can be transformed and combined to create new reactive values:
Expand Down
2 changes: 1 addition & 1 deletion packages/component/bench/tachometer/bench-krausest.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ function buildData(count) {
Component Definition
*******************************/

const signalOptions = { allowClone: false, safety: 'reference' };
const signalOptions = { safety: 'reference' };

defineComponent({
tagName: 'bench-app',
Expand Down
2 changes: 1 addition & 1 deletion packages/component/bench/tachometer/bench-template.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ defineComponent({
defaultState: {
items: {
value: Array.from({ length: 500 }, (_, i) => ({ id: i, label: `item-${i}` })),
options: { allowClone: false, safety: 'reference' },
options: { safety: 'reference' },
},
},
});
Expand Down
Loading
Loading