Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
8004e98
Refactor: Add safety preset state to reactivity helpers
jlukic Apr 15, 2026
4d75655
Refactor: Replace clone-on-read with freeze-on-set in Signal
jlukic Apr 15, 2026
dee7c31
Refactor: Immutable item enrichment in mobile-menu and nav-menu
jlukic Apr 15, 2026
28ce65e
Build: Discover runs all benchmarkable packages (fixes PR #148) (#149)
jlukic Apr 15, 2026
ecf22c3
Merge branch 'main' into perf/signal-safety-v2
jlukic Apr 15, 2026
8603027
Bug: Stop frozen descriptors from propagating through extend
jlukic Apr 15, 2026
27f68db
Merge branch 'perf/signal-safety-v2' of github.com:Semantic-Org/Seman…
jlukic Apr 15, 2026
f686dd1
Feat/BREAKING: Signal static config API, drop allowClone
jlukic Apr 15, 2026
9b4fcbb
Feat/BREAKING: Split utils noop into identity + noop
jlukic Apr 15, 2026
2911687
Docs: Drop CHANGELOG note for unreleased tracing statics
jlukic Apr 15, 2026
ee4b466
Perf: Restore fast path in extend for common data-property merge
jlukic Apr 15, 2026
44729df
Perf: extend fast-path via plain assignment
jlukic Apr 15, 2026
a395a26
Perf: Flip default safety to 'reference' to measure A/B
jlukic Apr 15, 2026
9bcd3f1
Perf: Remove lazy-thaw from setDataContext
jlukic Apr 15, 2026
442446e
Bug: Bench files pass dual-shape options for fair PR comparison
jlukic Apr 16, 2026
64c6820
Feat: Ship 'freeze' as default safety; shallow-copy template.data
jlukic Apr 16, 2026
5595cbd
Bug: Restore allowClone backward compat, revert bench file changes
jlukic Apr 16, 2026
bc353a4
Perf: Optimize extend for common case, fix frozen data in templates
jlukic Apr 16, 2026
f0cb848
Perf: Ship 'reference' as default safety, revert global-search clone
jlukic Apr 16, 2026
f4ef44d
Perf: Fast-path setArrayProperty for single index under reference
jlukic Apr 16, 2026
28901c4
Perf: Inline protect in hot value setter to avoid method call
jlukic Apr 16, 2026
a9cb8dc
Perf: Remove lazy-thaw from setDataContext; revert speculative fast-p…
jlukic Apr 16, 2026
7ce4e64
Refactor: Drop redundant safety:'reference' from framework-internal S…
jlukic Apr 16, 2026
106e66e
Docs: Update utils-noop example for post-split semantics
jlukic Apr 16, 2026
ff23ed7
Refactor: Utils one-line wins — Object.hasOwn, proxyObject default, e…
jlukic Apr 16, 2026
111ac61
Refactor: Consolidate Signal runtime config, restructure static surface
jlukic Apr 16, 2026
d4ff1fe
AI: Utils Modernization plan + Quiet Code agent lesson
jlukic Apr 16, 2026
5ba66ea
Test: Filter dotfile dirs in CDN packages discovery
jlukic Apr 16, 2026
61193ed
Refactor: Collapse Signal.configure to Object.assign
jlukic Apr 16, 2026
923f872
Refactor: Prune reactivity comments, restructure Signal by section
jlukic Apr 16, 2026
a9c8170
AI: Agent lesson — OSS comment bar + section divider calibration
jlukic Apr 16, 2026
67e529f
Perf: remove unnecessary clone on menu
jlukic Apr 16, 2026
f197f27
Harness: Update perf skill to include rebuttals to perf tests
jlukic Apr 16, 2026
6da9d1c
Harness: workspace
jlukic Apr 16, 2026
102d0bf
Feat: Flip default safety to freeze
jlukic Apr 16, 2026
0e3a1e7
Feat: Add dev-mode same-reference set warning; hoist notify/invalidat…
jlukic Apr 16, 2026
8d9c168
Feat: Dev-mode proxy on get() for framework-authored freeze errors
jlukic Apr 16, 2026
bdf8ed1
Docs: Signals and foreign references; fix global-search under freeze
jlukic Apr 16, 2026
83ef2bf
BREAKING: Remove allowClone option; migrate callsites to safety presets
jlukic Apr 16, 2026
47a1c91
AI: Plan updates; file defaultClone class-instance followup
jlukic Apr 16, 2026
606e8dd
Bug: LSP analyzer — unwrap {value, options} signal-config shape for t…
jlukic Apr 16, 2026
2d4c74e
Bug: Use extend({}, data) in Template constructor to preserve source …
jlukic Apr 16, 2026
f006b4a
Bug: Revert Template.data copy; thaw frozen data at subtemplate bound…
jlukic Apr 16, 2026
ffa81f6
Bug: Mark all pagefind-derived signals as safety: reference
jlukic Apr 16, 2026
5b5fe12
Harness: touch to rebuild tests
jlukic Apr 17, 2026
5e12782
Bench: Restore dual options for PR-vs-main fairness
jlukic Apr 17, 2026
c31aec7
Merge remote-tracking branch 'origin' into perf/signal-safety-v2-fina…
jlukic Apr 18, 2026
30713d3
Bug: Fix lsp doesnt understand complex state obj
jlukic Apr 18, 2026
c978797
Merge branch 'main' into perf/signal-safety-v2-finalize
jlukic Apr 18, 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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ xx.xx.xxxx
* **Feature** - Added `{#fn expression}` directive to pass values as-is without auto-invoking functions — mirrors `{#html}` pattern, useful for passing callbacks through property bindings

### Reactivity
* **BREAKING** - Signals now default to `safety: 'freeze'` (deep-freeze on set) instead of clone-on-read. Mutations of a signal value throw `TypeError` at the call site instead of being silently swallowed. Reads return the frozen reference — no more per-read clone. Three presets: `'freeze'` (default), `'reference'` (no protection; framework/perf opt-out), `'none'` (no protection, no dedupe; event-stream semantics).
* **BREAKING** - Removed the `allowClone` constructor option. Use `safety: 'reference'` for the equivalent "no protection" behavior. Signals that previously relied on clone-on-read for mutation isolation need to switch to `safety: 'reference'` or rewrite callers to avoid mutating peeked references.
* **Feature** - Added `signal.clone()` — tracked read that returns a deep copy. Use when handing signal data to libraries that mutate in place.
* **Feature** - Added static accessor properties for configuring defaults — `Signal.safety`, `Signal.tracing`, `Signal.stackCapture`, `Signal.equalityFunction`, `Signal.cloneFunction`. Assignment validates (function accessors throw `TypeError` on non-function). Bulk setup via `Signal.configure({...})`; inspect current state via `Signal.defaults`.
* **Feature** - In-place array helpers (`push`, `unshift`, `splice`, `setIndex`, `removeIndex`, `setArrayProperty`, `setProperty`) branch on safety — `'reference'` / `'none'` retain O(1) in-place mutation; `'freeze'` builds new arrays/objects immutably.
* **Feature** - Added `depend()` to register a signal as a dependency without reading the value
* **Feature** - Added `notify()` to force-trigger subscribers bypassing the equality check
* **Feature** - Added `hasDependents()` to check if any reactions are subscribed to a signal
Expand All @@ -28,6 +33,10 @@ xx.xx.xxxx
### Reactivity
* **Bug** - Fixed `instanceof` brand check on `Signal` to use prototype getter instead of class field — ensures cross-realm and prototype-created instances pass `instanceof` reliably.

### Utils
* **BREAKING** - `noop` now returns `undefined` (truly no-op). Previously it returned its first argument (identity). Use the new `identity` export if you need the old behavior.
* **Feature** - Added `identity` export — returns its first argument unchanged.

### Templates
* **Bug** - Fixed `instanceof` brand check on `Template` to use prototype getter instead of class field — same fix as Signal and Query.

Expand Down
1 change: 1 addition & 0 deletions ai/plans/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ Post-pipeline. Write docs when there's something to document. Build wrappers whe
| P4 | Homepage Tour Ribbon | 16-24h (2-3d) | pair | 3 PlaygroundExamples for templates/specs/components. |
| P5 | CSS Token Extraction | 16-24h (2-3d) | pair | `getThemingCSS` util + MCP tool. Blocked on token finalization. |
| P6 | MCP Improvements (remaining 3 tools) | 8-16h (1-2d) | agent | `get_theming_css`, `get_global_tokens`, `get_token_usage`. Blocked on token extraction. |
| P7 | [Utils Modernization](utils-modernization.md) | 4-8h + codemod | pair | Remove `any`/`onlyKeys` aliases, fix `first`/`last` return-type polymorphism, add `pipe`/`attempt`/`tap`. `initial` scope — 5 quick design calls. |

---

Expand Down
83 changes: 83 additions & 0 deletions ai/plans/utils-modernization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Utils Modernization

## Goal

`@semantic-ui/utils` has accumulated a mix of principled next-gen design and lodash-parity leftovers. The library's instinct is right — when two functions exist, they should capture meaningfully distinct behaviors, not arg-shape variants. A few leftovers violate that, and a few modern patterns are missing.

This plan captures the decisions needed to reconcile the library with that principle and stage the breaking changes behind a single release window.

**Naming principle**: two exports earn their place when they express **distinct intent at the call site**, even if the implementations overlap. Two names for the same intent (`any = some`) is a wart; two names for distinct intents that happen to share a body (`wrapFunction` as defensive normalization vs `constant` as explicit factory) is a feature — the name is for the reader.

## Design / Implementation

### Removals (breaking)

| Export | Issue | Proposed action |
|--------|-------|-----------------|
| `any` | Exact alias of `some` (`arrays.js:237`) — two names, one function | Delete. Document `some` as canonical (matches `Array.prototype.some`). |
| `onlyKeys` | Same operation as `pick` but takes array instead of spread args | Pick one shape. Most callers pass an array today (from config/filters/`Object.keys`) — recommend standardizing on `pick(obj, keys)` taking an array, delete `onlyKeys`. |

### Signature changes (breaking)

| Export | Issue | Proposed action |
|--------|-------|-----------------|
| `first` / `last` | Return-type polymorphism: `first(arr)` returns element, `first(arr, 2)` returns array | Split: `first(arr)` always returns the element. Add `firstN(arr, n)` / `lastN(arr, n)` that always return arrays. |

### Additions

| Export | Rationale |
|--------|-----------|
| `returnsSelf`, `returnsNull`, `returnsTrue`, `returnsFalse`, `returnsUndefined` | Named, shared, reference-stable functions for the common constant returns. Ship the common cases as direct exports so every consumer dedupes to the same closure — enables reference-equality checks like `template.js`'s `this.onThemeChangedCallback !== noop` pattern to generalize across the codebase. |
| `constant = wrapFunction` | Alias for the explicit-factory intent. Same body as `wrapFunction`, different semantic intent at the call site: `wrapFunction(x)` reads as defensive normalization ("value-or-function, normalize"); `constant(x)` reads as explicit factory ("function that returns this"). Covers the programmatic-value case (`constant(config.default)`) where a direct named export can't. |
| `identity` → rename to `returnsSelf` | `identity` is brand-new in this release (from the noop/identity split). Rename to `returnsSelf` for consistency with the prefix family and to avoid FP jargon. Ship both renames together in this release window rather than churning twice. |
| `pipe(...fns)(x)` | Functional composition is idiomatic in modern TS; lodash's `flow` is clunky. Small primitive, pairs well with existing polymorphic iterators. |
| `attempt(fn, fallback)` or `safely(fn)` | Several sites in the codebase use `try { ... } catch { /* noop */ }` (see `browser.js:113,152,178,191`). A named helper makes intent greppable and documents the pattern. |
| `tap(fn)` | Passes value through while running a side effect. Small, useful in pipes and reactive chains. |

### Decisions needed

- **Do `any` and `onlyKeys` get deprecation shims in this release, or hard-deleted?** Hard-delete is cleaner for a 1.0-era library; deprecation adds complexity for a short window. Recommend hard-delete + loud CHANGELOG entry + migration note.
- **`first`/`last` strategy:** (a) always-scalar + new `firstN`/`lastN`, or (b) always-array (breaking for all current 1-arg callers)? Recommend (a) — smaller blast radius, clearer semantics at call site.
- **`pick` arg shape final answer:** spread (`pick(obj, 'a', 'b')`) or array (`pick(obj, ['a', 'b'])`)? Recommend array — composes with `Object.keys`, filter results, config. Spread is 2014 ergonomics for hand-literal keys that rarely appear in modern code.
- **`pipe` return style:** eager (`pipe(fns, x)`) or curried (`pipe(...fns)(x)`)? Curried composes better; eager is simpler.
- **`attempt` vs `safely` naming:** `attempt(fn, fallback)` reads naturally ("attempt this, else fallback"). `safely(fn)` suggests "swallow errors" without the fallback value — less useful.
- **Shape resolved.** Ship shared direct exports for the common constant returns (`returnsSelf`, `returnsNull`, `returnsTrue`, `returnsFalse`, `returnsUndefined`) — reference-stable across consumers, enables dedup and ref-equality checks. `constant` is an alias of `wrapFunction` covering the programmatic-value case. `identity` renames to `returnsSelf` in the same release window.
- Rejected names along the way: `always` (asserts continued truth when actual use is ephemeral-default), bare `returns(value)` / `constant(value)` as the *only* surface (works, but loses the dedup win from sharing primitive closures across the codebase).

### Out of scope

- Changes to `extend` / `deepExtend` / `assignInPlace` — the three merge variants already capture distinct contracts (shallow / recursive / sync-with-deletion). Leave as-is.
- Rewriting `proxyObject`, `weightedObjectSearch`, `hashCode` — these are niche, principled, and earn their export.

### Completed as one-line wins (not part of this plan)

- `proxyObject` default swapped from `noop` to `() => ({})`. *Revisit:* `() => ({})` allocates a fresh closure on each defaulted call — once `always` lands, change to `always({})` for a shared reference.
- `hasProperty` reduced to thin re-export of `Object.hasOwn` (ES2022).
- `utility-functions.md` skill updated for `noop` / `identity` split.

### Adoption once `always` lands

Audit the codebase for `() => <literal>` patterns that are candidates to migrate:
- `NO_EQUALITY = () => false` in `reactivity/src/signal.js` → `always(false)`
- `filter: () => true` defaults (e.g. `docs/src/examples/templates/expressions-fn/filter-list.js:7`)
- `proxyObject` default (above)
- Any `defaultSettings` entries that use bare `() => x` closures

## Open Questions

See "Decisions needed" above. Five design calls, all quick — should fit in one pair session.

Once decisions are made:
- Codemod for `any` → `some`, `onlyKeys` → `pick` across `packages/`, `src/`, `docs/`, `tools/`.
- Audit `first(arr, N)` / `last(arr, N)` call sites — all need rewrite to `firstN` / `lastN`.
- CHANGELOG entries under **BREAKING** for each removal/signature change.
- Type-file updates.
- Skill doc (`ai/skills/authoring/utility-functions.md`) updates.

## Dependencies

None. Can land anytime — breaking changes batch naturally with other utils changes in a release window.

## Status

`initial` — design decisions pending. Created 2026-04-16 off the utils review following the signal-safety PR.
6 changes: 3 additions & 3 deletions ai/skills/authoring/component-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,12 +314,12 @@ const defaultState = { counter: 0, isOpen: false };
// signal() in createComponent — needed when you want signal options or
// to create signals dynamically
const createComponent = ({ signal }) => ({
element: signal(null, { allowClone: false }), // custom options
count: signal(0), // explicit signal
element: signal(null, { safety: 'reference' }), // custom options
count: signal(0), // explicit signal
});
```

Use `defaultState` by default. Use `signal()` when you need `allowClone: false`, custom equality, or signals created conditionally.
Use `defaultState` by default. Use `signal()` when you need a non-default safety preset (e.g. `safety: 'reference'` for third-party data), custom equality, or signals created conditionally.

---

Expand Down
31 changes: 16 additions & 15 deletions ai/skills/authoring/reactive-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,17 @@ new Signal(initialValue, options)
- `initialValue`: Any - The initial value for the signal
- `options`: Object (optional)
- `context`: Object - Debugging context metadata
- `safety`: `'freeze'` | `'reference'` | `'none'` - Value protection preset (default: `'freeze'`)
- `equalityFunction`: Function - Custom equality comparison (default: deep equality)
- `allowClone`: Boolean - Whether to clone values (default: true)
- `cloneFunction`: Function - Custom cloning function (default: deep clone)
- `cloneFunction`: Function - Custom cloning function used by `signal.clone()` (default: deep clone)

**Examples**:
```javascript
// Basic signal
// Basic signal (default safety: 'freeze')
const count = new Signal(0);

// Signal with no cloning (for performance or object identity)
const element = new Signal(domElement, { allowClone: false });
// Signal for DOM or third-party objects — don't freeze borrowed references
const element = new Signal(domElement, { safety: 'reference' });

// Signal with custom equality
const user = new Signal(userData, {
Expand Down Expand Up @@ -188,19 +188,20 @@ const user = new Signal(userData, {
});
```

#### Cloning Control
#### Safety Presets
```javascript
// Disable cloning for performance or object identity preservation
const expensiveObject = new Signal(largeDataStructure, {
allowClone: false // No cloning, use object as-is
});
// Default — deep-freeze on set; in-place mutation throws at the call site
const config = new Signal({ theme: 'dark' });

// Opt out for signals holding borrowed data (libraries, APIs, callbacks)
const pagefindResults = new Signal([], { safety: 'reference' });

// Custom cloning function
// Event-stream semantics — every set notifies, even if value is equal
const pulse = new Signal(null, { safety: 'none' });

// Custom clone function used by signal.clone()
const customSignal = new Signal(data, {
cloneFunction: (value) => {
// Custom cloning logic
return JSON.parse(JSON.stringify(value));
}
cloneFunction: value => JSON.parse(JSON.stringify(value)),
});
```

Expand Down
17 changes: 12 additions & 5 deletions ai/skills/authoring/utility-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,11 +367,17 @@ truncate('こんにちは世界です', 8, { locale: 'ja' }); // 'こ

### Core
```javascript
import { noop, wrapFunction } from '@semantic-ui/utils';
import { noop, identity, wrapFunction } from '@semantic-ui/utils';

// Identity function — returns its argument
noop(42); // 42
noop('hello'); // 'hello'
// noop — swallows arguments, returns undefined. Use as a reusable empty
// callback to avoid allocating fresh () => {} closures.
noop(); // undefined
noop(42, 'ignored'); // undefined

// identity — returns its first argument unchanged. Use as a pass-through
// default for transforms (e.g. `transform = mapFn ?? identity`).
identity(42); // 42
identity('hello'); // 'hello'

// Wraps non-functions into a function that returns the value
const fn = wrapFunction('default'); // () => 'default'
Expand Down Expand Up @@ -827,7 +833,8 @@ const pattern = new RegExp(escapeRegExp('price ($5.00)'), 'i');
### Functions (functions.js)
| Function | Signature | Returns |
|----------|-----------|---------|
| `noop` | `(v)` | `v` (identity function) |
| `noop` | `()` | `undefined` — swallows args, reusable empty callback |
| `identity` | `(v)` | `v` — returns first arg unchanged |
| `wrapFunction` | `(x)` | `x` if function, else `() => x` |
| `memoize` | `(fn, hashFn?)` | Memoized function |
| `wait` | `(ms, opts?)` | Promise |
Expand Down
Loading
Loading