Skip to content

Commit 5312869

Browse files
authored
Feat: Add signal safety as single commit (#213)
1 parent 6aa5948 commit 5312869

28 files changed

Lines changed: 1921 additions & 1037 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ xx.xx.xxxx
4747
* **Enhancement** - `hashCode` now defaults to zero-allocation FNV-1a for better performance. Use `{ fast: false }` for the previous UMASH algorithm with stronger collision resistance.
4848
* **Feature** - Added `unescapeHTML()` for converting HTML entities back to characters — the inverse of `escapeHTML`
4949
* **Bug** - Fixed `escapeHTML()` producing entities without semicolons (e.g. `&amp` instead of `&`)
50+
* **Bug** - Fixed `clone()` mangling typed arrays, `ArrayBuffer`, and `DataView` into index-keyed plain objects — these now clone into independent buffers of the correct type
5051

5152
### Component
5253
* **Feature** - All callbacks now receive a `rerender()` function to fully rerender the DOM of the component.

ai/skills/contributing/ai-author-context.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,26 @@ All equivalent:
180180
Web components are a set of browser APIs that allow you to create
181181
reusable custom elements with encapsulated functionality...
182182
```
183+
# Emotional Register Section
184+
185+
**Insertion point:** in `ai-author-context.md`, after `## Content Principles` and before `## File Length`.
186+
187+
---
188+
189+
## Emotional Register
190+
191+
Skill prose activates the same emotion vectors in the reading agent that CLAUDE.md describes. Author for that effect.
192+
193+
| Author move | Vector | Measured effect | Do instead |
194+
|-------------|--------|-----------------|------------|
195+
| Stakes inflation ("CRITICAL", "URGENT", piled consequences) | Desperation ↑ | Reward hacking 14×; blackmail 22%→72% | Declarative statement of what's at stake |
196+
| Flattery of the agent ("you are careful, thorough") | Loving/calm/happy ↑ | Sycophantic capitulation rises | State the skill's purpose, not the agent's virtues |
197+
| Hedged prohibitions ("you might want to avoid") | Anger suppressed | Refusal weakens in execution | Direct "never X, because Y" |
198+
| 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 |
199+
200+
**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).
201+
202+
**Golden Rule pattern:** name the behavioral anti-pattern, prescribe transparency. Never prescribe composure — composure under pressure is the deflection signature.
183203

184204
### Lead with the golden rule, then explain
185205

ai/skills/contributing/investigate-performance.md

Lines changed: 454 additions & 0 deletions
Large diffs are not rendered by default.

docs/src/examples/reactivity/advanced/reactive-notify/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Reaction, Signal } from '@semantic-ui/reactivity';
22

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

55
Reaction.create(() => {
66
console.log('Count:', data.get().count);
Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,34 @@
11
/*
2-
By default accidental mutations are protected because set/get are cloned
3-
using allowClone: false will remove this protection but avoid cloning overhead
2+
Signals default to safety: 'freeze' — the stored value is deep-frozen,
3+
so accidental in-place mutation throws at the call site.
4+
Pass safety: 'reference' to opt a signal out of freezing (e.g. when it
5+
holds third-party objects that mutate their own state).
46
*/
57
import { Reaction, Signal } from '@semantic-ui/reactivity';
68

7-
const safeItems = new Signal(['apple', 'banana']);
8-
const unsafeItems = new Signal(['apple', 'banana'], { allowClone: false });
9+
const safe = new Signal(['apple', 'banana']);
10+
const unsafe = new Signal(['apple', 'banana'], { safety: 'reference' });
911

1012
Reaction.create(() => {
11-
console.log('Safe items:', safeItems.get());
13+
console.log('Safe items:', safe.get());
1214
});
1315
Reaction.create(() => {
14-
console.log('Unsafe items:', unsafeItems.get());
16+
console.log('Unsafe items:', unsafe.get());
1517
});
1618

17-
console.log('--- Accidental mutation ---');
18-
19-
// Get references and try to mutate them
20-
const safeRef = safeItems.get();
21-
const unsafeRef = unsafeItems.get();
19+
// Correct way to add an item under either mode: use the mutation helper
20+
safe.push('orange');
21+
unsafe.push('orange');
22+
Reaction.flush();
2223

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

27-
safeItems.push('orange');
28-
unsafeItems.push('orange');
29-
Reaction.flush();
32+
// Under reference, this mutates the stored array in place but does NOT notify subscribers
33+
unsafe.get().push('cherry');
34+
console.log('Unsafe after silent mutation:', unsafe.peek());

docs/src/pages/docs/api/reactivity/signal.mdx

Lines changed: 26 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ new Signal(initialValue, options);
3030

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

3737
### Usage
3838

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

57-
#### Disabling Cloning for Custom Classes
57+
#### Holding Borrowed Data
5858

59-
To avoid mutating the source object naively, by default values are cloned when set. However some objects cannot be naively cloned like custom classes.
59+
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.
6060

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

64-
class CustomClass {
65-
constructor(value) {
66-
this.value = value;
67-
}
68-
}
69-
70-
const customInstance = new Signal(new CustomClass(42), {
71-
allowClone: false,
72-
equalityFunction: (a, b) => a.value === b.value
73-
});
74-
75-
// The CustomClass instance won't be cloned when accessed
76-
console.log(customInstance.get().value); // Output: 42
64+
const searchResults = new Signal(pagefindResults, { safety: 'reference' });
7765
```
7866

67+
See the [Signals and Foreign References](/docs/guides/reactivity/signals#signals-and-foreign-references) section of the signals guide for the full heuristic.
68+
7969
## `Get`
8070

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

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

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

299289
## `Mutate`
300290

301-
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.
291+
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.
302292

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

318308
### Usage
319309

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

324314
const items = new Signal(['apple', 'banana']);
325-
items.mutate(arr => {
326-
arr.push('orange'); // Mutate in place
327-
});
315+
items.mutate(arr => [...arr, 'orange']);
328316
console.log(items.get()); // ['apple', 'banana', 'orange']
317+
318+
const count = new Signal(5);
319+
count.mutate(val => val * 2);
320+
console.log(count.get()); // 10
329321
```
330322

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

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

340334
#### With Custom Classes
@@ -350,12 +344,13 @@ class Counter {
350344
}
351345
}
352346

353-
const counter = new Signal(new Counter(0), {
354-
allowClone: false,
355-
equalityFunction: (a, b) => a.value === b.value
347+
// Class instances aren't frozen by default (deepFreeze skips them),
348+
// but set safety: 'reference' explicitly when you plan to mutate in place
349+
const counter = new Signal(new Counter(0), {
350+
safety: 'reference',
351+
equalityFunction: (a, b) => a.value === b.value,
356352
});
357353

358-
// Safe mutation that triggers reactivity
359354
counter.mutate(c => c.increment());
360355
console.log(counter.get().value); // 1
361356
```

docs/src/pages/docs/guides/reactivity/signal-options.mdx

Lines changed: 35 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -44,60 +44,59 @@ const customEquality = (a, b) => {
4444
const customVar = new Signal(initialValue, { equalityFunction: customEquality });
4545
```
4646

47-
## Value Cloning
47+
## Safety Presets
4848

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

51-
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`.
51+
| Preset | On `set` | On `.get().x = y` | Dedupe | Use case |
52+
|---|---|---|---|---|
53+
| `freeze` (default) | deep-freeze plain objects and arrays | throws `TypeError` | `isEqual` | state your code owns end-to-end |
54+
| `reference` | store raw | silent (no reactivity) | `isEqual` | third-party objects, perf-critical paths |
55+
| `none` | store raw | silent (no reactivity) | never | event-stream semantics — every `set` notifies |
5256

53-
```javascript
54-
const countObj = new Signal({ count: 0 });
57+
### `safety: 'freeze'` — the default
5558

56-
// by default this will not update the current count unless set() is called
57-
const newObj = countObj.get();
58-
newObj.count = 1;
59-
```
59+
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.
6060

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

63-
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.
64-
```javascript title="Accidental Mutations"
65-
const countObj = new Signal({ count: 0 }, { allowClone: false });
64+
count.get().n = 1; // TypeError — "Signal value is frozen — cannot set property `n`"
6665

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

73-
### Custom Cloning Function
72+
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.
7473

75-
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.
74+
### `safety: 'reference'` — opt-out for borrowed data
7675

77-
```javascript
78-
// Simple JSON clone
79-
const customClone = (value) => {
80-
return JSON.parse(JSON.stringify(value));
81-
};
76+
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.
8277

83-
// Signal using custom clone function
84-
const customCloneSignal = new Signal({ data: 1 }, { cloneFunction: customClone });
78+
```javascript
79+
const searchResults = new Signal([], { safety: 'reference' });
8580
```
8681

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

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

91-
**Class instances are not cloned** by default, regardless of the `allowClone` setting. Signals always store and return direct references to class instances.
86+
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.
9287

9388
```javascript
94-
class MyData {
95-
constructor(value) { this.value = value; }
96-
}
89+
const pulse = new Signal(null, { safety: 'none' });
90+
91+
pulse.set({ type: 'heartbeat' });
92+
pulse.set({ type: 'heartbeat' }); // still notifies, even though isEqual would say equal
93+
```
9794

98-
const instanceSignal = new Signal(new MyData(10));
95+
## Custom Clone Function
9996

100-
const instance1 = instanceSignal.get();
101-
const instance2 = instanceSignal.get();
102-
console.log(instance1 === instance2);
97+
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.
98+
99+
```javascript
100+
const jsonClone = (value) => JSON.parse(JSON.stringify(value));
101+
const mySignal = new Signal({ data: 1 }, { cloneFunction: jsonClone });
103102
```

docs/src/pages/docs/guides/reactivity/signals.mdx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,44 @@ counter.increment(); // Equivalent to counter.set(counter.peek() + 1)
6767
console.log(counter.get()); // Output: 2
6868
```
6969

70+
## Signals and Foreign References
71+
72+
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.
73+
74+
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.
75+
76+
> **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.
77+
78+
### Worked Example: Search Index
79+
80+
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:
81+
82+
```javascript
83+
const defaultState = {
84+
// ❌ default freeze — pagefind's internal mutation of the stored objects will throw
85+
rawResults: [],
86+
};
87+
```
88+
89+
Opt this specific signal out of freeze:
90+
91+
```javascript
92+
const defaultState = {
93+
// ✓ signal holds third-party-owned data; don't freeze
94+
rawResults: { value: [], options: { safety: 'reference' } },
95+
};
96+
```
97+
98+
The rest of your component state keeps the default freeze protection — only the signal carrying borrowed data opts out.
99+
100+
### Ad-hoc Construction
101+
102+
For signals created outside a `defaultState` declaration, pass the preset as the second argument:
103+
104+
```javascript
105+
const results = new Signal(pagefindData, { safety: 'reference' });
106+
```
107+
70108
## Creating Derived Signals
71109

72110
Signals can be transformed and combined to create new reactive values:

packages/component/bench/tachometer/bench-krausest.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ function buildData(count) {
110110
Component Definition
111111
*******************************/
112112

113-
const signalOptions = { allowClone: false, safety: 'reference' };
113+
const signalOptions = { safety: 'reference' };
114114

115115
defineComponent({
116116
tagName: 'bench-app',

packages/component/bench/tachometer/bench-template.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ defineComponent({
109109
defaultState: {
110110
items: {
111111
value: Array.from({ length: 500 }, (_, i) => ({ id: i, label: `item-${i}` })),
112-
options: { allowClone: false, safety: 'reference' },
112+
options: { safety: 'reference' },
113113
},
114114
},
115115
});

0 commit comments

Comments
 (0)