diff --git a/CHANGELOG.md b/CHANGELOG.md index e1ddf1950..eddf135f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ xx.xx.xxxx ### Utils * **Feature** - Added [`deepFreeze`](https://next.semantic-ui.com/docs/api/utils/cloning#deepfreeze) — recursively freezes a value in place and returns the same reference. Walks arrays and plain objects only, leaving `Date`, `Map`, `Set`, `RegExp`, DOM nodes, and custom class instances untouched so their internal slots keep working. Cycle-safe via an internal `WeakSet`; already-frozen inputs take a fast-path no-op. * **Feature** - Added [`createCache`](https://next.semantic-ui.com/docs/api/utils/cache) — a bounded, Map-like cache factory with pluggable eviction (`lru` default, `fifo`, `flush`) and an `onEvict` hook. Collapses ad-hoc `new Map()` + size-check patterns behind one named primitive. -* **Feature** - Added `assignInPlace()` for syncing an object's contents to match a source without replacing the reference — supports `preserveExistingKeys` to skip deletion and `returnChanged` to detect modifications +* **Feature** - Added `assignInPlace()` for syncing an object's contents to match a source without replacing the reference — supports `preserveExistingKeys` to skip deletion, `preserveGetters` to keep computed properties (own getter descriptors) intact across syncs, and `returnChanged` to detect modifications * **Breaking** - `kebabToCamel` and `camelToKebab` now use lossless encoding — digit-leading segments are preserved with `_` (e.g. `grid-2x2` → `grid_2x2`), and every uppercase letter gets its own hyphen (e.g. `arrowDownAZ` → `arrow-down-a-z`). Both accept a `separator` option to customize the digit-boundary character. `camelToKebab` now normalizes leading uppercase by default for DOM-safe output (e.g. `FooBar` → `foo-bar`); pass `{ lossless: true }` to preserve it for exact round-trips. * **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` diff --git a/ai/plans/ROADMAP.md b/ai/plans/ROADMAP.md index 2be0a4484..4e66788dd 100644 --- a/ai/plans/ROADMAP.md +++ b/ai/plans/ROADMAP.md @@ -83,6 +83,7 @@ Plans with an open PR or live pair work. Updated as ceremony when a PR opens; en - [Release 0.18.0](active/release-0-18-0.md) — [PR #122](https://github.com/Semantic-Org/Semantic-Next/pull/122) `docs/shippable` (menu trimming + audit pass pending). Ships the next tagged release; last was 0.17.0 in November. - [Signal Performance](active/signal-performance.md) — [PR #150](https://github.com/Semantic-Org/Semantic-Next/pull/150) freeze-by-default. Perf story unresolved (see plan's Bench Results); release inclusion is the open call. +- [Fine-Grained Reactivity](active/fine-grained-reactivity.md) — [PR #183](https://github.com/Semantic-Org/Semantic-Next/pull/183) `ReactiveDataContext` per-key Signal bag. Session 1 (each-block) landed; subtemplate, snippet, hydration sites still pending. --- ## Phase 0 — Renderer Architecture @@ -110,7 +111,7 @@ Behavioral changes and API contracts that downstream agents and consumers will t | # | Plan | Hours | Mode | Scope | Notes | |---|------|-------|------|-------|-------| | 2a | [Signal Performance](active/signal-performance.md) | 4-5h + audit | pair | scoped | `safety` preset system (`freeze` / `reference` / `none`) replacing `allowClone`. Audit of `.get()` call sites for get-mutate-set patterns gates the default flip. | -| 2a.1 | [Fine-Grained Reactivity](fine-grained-reactivity.md) | 6-8h | pair | initial | `ReactiveDataContext` — per-key Signal bag — at `{#each}` items, subtemplate `reactiveData`, snippet args. Eliminates the N×M coarse invalidation pattern. Lands after 2a. | +| 2a.1 | [Fine-Grained Reactivity](active/fine-grained-reactivity.md) | 6-8h | pair | initial | `ReactiveDataContext` — per-key Signal bag — at `{#each}` items, subtemplate `reactiveData`, snippet args. Eliminates the N×M coarse invalidation pattern. Lands after 2a. | | 2b | [Value Schema](value-schema.md) | 16-24h (2-3d) | pair | initial | Contract for ~20-30 form components. `value` setting + schema + `change` event. Gates form/form-field and the wrapper architecture. | | 2c | [State from Settings](state-from-settings.md) | 8h | pair | scoped | `{ default: 'all', from: 'setting' }` in `defaultState`. Eliminates manual shadowing for components that accept initial values from attributes but own them as state. | | 2d | [Subtemplate Settings](subtemplate-settings.md) | 8-12h | pair | initial | Reactive `defaultSettings` on subtemplates with merged proxy over parent web component settings. Same upgrade path: add `tagName` and the subtemplate becomes a web component with no API change. | diff --git a/ai/plans/active/fgr-as-mode-per-field-isolation.md b/ai/plans/active/fgr-as-mode-per-field-isolation.md new file mode 100644 index 000000000..f1a442536 --- /dev/null +++ b/ai/plans/active/fgr-as-mode-per-field-isolation.md @@ -0,0 +1,144 @@ +# FGR — Per-Field Isolation in `as`-Mode `{#each}` + +## Goal + +Close the per-FIELD isolation gap inside `{#each item in items}` where mutating one field of an item fans out to every binding reading that item, instead of only the binding reading the mutated field. Two `it.fails` contract tests in `subtree-spurious.test.js` (lines 720, 865) pin the gap; this plan makes them pass without regressing the 28 currently-passing FGR contracts or the per-record mount budget. + +A fast-follow to [Fine-Grained Reactivity](fine-grained-reactivity.md) (PR #183), which delivered per-key isolation across the three documented sites (each items / subtemplate `reactiveData` / snippet args). Per-FIELD inside the as-key was a known follow-on at PR-merge time — the architecture intentionally shipped without it so #183 could land at the structural floor on its remaining mount-cost regressions (`bulk-add-500` +18%, `remove-last-100` +24%, `each-mount-1000` +11-14%). + +## Background + +The shape this plan reaches for already exists in the codebase: `createSettingsProxy` at `packages/component/src/component-helpers.js:247-276`. Settings exposes dynamic property names (`settings.X`) where the framework can't enumerate the read set ahead of time — bindings and createComponent code decide which settings they care about at runtime. The proxy intercepts each `.X` access, lazy-creates a per-property reactive primitive in a `Map`, and registers the active Reaction. + +The 2a.2 plan applies that pattern to the as-key value in `{#each item in items}`. Same problem: bindings access `item.X` for various X that the framework cannot enumerate in advance (helper bodies, deeper expressions, anything past a runtime boundary). Same primitive: a Proxy on the item, lazy field-dep allocation in a Map, dep registered on get, fired by reconcile when the snapshot diff sees a changed key. + +The original draft predated `b485010aa` ("Refactor: Swap buildArgsProxy for native getter records") and framed Option A as "reintroducing a Proxy after we removed one." That framing was incorrect. `buildArgsRecord` operates on subtemplate args where the template author *declares* the key set at the call site (`{>child a=expr b=expr}` → compiler emits `node.reactiveData = { a, b }`). The framework iterates that AST artifact at clone time and installs descriptors for known keys. Item field access has no analogous declaration — fields are read in user-authored binding code at runtime, behind helper boundaries the renderer can't see through. The two access patterns call for two different primitives, and the codebase already chose the right one for each: descriptors for declared args, Proxy for dynamic names. + +## The gap + +`{#each todo in todos}` puts the whole item under one key on the per-record `ReactiveDataContext` (RDC). `getEachData` at `packages/renderer/src/engines/native/shared/each.js:32-44` returns: + +```js +return as + ? { [as]: item, [indexAs]: indexOrKey } + : { ...item, this: item, [indexAs]: indexOrKey }; +``` + +Spread mode (`!node.as`) flattens each item field into its own RDC key. A binding reading `text` registers a per-key dep on `'text'`; mutating one field fires only that dep. Per-field isolation works. + +As-mode wraps the item under one as-key. A binding reading `todo.completed` does: + +1. `proxy.todo` → RDC `trapGet` (`reactive-context.js:94-105`) → registers per-key dep on `'todo'` → returns the item +2. `.completed` → plain object access on the item — no dep registration + +Reconcile detects in-place mutation via snapshot diff and routes through `notifyKey(node.as)` at `each.js:360-361`. **Every** binding that read `todo.X` wakes, regardless of which field actually changed. Spread-mode at `each.js:363-368` calls `setKey(key, item[key])` per changed field and only the matching field-bound bindings wake — that path is correct. + +The bench-todo composition at `subtree-spurious.test.js:865` extends the same gap through a subtemplate boundary: `{#each todo in todos}{>todoItem id=todo._id title=todo.title completed=todo.completed}{/each}`. Each `reactiveData` getter (`buildArgsRecord` at `template.js:121-178`) re-evaluates `todo.X` on the parent data — meaning each subtemplate binding registers the SAME per-record `'todo'` dep, and one mutation wakes all three. The composition does not introduce a new architectural site; it surfaces the same as-mode gap one layer deeper. + +## Constraints + +- The 28 currently-passing FGR contract tests stay passing. No regressing isolation on the working sites. +- Spread-mode behavior unchanged. Per-field isolation already works there. +- `each-mount-1000`, `bulk-add-500`, `remove-last-100` do not regress further. Per-record allocation cost is the load-bearing axis; the cleanest fix shape may not be the cheapest, and the plan must respect that floor. +- Both `it.fails` markers come off (`subtree-spurious.test.js:720`, `:865`). +- `notifyKey('this')` for spread-mode (`each.js:367`) keeps working — readers of the whole-item key (`{this}` or spread closures) still wake on in-place mutation. +- Hydration path (`adoptServerItems` in `each.js:451-562`) inherits the same record shape — whatever solution lands has to flow through hydration without additional adoption cost. + +## Design + +### Recommendation: Option A — settings-proxy pattern at the as-key + +**Wrap the item in a lazy-allocated Proxy at the RDC's as-key fetch site.** When a Reaction reads `proxy.todo`, RDC's `trapGet` returns an item-tracking proxy whose `get` registers per-FIELD deps. When reconcile's snapshot diff sees changed keys it calls `notifyField(asKey, fieldName)` per change; only field-bound bindings wake. + +This is the same primitive `createSettingsProxy` uses for the same reason. The framework cannot enumerate which fields a binding will read on the item: bindings can do `item.X`, helpers can do `(t) => t.X`, deeper expressions can do `item.tags[0]`. All of those run in user-authored code, often behind helper boundaries the renderer cannot see through. Dep registration has to fire at the moment of `.X` access, dispatched dynamically. Proxy is the language's native primitive for that. + +The RDC at the each-record level is already a Proxy by design — that's where per-key reactivity lives. The proposed item proxy is one level deeper, lazily allocated when a Reaction reads the as-key. It doesn't change the subtemplate-side `buildArgsRecord` layer or its IC story. + +### Why not Option D (getter-descriptor item wrapper) + +Considered: build a getter-descriptor wrapper around the item at record-creation time, mirroring `buildArgsRecord`'s shape. Walk `Object.keys(item)`, install a getter per field that calls `dep.depend(); return item[field]`. Same primitive, no new Proxy. + +Rejected because **the framework can't enumerate the read set** for items. `buildArgsRecord` works because subtemplate args are template-declared at the call site (`{>child a=expr b=expr}` → compiler emits `node.reactiveData = { a, b }`); the renderer iterates that AST artifact at clone time and installs descriptors for every key the bindings could read. There's no analogous declaration for item field access. A binding's read set is encoded in the binding's own code, sometimes inside helper bodies the compiler can't see through. A descriptor wrapper would have to enumerate fields by walking `Object.keys(item)` at creation, which catches only fields present at that moment — a binding reading a field added later via `setProperty` would silently never wake. The Proxy's get trap dispatches on any property access, so registration works the same regardless of whether the field exists yet. + +### Why not Option B (spread under as-key) + +Considered: have `getEachData` spread item fields into per-record RDC keys *and* keep an as-key, with evaluator awareness routing `todo.completed` to the spread fields. Rejected for the same reason the draft rejected it — collision risk between as-key and field names, and it requires breaking the renderer/evaluator separation. Verifying against current code: `evaluator.lookupExpressionValue` routes through `parentData` opaquely; teaching it the as-key concept would couple the evaluator to a renderer-internal shape. Architectural cost stays high. + +### Why not Option C (compile-time hoisting) + +Compiler walks each-body AST, finds `todo.X` accesses, emits per-field RDC keys at each-block creation. Rejected because the bench-todo failing test at line 865 uses helper-mediated access where the compiler cannot see `.X` through the helper boundary — and the verbose-syntax bench-todo test threads `todo.X` through subtemplate `reactiveData` expressions evaluated at runtime, still on the parent data context. Compile-time hoisting would need to be paired with A or B for the missed cases. + +## Implementation + +### 1. RDC extension — `packages/renderer/src/engines/native/reactive-context.js` + +Add an `asKey` constructor option. When `prop === asKey` in `trapGet`: + +- Lazy-allocate an item-tracking proxy on first read (cached on the RDC instance; cleared when the as-key value replaces or the record is disposed). +- The item proxy's `get` trap registers a per-field dep keyed by `(asKey, fieldName)` on the active Reaction, then returns `item[fieldName]`. +- Per-field deps live in a null-prototype object on the RDC: `target.fieldDeps[asKey][fieldName] = Dependency`. Lazy-allocate the inner map on first reactive field read. Match the existing `target.values`/`target.deps` shape — both are eager at the record level for IC stability, but field-level deps are only allocated when a binding actually subscribes to a field. + +Add a method `notifyField(asKey, fieldName)`: + +- Fires the per-field dep for `(asKey, fieldName)` if present. +- No-op if no Reaction has subscribed to that field — common case, cheap. + +The "any-field" fallback dep that the draft proposed for whole-item closures: I do not believe it is needed. A closure that captures `todo` and reads `.X` later still dispatches through the proxy and registers the per-field dep at its read-time Reaction. A closure that examines item *identity* (===) is unaffected by in-place mutation (same ref). Worth confirming during implementation; flag as Open Question 1 below. + +### 2. Each-block reconcile — `packages/renderer/src/engines/native/blocks/each.js:339-368` + +In the same-ref + snapshot-diff branch, when `node.as` is set: + +- Replace the single `record.dataContext.notifyKey(node.as)` with a loop over `changedKeys` calling `record.dataContext.notifyField(node.as, key)`. +- Spread-mode branch (`!node.as`) untouched — it keeps `setKey(key, item[key])` per changed key and `notifyKey('this')`. + +`createRecord` and `adoptServerItems` (`each.js:139-176`, `:451-562`) pass `asKey: node.as` into the RDC constructor. No other changes at the call sites. + +### 3. Tests — `packages/renderer/test/browser/subtree-spurious.test.js` + +- Remove `it.fails` from the test at line 720 (each-block per-FIELD isolation). +- Remove `it.fails` from the test at line 865 (subtemplate-inside-each composition / bench-todo shape). +- Add a positive test: bindings reading `todo.text` do NOT wake when `todo.completed` mutates (sibling-field isolation within the same item, as a finer-grained version of the existing sibling-record test at line 764). +- Add a closure-capture test: a helper `(todo) => todo.text` invoked from inside a binding registers the per-field dep correctly when invoked through the proxy, and survives in-place mutation of a different field without re-firing. + +### 4. Bench + +- `each-mount-1000` should stay flat. Lazy item-proxy allocation defers the cost past the mount pass — a record's item proxy is only built on first reactive read, which happens during binding render and is already accounted for in current mount costs. Verify by running before/after on the existing bench, expecting variance ≤ ±2%. +- `bench-todo` `toggle-*` and `edit-*` metrics should improve modestly — currently each toggle wakes every binding reading the item; per-FIELD cuts the wake to one binding per mutated field. Magnitude depends on binding count per item; the bench-todo template has three subtemplate bindings per todo, so the upper bound is ~3x reduction in spurious work for single-field toggles. +- `bulk-add-500` and `remove-last-100` should not regress. These are mount/dispose-dominant, and the lazy proxy is not allocated during pure mount. If they DO regress, revisit the lazy boundary — possibly defer the per-field dep allocation to the FIRST reactive field read rather than the first as-key read. + +No new bench is needed for the per-FIELD contract — the failing tests pin the contract directly. A `subtree-spurious`-style metric was considered and dropped as redundant. + +## Composition: subtemplate-inside-each + +The bench-todo failing test at line 865 is a composition of two architectural layers: the each-block RDC and the subtemplate's `buildArgsRecord`. The flow under this plan: + +1. `setProperty('a', 'completed', true)` → array Signal fires. +2. Each-block `update` → reconcile → snapshot diff returns `changedKeys = ['completed']`. +3. As-mode branch fires `notifyField('todo', 'completed')`. +4. The subtemplate's `readCompleted` binding registered a per-field dep on `('todo', 'completed')` when its getter evaluated `todo.completed` against the parent data — that's where the proxy intercepts. +5. Only `readCompleted` wakes. `readId` and `readTitle` registered per-field deps on `('todo', '_id')` and `('todo', 'title')` — those deps did not fire. + +The composition works because the subtemplate's getter-descriptor record looks up `todo.X` on the parent data context (the each-record's RDC proxy), and that proxy is the per-FIELD interception point. The fix lives at the each-block layer; the subtemplate layer needs no change. **This is the angle the original draft did not explicitly verify.** It does check out — the draft's recommendation handles it correctly, but the path through the subtemplate boundary deserves to be stated explicitly so the implementer can reason about it. + +## Open Questions + +1. **Is the "any-field" fallback dep needed?** The draft's recommendation included a `'$ANY:asKey'` dep for whole-item closures. My read of the proxy semantics says it is not needed — proxies dispatch on `.X` access regardless of when the closure was captured, and identity-based reads (===) are unaffected by in-place mutation. Resolve in-session by writing the closure-capture test (Step 3, fourth bullet) and seeing whether it passes without the fallback. If it fails, restore the fallback dep with a single any-field dep per as-key, fired on every `notifyField` call. + +2. **Late-added field handling.** `setProperty(id, 'newKey', x)` adds a key to an item that was not present at record creation. Snapshot diff already detects added keys (`each.js:67-69`). The proposed plan registers per-field deps lazily on first reactive read — a binding reading a field that does not yet exist registers a dep on `(asKey, fieldName)`, then `notifyField` fires that dep when the field is added. Confirm this works end-to-end with a test: render a binding for `todo.flag` where `flag` is initially absent, then `setProperty(id, 'flag', true)` and assert the binding fires. + +3. **Hydration parity.** `adoptServerItems` constructs the RDC with the same options as `createRecord` (currently `registerItemContext: true, sealKeysAfterReplace: !!node.as`). Adding `asKey: node.as` extends both call sites identically. Confirm during implementation that hydrated bindings register per-field deps on first run — not a design question, an implementation sanity check. + +## Dependencies + +- [Fine-Grained Reactivity](fine-grained-reactivity.md) — must merge first. This plan extends the RDC primitive that #183 introduces, modifies the each-block reconcile path #183 establishes, and removes contract markers #183 left in place. + +## Sessions (estimated) + +1. **Land RDC + each-block changes** (~2-3h pair). Extend `ReactiveDataContext` with `asKey` and `notifyField`; thread `asKey: node.as` through `createRecord` and `adoptServerItems`; replace `notifyKey(node.as)` in reconcile with per-field loop; remove `it.fails` markers; add the new positive/closure-capture tests; run the renderer test suite green. + +2. **Bench verification** (~1h pair). Run `each-mount-1000`, `bulk-add-500`, `remove-last-100`, `bench-todo` against the prior session's baseline. Lock in the lazy boundary (Open Question 2) based on what bulk-add and remove-last show. If regressions exceed the structural floor, defer field-dep allocation further or accept the trade as documented. + +## Status + +Initial scope — design decisions locked (Option A with lazy item-proxy on the RDC, per-field deps via `notifyField`, no compile-time hoisting). Three open questions to resolve in-session, all verifiable by writing tests rather than design discussion. diff --git a/ai/plans/fine-grained-reactivity.md b/ai/plans/active/fine-grained-reactivity.md similarity index 97% rename from ai/plans/fine-grained-reactivity.md rename to ai/plans/active/fine-grained-reactivity.md index c4c1c010d..8c9f735b0 100644 --- a/ai/plans/fine-grained-reactivity.md +++ b/ai/plans/active/fine-grained-reactivity.md @@ -253,7 +253,7 @@ Three layers of `ReactiveDataContext` stack via Proxy fallthrough: a snippet's c ## Dependencies -- **[Signal Performance](active/signal-performance.md)** — lands first. The `safety` preset system this plan uses (`safety: 'none'` for internal per-key Signals) arrives with signal-performance. The migration-audit gating work that plan describes is orthogonal to this one, but the preset API needs to be in place before `ReactiveDataContext` is written against it. +- **[Signal Performance](signal-performance.md)** — lands first. The `safety` preset system this plan uses (`safety: 'none'` for internal per-key Signals) arrives with signal-performance. The migration-audit gating work that plan describes is orthogonal to this one, but the preset API needs to be in place before `ReactiveDataContext` is written against it. - **Snippet zero-reactivity investigation** (open question 1) — blocks session 3 (snippet-site adoption) but not session 1 (each-site) or session 2 (subtemplate-site). ## Sessions (estimated) diff --git a/ai/plans/active/signal-performance.md b/ai/plans/active/signal-performance.md index 5125a1efe..725b6e6c8 100644 --- a/ai/plans/active/signal-performance.md +++ b/ai/plans/active/signal-performance.md @@ -83,7 +83,7 @@ new Signal(data, { safety: 'none', equalityFunction: isEqual }) // no pro None upstream — all items are independent of the broader roadmap. -**Downstream:** [Fine-Grained Reactivity](../fine-grained-reactivity.md) consumes the `safety: 'none'` preset for framework-internal per-key Signals. Land this first; Fine-Grained Reactivity slots in once the preset API is stable. +**Downstream:** [Fine-Grained Reactivity](fine-grained-reactivity.md) consumes the `safety: 'none'` preset for framework-internal per-key Signals. Land this first; Fine-Grained Reactivity slots in once the preset API is stable. ## Known callsites requiring `safety: 'none'` diff --git a/ai/skills/contributing/native-renderer.md b/ai/skills/contributing/native-renderer.md index 3947e14ac..fc6251114 100644 --- a/ai/skills/contributing/native-renderer.md +++ b/ai/skills/contributing/native-renderer.md @@ -385,5 +385,5 @@ Single-file: `npm test -- attribute-bindings`. Note: `-t` filters report non-mat | **Native SSR** (`ai/plans/archive/native-ssr.md`) | Server-side rendering and hydration changes | | **Renderer Refinement** (`ai/plans/archive/native-renderer-refinement.md`) | Performance, code quality, architectural purity | | **Native Renderer Blocks** (`ai/plans/archive/native-renderer-blocks.md`) | Block-level concerns (drove the `defineBlock` decomposition) | -| **Fine-Grained Reactivity** (`ai/plans/fine-grained-reactivity.md`) | Selective `dataDep` tracking, cutting subscription overhead | +| **Fine-Grained Reactivity** (`ai/plans/active/fine-grained-reactivity.md`) | Selective `dataDep` tracking, cutting subscription overhead | | **Lit Removal** (archived) | Historical context — `ComponentBase` extends `HTMLElement` is shipped | diff --git a/docs/src/pages/docs/api/utils/objects.mdx b/docs/src/pages/docs/api/utils/objects.mdx index 6821ccdd0..45719878b 100644 --- a/docs/src/pages/docs/api/utils/objects.mdx +++ b/docs/src/pages/docs/api/utils/objects.mdx @@ -158,7 +158,7 @@ console.log(result); // { a: 1, b: 2, c: 3, d: 4 } ### assignInPlace ```javascript -function assignInPlace(target, source, { preserveExistingKeys = false, returnChanged = false } = {}) +function assignInPlace(target, source, { preserveExistingKeys = false, preserveGetters = false, returnChanged = false } = {}) ``` Mutates the target object in place so its contents match the source, without replacing the object reference. Deletes keys not present in source (unless `preserveExistingKeys` is true), then assigns all source properties. @@ -176,6 +176,7 @@ Mutates the target object in place so its contents match the source, without rep | Name | Type | Default | Description | |---------------------|---------|---------|-------------| | preserveExistingKeys | boolean | false | Keep keys in target that are not in source | +| preserveGetters | boolean | false | Skip own getter descriptors when deleting keys not in source. Useful when target carries computed properties that shouldn't be torn down by syncs | | returnChanged | boolean | false | Return whether any properties changed instead of the target | #### Returns @@ -196,6 +197,12 @@ const settings = { theme: 'light', lang: 'en' }; assignInPlace(settings, { theme: 'dark', fontSize: 14 }, { preserveExistingKeys: true }); console.log(settings); // { theme: 'dark', lang: 'en', fontSize: 14 } +// Preserve computed properties +const view = { name: 'Alice' }; +Object.defineProperty(view, 'greeting', { get() { return `Hi, ${this.name}`; }, enumerable: true }); +assignInPlace(view, { name: 'Bob' }, { preserveGetters: true }); +console.log(view.greeting); // 'Hi, Bob' — getter intact + // Detect changes const state = { count: 5 }; assignInPlace(state, { count: 5 }, { returnChanged: true }); // false diff --git a/packages/component/bench/tachometer/bench-template.js b/packages/component/bench/tachometer/bench-template.js index ce9d37a71..77b2943ee 100644 --- a/packages/component/bench/tachometer/bench-template.js +++ b/packages/component/bench/tachometer/bench-template.js @@ -122,7 +122,11 @@ const dataBlobChild = defineComponent({ defineComponent({ tagName: 'bench-data-blob', renderingEngine: 'native', - template: `{#each i in idxs}{>child data=getCardData}{/each}`, + // Explicit form `{> template name='child' data=expr}` — shorthand + // `{>child data=expr}` parses `data=expr` as a reactiveData entry + // (key='data') rather than as the blob argument, so this metric would + // stop measuring the blob path it was named for. + template: `{#each i in idxs}{> template name='child' data=getCardData}{/each}`, subTemplates: { child: dataBlobChild }, defaultState: { labelVal: 'init' }, createComponent: ({ state }) => ({ diff --git a/packages/component/test/browser/component-lit.test.js b/packages/component/test/browser/component-lit.test.js index 8d94954a3..c8e412a6a 100644 --- a/packages/component/test/browser/component-lit.test.js +++ b/packages/component/test/browser/component-lit.test.js @@ -4,6 +4,18 @@ import { LitWebComponentBase } from '../../src/engines/lit/base.js'; import '../../src/engines/lit/register.js'; import { defineComponent } from '../../src/index.js'; +// vi.mock is hoisted to module top by vitest — keeping it explicit here +// matches that hoist, scopes isServer:true to this entire file, and +// avoids the "vi.mock must be at the top level" deprecation that +// causes intermittent CI failures when the inline form races test setup. +vi.mock('@semantic-ui/utils', async () => { + const actual = await vi.importActual('@semantic-ui/utils'); + return { + ...actual, + isServer: true, + }; +}); + /* Lit-specific component tests — these test LitElement internals (static styles, willUpdate, shadowRootOptions) that don't exist @@ -80,14 +92,6 @@ describe('Component (Lit-specific)', () => { describe('Server-Side Rendering', () => { it('should handle SSR scenario in willUpdate', () => { - vi.mock('@semantic-ui/utils', async () => { - const actual = await vi.importActual('@semantic-ui/utils'); - return { - ...actual, - isServer: true, - }; - }); - const TestComponent = defineComponent({ tagName: 'test-lit-ssr-component', renderingEngine: 'lit', diff --git a/packages/reactivity/src/dependency.js b/packages/reactivity/src/dependency.js index 750339779..7c533f7a3 100755 --- a/packages/reactivity/src/dependency.js +++ b/packages/reactivity/src/dependency.js @@ -26,6 +26,8 @@ export class Dependency { } changed(context) { + // Skip tracing-context bookkeeping when no subscribers are listening. + if (this.subscribers.size === 0) { return; } if (isTracing()) { if (context) { this.context = context; diff --git a/packages/renderer/src/engines/native/blocks/async.js b/packages/renderer/src/engines/native/blocks/async.js index 015e69a86..78efcb450 100644 --- a/packages/renderer/src/engines/native/blocks/async.js +++ b/packages/renderer/src/engines/native/blocks/async.js @@ -45,7 +45,7 @@ function createSuccessDataContext(node, value) { // server already rendered the current state, so we skip the synchronous // loadingContent re-render (see hydrate hook). function evaluateAndRender(ctx, { skipLoadingRender = false } = {}) { - const { node, data, scope, region, renderAST, lookupExpression, self, isSVG } = ctx; + const { node, data, scope, region, renderAST, lookupExpression, childContext, self, isSVG } = ctx; const result = lookupExpression(node.expression); const currentGen = ++self.generation; @@ -53,7 +53,7 @@ function evaluateAndRender(ctx, { skipLoadingRender = false } = {}) { const stateScope = scope.child(); const fragment = renderAST({ ast, - data: { ...data, ...extraData }, + data: childContext(data, extraData), scope: stateScope, isSVG, }); @@ -93,13 +93,13 @@ function evaluateAndRender(ctx, { skipLoadingRender = false } = {}) { } function renderErrorState(ctx, err) { - const { node, data, scope, region, renderAST, isSVG } = ctx; + const { node, data, scope, region, renderAST, childContext, isSVG } = ctx; if (!node.errorContent?.length) { return; } const stateScope = scope.child(); const errorData = node.errorAs ? { [node.errorAs]: err } : { this: err }; const fragment = renderAST({ ast: node.errorContent, - data: { ...data, ...errorData }, + data: childContext(data, errorData), scope: stateScope, isSVG, }); diff --git a/packages/renderer/src/engines/native/blocks/each.js b/packages/renderer/src/engines/native/blocks/each.js index 50e1b8a3a..55dcebab4 100644 --- a/packages/renderer/src/engines/native/blocks/each.js +++ b/packages/renderer/src/engines/native/blocks/each.js @@ -1,7 +1,7 @@ -import { Signal } from '@semantic-ui/reactivity'; import { arrayFromObject, isArray, isEmpty } from '@semantic-ui/utils'; import { isBlockClose, isBlockOpen } from '../../../build-html-string.js'; import { defineBlock } from '../define-block.js'; +import { ReactiveDataContext } from '../reactive-context.js'; import { decodeItemKey, getEachData, getItemID, SUI_ITEM_MARKER } from '../shared/each.js'; import { registerBlock } from './registry.js'; @@ -17,6 +17,16 @@ import { registerBlock } from './registry.js'; over *live* DOM, instead of dereferencing a stale childNodes snapshot taken at item-creation time. + Each record holds a ReactiveDataContext that exposes per-key Signals + via a Proxy. The proxy is the data context the item's content renders + against; reading `proxy.foo` registers a per-key dependency, falling + through to the parent context on miss. Replacing item data (ref change) + routes through `dataContext.replace()`; in-place mutations are detected + via a per-record snapshot diff and routed through per-key `setKey` for + the spread case (primitive value pushes) or `notifyKey` for the `as` + case (the wrapper key holds the item by reference, so the ref-equality + short-circuit needs an explicit nudge). + Hydrate adopts the server-rendered per-item DOM via `` markers and wires per-item Reactions in place — the same "register Reactions on hydrate" contract every @@ -24,81 +34,44 @@ import { registerBlock } from './registry.js'; */ -// Proxies created by this module go into the WeakSet; template.js checks -// membership to decide when expression reads should register deps directly -// (item context) versus wrapping in Reaction.nonreactive (static data). -const itemContextProxies = new WeakSet(); -export function isItemContext(data) { - return data != null && itemContextProxies.has(data); -} - function getCollectionType(items) { return isArray(items) ? 'array' : 'object'; } // Allocate once per record at first reconcile (createSnapshot). On // subsequent reconciles, refreshSnapshotAndDetect both diffs the item -// against the cached snap AND updates snap in place — one pass, zero -// allocation. The common case on update-10th (900 unchanged items) pays -// only a cache-friendly `snap.k === item.k` check per prop per item. +// against the cached snapshot AND updates the snapshot in place — one +// pass, zero allocation. The common case on update-10th (900 unchanged +// items) pays only a cache-friendly `snapshot.k === item.k` check per +// prop per item. function createSnapshot(item) { if (item === null || typeof item !== 'object') { return item; } - const snap = {}; - for (const k in item) { - if (Object.prototype.hasOwnProperty.call(item, k)) { - snap[k] = item[k]; + const snapshot = {}; + for (const key in item) { + if (Object.prototype.hasOwnProperty.call(item, key)) { + snapshot[key] = item[key]; } } - return snap; + return snapshot; } -// Returns true if any top-level prop of `item` differs from `snap`, and -// updates `snap` to match `item`. Added keys register as a change on the -// iteration that introduces them; removed keys slip past (we don't scan -// snap's keys — the common case has a stable prop set, and the alternative -// would pessimize the hot path for a never-observed contract). If the -// prop set is unstable, the user can always call itemSignal.notify() -// manually. -function refreshSnapshotAndDetect(snap, item) { - if (snap === null || typeof snap !== 'object') { return snap !== item; } - if (item === null || typeof item !== 'object') { return true; } - let changed = false; - for (const k in item) { - if (!Object.prototype.hasOwnProperty.call(item, k)) { continue; } - if (snap[k] !== item[k]) { - changed = true; - snap[k] = item[k]; - } - else if (!(k in snap)) { - // New key arrived with same value (e.g. undefined). Rare — record - // it and flag changed so the binding re-evaluates `k in item`. - changed = true; - snap[k] = item[k]; +// Returns the list of changed keys (or null if nothing changed) and +// updates `snapshot` to match `item` in place. Added keys register on +// the iteration that introduces them; removed keys slip past — the +// common case has a stable prop set, and the alternative would +// pessimize the hot path for a never-observed contract. +function refreshSnapshotAndDetect(snapshot, item) { + if (snapshot === null || typeof snapshot !== 'object') { return null; } + if (item === null || typeof item !== 'object') { return null; } + let changedKeys = null; + for (const key in item) { + if (!Object.prototype.hasOwnProperty.call(item, key)) { continue; } + if (snapshot[key] !== item[key]) { + (changedKeys ??= []).push(key); + snapshot[key] = item[key]; } } - return changed; -} - -// Load-bearing: the parent-data fallthrough + item-signal reactivity -// pattern is what lets `{name}` resolve to either an item field or a -// parent-context binding without the caller knowing which. Flattening -// this to a merged object would break per-item Signal subscriptions — -// expressions wouldn't re-evaluate when the item mutates. -function createItemDataProxy(parentData, itemSignal) { - const proxy = new Proxy(parentData, { - get(target, prop) { - if (typeof prop === 'symbol') { return target[prop]; } - const itemData = itemSignal.value; - if (prop in itemData) { return itemData[prop]; } - return target[prop]; - }, - has(target, prop) { - const itemData = itemSignal.peek(); - return (prop in itemData) || (prop in target); - }, - }); - itemContextProxies.add(proxy); - return proxy; + return changedKeys; } // Remove every node in the half-open range (start, end] — i.e. from @@ -109,11 +82,11 @@ function createItemDataProxy(parentData, itemSignal) { // markers right now IS the item's current DOM, regardless of what was // there at createRecord time. function removeRangeContent(startMarker, endMarker) { - let n = startMarker.nextSibling; - while (n && n !== endMarker) { - const next = n.nextSibling; - n.remove(); - n = next; + let node = startMarker.nextSibling; + while (node && node !== endMarker) { + const next = node.nextSibling; + node.remove(); + node = next; } } @@ -124,12 +97,12 @@ function removeRangeContent(startMarker, endMarker) { // would point into the fragment, not the source. After this call, both // markers and all inner content are in the fragment in original order. function extractRangeToFragment(startMarker, endMarker, fragment) { - let n = startMarker.nextSibling; + let node = startMarker.nextSibling; fragment.appendChild(startMarker); - while (n && n !== endMarker) { - const next = n.nextSibling; - fragment.appendChild(n); - n = next; + while (node && node !== endMarker) { + const next = node.nextSibling; + fragment.appendChild(node); + node = next; } fragment.appendChild(endMarker); } @@ -137,6 +110,7 @@ function extractRangeToFragment(startMarker, endMarker, fragment) { function clearRecords(records) { for (const record of records) { record.scope.dispose(); + if (record.dataContext) { record.dataContext.dispose(); } disposeRecordDOM(record); } records.length = 0; @@ -165,9 +139,12 @@ function disposeRecordDOM(record) { function createRecord({ key, item, index, collectionType, node, data, scope, renderAST, isSVG }) { const eachData = getEachData(item, index, collectionType, node); const itemScope = scope.child(); - const itemSignal = new Signal(eachData, { allowClone: false }); - const itemProxy = createItemDataProxy(data, itemSignal); - const fragment = renderAST({ ast: node.content, data: itemProxy, scope: itemScope, isSVG }); + const dataContext = new ReactiveDataContext(data, { + registerItemContext: true, + sealKeysAfterReplace: !!node.as, + }); + dataContext.replace(eachData); + const fragment = renderAST({ ast: node.content, data: dataContext.proxy, scope: itemScope, isSVG }); // Marker-bounded item range: startMarker ... [item content] ... endMarker. // These two empty text nodes are the record's only positional identity. // Inner blocks never touch them — each nested DynamicRegion owns its own @@ -180,24 +157,27 @@ function createRecord({ key, item, index, collectionType, node, data, scope, ren key, item, index, - itemSignal, + dataContext, startMarker, endMarker, fragment, scope: itemScope, isElse: false, - // Populated on the first reconcile pass (phase 3). Null marker means - // "no prior snapshot → record is fresh, no subscribers to wake up, - // skip notify". Cleared to a shallow-clone of the item's top-level - // props once the record has seen one reconcile; subsequent passes - // compare against this snapshot to detect in-place mutations without - // firing notify() on untouched items. - propsSnapshot: null, + // Captured on creation so the first reconcile pass can detect + // in-place mutations against a real reference. Refreshed in place + // by refreshSnapshotAndDetect on each subsequent reconcile. + snapshot: createSnapshot(item), + // True until the record's first reconcile pass. Distinguishes + // freshly-created records (whose bindings were wired against the + // current data and have no stale subscribers to wake) from steady- + // state records (which need notifyKey broadcasts on in-place mutation). + fresh: true, }; } function disposeRecord(record) { record.scope.dispose(); + if (record.dataContext) { record.dataContext.dispose(); } disposeRecordDOM(record); } @@ -212,10 +192,13 @@ function disposeRecord(record) { // extracts the [startMarker..endMarker] range into a fragment, // then reinserts it — correct regardless of what inner blocks // have done to content between the markers. -// Phase 3: itemSignal updates for records whose item/index changed. +// Phase 3: per-key data updates. Ref-changed records refresh their +// whole context via dataContext.replace(); same-ref records +// run a snapshot diff and only touch keys that actually +// mutated. function reconcile({ records, items, collectionType, node, data, scope, region, renderAST, isSVG }) { const oldRecords = records.slice(); - const oldKeys = oldRecords.map((r) => r.key); + const oldKeys = oldRecords.map((record) => record.key); const newKeys = items.map((item, i) => getItemID(item, i, collectionType)); const newRecords = new Array(items.length); @@ -264,8 +247,8 @@ function reconcile({ records, items, collectionType, node, data, scope, region, } else { const oldIdx = oldKeyToIdx.get(newKeys[newHead]); - const oldRec = oldIdx !== undefined ? oldRecords[oldIdx] : null; - if (oldRec === null) { + const oldRecord = oldIdx !== undefined ? oldRecords[oldIdx] : null; + if (oldRecord === null) { newRecords[newHead] = createRecord({ key: newKeys[newHead], item: items[newHead], @@ -279,7 +262,7 @@ function reconcile({ records, items, collectionType, node, data, scope, region, }); } else { - newRecords[newHead] = oldRec; + newRecords[newHead] = oldRecord; oldRecords[oldIdx] = null; } newHead++; @@ -303,8 +286,8 @@ function reconcile({ records, items, collectionType, node, data, scope, region, } while (oldHead <= oldTail) { - const r = oldRecords[oldHead++]; - if (r !== null) { disposeRecord(r); } + const record = oldRecords[oldHead++]; + if (record !== null) { disposeRecord(record); } } // Phase 2: linearize DOM order using markers. @@ -322,65 +305,75 @@ function reconcile({ records, items, collectionType, node, data, scope, region, // Fresh records start with their content already in the fragment // we built in createRecord — insert it directly. let cursor = region.anchor; - for (const rec of newRecords) { - if (!rec) { continue; } - if (rec.fragment) { + for (const record of newRecords) { + if (!record) { continue; } + if (record.fragment) { // Freshly created record — content and markers are in the fragment. - cursor.after(rec.fragment); - rec.fragment = null; + cursor.after(record.fragment); + record.fragment = null; } - else if (rec.startMarker.previousSibling !== cursor) { + else if (record.startMarker.previousSibling !== cursor) { // Existing record in the wrong position — extract and reinsert. - const frag = document.createDocumentFragment(); - extractRangeToFragment(rec.startMarker, rec.endMarker, frag); - cursor.after(frag); + const fragment = document.createDocumentFragment(); + extractRangeToFragment(record.startMarker, record.endMarker, fragment); + cursor.after(fragment); } - cursor = rec.endMarker; + cursor = record.endMarker; } - // Phase 3: update item signals where item ref or index changed, OR - // where a retained same-ref item mutated in place. + // Phase 3: per-key data updates. + // + // Ref-change path: rebuild the wrapper and push every key. Per-key + // Signals dedup via default isEqual, so primitives that happen to + // share values across the swap don't notify. // - // Same-ref same-index objects bypass Signal.set's equality gate (the - // wrapper's `[as]: item` is reference-equal across calls and isEqual - // stops at ===). The naive fix — calling notify() unconditionally — - // wakes every per-item binding on every reconcile, so unchanged - // records pay the cost of mutated ones. Instead, snapshot each item's - // top-level props at reconcile end and shallow-compare on the next - // pass, only firing notify() when a prop actually changed. Top-level - // prop mutations (items[i].active = ...) re-render dependent - // expressions; nested-object mutations (items[i].nested.x) slip past - // the shallow check, and no documented contract relies on them. + // Same-ref path: snapshot-diff the raw item to find which fields + // mutated. Spread-mode templates push each changed primitive into + // its per-key Signal; readers of `proxy.this` get an explicit + // notifyKey because the wrapper's `this` key holds the item by + // reference and the ref equality gate would otherwise short-circuit. + // `as`-mode templates notify the as-key — the wrapper holds the + // item by reference and the per-key Signal can't see the inner + // mutation any other way. + // + // Fresh records (just created in this pass) skip the diff because + // their bindings were wired synchronously against the current values + // and have no stale subscribers to wake. for (let i = 0; i < newRecords.length; i++) { - const rec = newRecords[i]; + const record = newRecords[i]; const item = items[i]; - if (rec.item !== item || rec.index !== i) { - rec.itemSignal.set(getEachData(item, i, collectionType, node)); - rec.item = item; - rec.index = i; - // Capture the new item's shape so a future in-place mutation is - // detectable. Only allocation-site for the snapshot object. - rec.propsSnapshot = createSnapshot(item); + const refChanged = record.item !== item || record.index !== i; + + if (refChanged) { + record.dataContext.replace(getEachData(item, i, collectionType, node)); + record.item = item; + record.index = i; + record.snapshot = createSnapshot(item); } - else if (typeof item === 'object' && item !== null) { - if (rec.propsSnapshot === null) { - // Fresh record (first reconcile after createRecord). Bindings - // were wired synchronously against the signal's value; there's - // no stale subscriber to wake. Record the snapshot for the next - // reconcile's comparison. - rec.propsSnapshot = createSnapshot(item); + else if (typeof item === 'object' && item !== null && !record.fresh) { + if (record.snapshot === null) { + record.snapshot = createSnapshot(item); } - else if (refreshSnapshotAndDetect(rec.propsSnapshot, item)) { - // Mutation observed — propagate to per-item bindings. The - // snapshot was updated in place by refreshSnapshotAndDetect, - // no new allocation. - rec.itemSignal.notify(); + else { + const changedKeys = refreshSnapshotAndDetect(record.snapshot, item); + if (changedKeys) { + if (node.as) { + record.dataContext.notifyKey(node.as); + } + else { + for (const key of changedKeys) { + record.dataContext.setKey(key, item[key]); + } + record.dataContext.notifyKey('this'); + } + } } } + record.fresh = false; } records.length = 0; - for (const r of newRecords) { records.push(r); } + for (const record of newRecords) { records.push(record); } } function renderElse({ records, node, data, scope, region, renderAST, isSVG }) { @@ -392,12 +385,13 @@ function renderElse({ records, node, data, scope, region, renderAST, isSVG }) { key: null, item: null, index: -1, - itemSignal: null, + dataContext: null, startMarker: null, endMarker: null, scope: elseScope, isElse: true, - propsSnapshot: null, + snapshot: null, + fresh: false, }); } @@ -422,27 +416,27 @@ function extractServerItemGroups(ownedNodes) { let current = null; let blockDepth = 0; - for (const n of ownedNodes) { - if (n.nodeType === Node.COMMENT_NODE) { - const data = n.data; + for (const node of ownedNodes) { + if (node.nodeType === Node.COMMENT_NODE) { + const data = node.data; if (isBlockOpen(data)) { blockDepth++; - if (current) { current.nodes.push(n); } + if (current) { current.nodes.push(node); } continue; } if (isBlockClose(data)) { blockDepth--; - if (current) { current.nodes.push(n); } + if (current) { current.nodes.push(node); } continue; } if (blockDepth === 0 && data.startsWith(SUI_ITEM_MARKER)) { if (current) { groups.push(current); } const key = decodeItemKey(data.slice(SUI_ITEM_MARKER.length)); - current = { key, startComment: n, nodes: [] }; + current = { key, startComment: node, nodes: [] }; continue; } } - if (current) { current.nodes.push(n); } + if (current) { current.nodes.push(node); } } if (current) { groups.push(current); } return groups; @@ -472,7 +466,7 @@ function adoptServerItems({ } const serverByKey = new Map(); - for (const g of serverGroups) { serverByKey.set(g.key, g); } + for (const group of serverGroups) { serverByKey.set(group.key, group); } const newRecords = []; const usedKeys = new Set(); @@ -487,8 +481,11 @@ function adoptServerItems({ usedKeys.add(key); const eachData = getEachData(item, i, collectionType, node); const itemScope = scope.child(); - const itemSignal = new Signal(eachData, { allowClone: false }); - const itemProxy = createItemDataProxy(data, itemSignal); + const dataContext = new ReactiveDataContext(data, { + registerItemContext: true, + sealKeysAfterReplace: !!node.as, + }); + dataContext.replace(eachData); // Wire per-item reactivity on the existing DOM. hydrateInnerContent // moves the nodes into a temporary fragment, walks with @@ -498,7 +495,7 @@ function adoptServerItems({ hydrateInnerContent({ ownedNodes: mutableNodes, innerAST: node.content, - data: itemProxy, + data: dataContext.proxy, scope: itemScope, }); @@ -515,21 +512,22 @@ function adoptServerItems({ // Sibling block markers can land between item boundaries — // reassemble via fragment so endMarker follows the last item node, // not whichever node happens to be last. - const frag = document.createDocumentFragment(); - frag.append(startMarker, ...mutableNodes, endMarker); - insertAfter.after(frag); + const fragment = document.createDocumentFragment(); + fragment.append(startMarker, ...mutableNodes, endMarker); + insertAfter.after(fragment); insertAfter = endMarker; newRecords.push({ key, item, index: i, - itemSignal, + dataContext, startMarker, endMarker, scope: itemScope, isElse: false, - propsSnapshot: null, + snapshot: createSnapshot(item), + fresh: true, }); } else { @@ -552,11 +550,11 @@ function adoptServerItems({ } // Dispose unused server items — their DOM is no longer in the list. - for (const g of serverGroups) { - if (usedKeys.has(g.key)) { continue; } - if (g.startComment.parentNode) { g.startComment.remove(); } - for (const n of g.nodes) { - if (n.parentNode) { n.remove(); } + for (const group of serverGroups) { + if (usedKeys.has(group.key)) { continue; } + if (group.startComment.parentNode) { group.startComment.remove(); } + for (const orphan of group.nodes) { + if (orphan.parentNode) { orphan.remove(); } } } @@ -601,12 +599,13 @@ const eachBlock = defineBlock({ key: null, item: null, index: -1, - itemSignal: null, + dataContext: null, startMarker: null, endMarker: null, scope: elseScope, isElse: true, - propsSnapshot: null, + snapshot: null, + fresh: false, }); } return; @@ -646,6 +645,7 @@ const eachBlock = defineBlock({ for (const record of self.records) { if (record.isElse) { continue; } record.scope.dispose(); + if (record.dataContext) { record.dataContext.dispose(); } disposeRecordDOM(record); } self.records.length = 0; diff --git a/packages/renderer/src/engines/native/blocks/sample.js b/packages/renderer/src/engines/native/blocks/sample.js index be176017d..f1f5f3fac 100644 --- a/packages/renderer/src/engines/native/blocks/sample.js +++ b/packages/renderer/src/engines/native/blocks/sample.js @@ -94,7 +94,7 @@ const sample = defineBlock({ hooks). When tracked signals change, update() fires — never render() again on the same instance. */ - render({ node, data, scope, region, renderAST, lookupExpression, self }) { + render({ node, data, scope, region, renderAST, lookupExpression, childContext, self }) { const value = lookupExpression(node.expression); self.lastValue = value; self.generation++; @@ -102,7 +102,7 @@ const sample = defineBlock({ const childScope = scope.child(); const fragment = renderAST({ ast: node.content, - data: { ...data, sampleValue: value }, + data: childContext(data, { sampleValue: value }), scope: childScope, }); region.setContent(fragment, childScope); @@ -130,13 +130,13 @@ const sample = defineBlock({ for the prefix scheme). Use it for branch selection, key recovery, etc. */ - hydrate({ node, data, region, lookupExpression, hydrateInto, self }) { + hydrate({ node, data, region, lookupExpression, hydrateInto, childContext, self }) { const value = lookupExpression(node.expression); self.lastValue = value; self.generation++; if (region.ownedNodes.length > 0 && node.content) { - hydrateInto({ innerAST: node.content, data: { ...data, sampleValue: value } }); + hydrateInto({ innerAST: node.content, data: childContext(data, { sampleValue: value }) }); } }, @@ -152,7 +152,7 @@ const sample = defineBlock({ Don't dispose self; defineBlock owns its lifetime via destroy(). */ - update({ node, data, scope, region, renderAST, lookupExpression, self }) { + update({ node, data, scope, region, renderAST, lookupExpression, childContext, self }) { const value = lookupExpression(node.expression); if (value === self.lastValue) { return; } // common bail-out self.lastValue = value; @@ -161,7 +161,7 @@ const sample = defineBlock({ const childScope = scope.child(); const fragment = renderAST({ ast: node.content, - data: { ...data, sampleValue: value }, + data: childContext(data, { sampleValue: value }), scope: childScope, }); region.setContent(fragment, childScope); diff --git a/packages/renderer/src/engines/native/blocks/template.js b/packages/renderer/src/engines/native/blocks/template.js index 3bbdad4a3..23ea49b8a 100644 --- a/packages/renderer/src/engines/native/blocks/template.js +++ b/packages/renderer/src/engines/native/blocks/template.js @@ -1,8 +1,8 @@ import { Reaction } from '@semantic-ui/reactivity'; import { Template } from '@semantic-ui/templating'; -import { each, fatal, isPlainObject, isString, keys } from '@semantic-ui/utils'; +import { each, extend, fatal, isPlainObject, isString } from '@semantic-ui/utils'; import { defineBlock } from '../define-block.js'; -import { isItemContext } from './each.js'; +import { isItemContext } from '../reactive-context.js'; import { registerBlock } from './registry.js'; /* @@ -21,96 +21,155 @@ import { registerBlock } from './registry.js'; their own deps via the snippet-arg proxy. update is a no-op (snippets are one-shot at mount). • subtemplate — full Template lifecycle: clone, initialize, render, - attach. Updates re-evaluate name, swap instance if it - changed, otherwise push new data via setDataContext. - - Two-level context use: create() receives dispatch-level bag and stashes - renderer.evaluator / renderer.subTemplates / renderer.snippets / - renderer.template / renderer.dataDep onto self. The 9-key author bag - stays honest; subsequent hooks read from self.* rather than reaching - through scope. + attach. Updates re-evaluate name + blob, swap instance + if name changed. + + Snippets and subtemplates share the same data-propagation primitive: + a lazy-getter record (buildArgsRecord) — a plain object inheriting + from the parent context, with declared keys defined as native ES + getter descriptors. Each reactiveData entry's getter calls + evaluator.lookupExpressionValue at access time, so source-signal deps + register on whichever Reaction is current at that read — the binding's + own Reaction. Per-key isolation is structural: a binding that reads + record.label registers labelVal; a sibling binding reading record.status + registers statusVal; mutating labelVal wakes only the label binding. + + Subtemplates carry the lifecycle layer (clone, createComponent, settings, + onCreated/etc.) on top of this same propagation. The record is installed + as the subtemplate's `data` BEFORE initialize() runs so that closures + captured by createComponent see it and route through the getters on + later reads. createComponent is invoked nonreactively so setup-time + reads of data.foo don't pollute the parent's outer Reaction with + source-signal deps. + + Blob `data={...}` (string or object literal) keeps the eager, + bumpDataVersion-fanout path documented as coarse-by-design. */ -function unpackNodeData(node, data, evaluator) { - let templateData = {}; - if (node.data) { - if (isString(node.data)) { - const evaluated = evaluator.lookupExpressionValue(node.data, data); - if (isPlainObject(evaluated)) { - templateData = { ...templateData, ...evaluated }; - } - } - else if (isPlainObject(node.data)) { - // Inside {#each}, read reactively so item-signal mutations propagate - // into subtemplate data. Outside each, static data={} stays non-reactive. - const inItemContext = isItemContext(data); - each(node.data, (expr, key) => { - templateData[key] = inItemContext - ? evaluator.lookupExpressionValue(expr, data) - : Reaction.nonreactive(() => evaluator.lookupExpressionValue(expr, data)); - }); +function unpackBlobData(node, data, evaluator) { + let blobData = {}; + if (!node.data) { return blobData; } + if (isString(node.data)) { + const evaluated = evaluator.lookupExpressionValue(node.data, data); + if (isPlainObject(evaluated)) { + blobData = { ...blobData, ...evaluated }; } + return blobData; } - if (node.reactiveData) { - each(node.reactiveData, (expr, key) => { - templateData[key] = evaluator.lookupExpressionValue(expr, data); + if (isPlainObject(node.data)) { + // Inside {#each}, read reactively so item-signal mutations propagate + // into subtemplate data. Outside each, static data={} stays non-reactive. + const inItemContext = isItemContext(data); + each(node.data, (expr, key) => { + blobData[key] = inItemContext + ? evaluator.lookupExpressionValue(expr, data) + : Reaction.nonreactive(() => evaluator.lookupExpressionValue(expr, data)); }); } - return templateData; + return blobData; } -// Snippet-arg overlay proxy. Args become lazy getters that re-evaluate -// against the parent data context at access time, tracking the same -// Signal deps a fresh-render snippet would. The has/ownKeys/descriptor -// traps make `prop in snippetData` and Object.keys() see the args. -function buildSnippetProxy(node, data, evaluator) { - const staticGetters = {}; - const reactiveGetters = {}; +// Lazy-getter record used by both snippets and subtemplates. Each +// reactiveData entry (and each entry of a literal node.data object for +// snippets) becomes a native ES getter descriptor on a flat plain +// object. Source-signal deps register on whichever Reaction is current +// at the read, so a binding's Reaction subscribes directly to its +// inputs — per-key isolation is structural, not mediated by an +// intermediate Dep layer. +// +// Shape: a fresh `{}` (proto = Object.prototype) with target's own +// descriptors copied in via `extend`, then declared getters defined +// on top. Every record built for the same template node ends up with +// the same own-property progression in the same order, so V8 +// consolidates them into one hidden class. The IC at every binding's +// `data[token]` read site stays monomorphic across all records in an +// each-block iteration. A prototype-chain shape off the target's +// identity splits records into per-target hidden classes, so we copy +// descriptors onto a flat record instead. +// +// Why descriptors instead of a Proxy: a Proxy's get trap is a function +// call into module code on every property read. Native getter +// descriptors compile to the same hidden-class IC as a plain property +// access — V8 inlines them. The trap surface that a Proxy would carry +// (has / ownKeys / getOwnPropertyDescriptor / set / defineProperty / +// deleteProperty / getPrototypeOf) collapses into the language +// semantics: `in` checks own properties, `Object.keys` returns own +// enumerables, `extend` (utils/objects.js) is descriptor-aware and +// copies the get/set pair intact. +// +// Why `extend(record, target)` over `Object.assign(record, target)`: +// extend is descriptor-aware. If target itself carries a getter (e.g. +// nested subtemplate, or component-level `darkMode`), Object.assign +// would invoke and snapshot the value. extend copies the descriptor, +// preserving laziness. For non-getter target keys both produce the +// same result. +// +// Absorb-set semantics: declared keys carry `set: () => {}` so that +// `record.foo = x` is silently absorbed. The settingsScope-mirror path +// writes Signal references onto the subtemplate's `settings` proxy +// directly, not through this record, so absorb-set does not interfere +// with overlay propagation. + +const ABSORB_SET = () => {}; + +function buildArgsRecord({ node, parentData, evaluator, target }) { + // Declared-key collection. Two flavors: static (eager value) and + // expression (lazy lookup). Parallel arrays avoid an object allocation + // per declared key. + let keys = null; + let kinds = null; // 's' = static, 'e' = expression + let values = null; // static value or expression token + + const declare = (key, kind, val) => { + if (keys === null) { + keys = []; + kinds = []; + values = []; + } + keys.push(key); + kinds.push(kind); + values.push(val); + }; if (node.data) { if (isString(node.data)) { - const evaluated = evaluator.lookupExpressionValue(node.data, data); + const evaluated = evaluator.lookupExpressionValue(node.data, parentData); if (isPlainObject(evaluated)) { - each(evaluated, (val, key) => { - staticGetters[key] = () => val; - }); + each(evaluated, (val, key) => declare(key, 's', val)); } } else if (isPlainObject(node.data)) { - each(node.data, (expr, key) => { - staticGetters[key] = () => evaluator.lookupExpressionValue(expr, data); - }); + each(node.data, (expr, key) => declare(key, 'e', expr)); } } if (node.reactiveData) { - each(node.reactiveData, (expr, key) => { - reactiveGetters[key] = () => evaluator.lookupExpressionValue(expr, data); - }); + each(node.reactiveData, (expr, key) => declare(key, 'e', expr)); } - const allGetters = { ...staticGetters, ...reactiveGetters }; - const getterKeys = keys(allGetters); - - return new Proxy(data, { - get(target, prop) { - if (typeof prop === 'symbol') { return target[prop]; } - if (prop in allGetters) { return allGetters[prop](); } - return target[prop]; - }, - has(target, prop) { - return (prop in allGetters) || (prop in target); - }, - ownKeys(target) { - return [...new Set([...getterKeys, ...Reflect.ownKeys(target)])]; - }, - getOwnPropertyDescriptor(target, prop) { - if (prop in allGetters) { - return { configurable: true, enumerable: true, get: allGetters[prop] }; - } - return Object.getOwnPropertyDescriptor(target, prop); - }, - }); + // Empty-args fast path — no-arg snippet invocations (`{>name}`) skip + // the wrapper entirely and use the parent data context directly. + if (keys === null) { return target; } + + // Flat record with shared Object.prototype proto. extend copies + // target's own descriptors (preserving any getters) before declared + // getters land on top, so every record built for the same node ends + // up with the same own-property shape. + const record = {}; + extend(record, target); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const val = values[i]; + Object.defineProperty(record, key, { + configurable: true, + enumerable: true, + get: kinds[i] === 's' + ? () => val + : () => evaluator.lookupExpressionValue(val, parentData), + set: ABSORB_SET, + }); + } + return record; } // Read name nonreactively for kind detection so name changes (which @@ -143,7 +202,19 @@ function resolveSnippet(nameExpr, data, self) { return self.snippets[name] || null; } -function cloneInstance({ template, templateName, templateData, self }) { +// Build the subtemplate's lazy-getter record and clone the Template +// against it. The record is installed BEFORE initialize() runs so that +// closures captured by createComponent (e.g. methods that read +// `data.foo` later) capture the record itself — subsequent reads route +// through the lazy getters and register source-signal deps on the +// caller's Reaction. +// +// Initialize is wrapped in Reaction.nonreactive so synchronous reads +// of data.foo from inside createComponent or onCreated do not register +// source-signal deps on the parent's outer Reaction. Reads from later +// binding-Reaction context still register normally — the wrap only +// silences the setup path. +function cloneInstance({ template, templateName, templateData, self, parentData, node }) { const instance = template.clone({ templateName, data: templateData, @@ -152,7 +223,17 @@ function cloneInstance({ template, templateName, templateData, self }) { }); if (self.parentTemplate?.element) { instance.setElement(self.parentTemplate.element); } if (self.parentTemplate) { instance.setParent(self.parentTemplate); } - instance.initialize(); + + // For reactiveData subtemplates, install the lazy-getter record as + // the instance's data ref. `target: instance.data` seeds the record + // with the blob's own descriptors via `extend`, so blob keys read + // through the same record alongside the declared reactiveData getters. + if (node?.reactiveData) { + const record = buildArgsRecord({ node, parentData, evaluator: self.evaluator, target: instance.data }); + instance.data = record; + } + + Reaction.nonreactive(() => instance.initialize()); return instance; } @@ -166,15 +247,68 @@ function attachToRenderRoot(instance, region, self, { startNode } = {}) { }); } +// Settings mirror — when a reactiveData key matches one of the +// subtemplate's declared defaultSettings entries, route per-key +// updates into the settings Proxy too. The settings Proxy holds its +// own per-key Signals (createSubtemplateSettings in templating); a +// write fires the Signal, so closures reading `settings.foo` track +// that Signal and wake when it notifies. Without this, settings-keyed +// closures stay stale because the data-side proxy doesn't touch them. +// +// Allocates a child scope + one Reaction only when the subtemplate +// actually has overlap between reactiveData and defaultSettings; +// pure-data subtemplates pay nothing. +function setupSettingsMirror({ node, data, scope, region, self }) { + if (!node.reactiveData) { return; } + const defaultSettings = self.currentInstance.defaultSettings; + const settingsProxy = self.currentInstance.settings; + if (!settingsProxy || !defaultSettings) { return; } + + const settingsKeys = []; + each(node.reactiveData, (_, key) => { + if (key in defaultSettings) { settingsKeys.push(key); } + }); + if (settingsKeys.length === 0) { return; } + + self.settingsScope = scope.child(); + self.settingsScope.reaction(region.anchor, () => { + for (const key of settingsKeys) { + const expr = node.reactiveData[key]; + settingsProxy[key] = self.evaluator.lookupExpressionValue(expr, data); + } + }, { message: 'subtemplate-settings' }); +} + +function teardownSettingsMirror(self) { + if (self.settingsScope) { + self.settingsScope.dispose(); + self.settingsScope = null; + } +} + function clearInstance(self, region) { if (self.currentInstance) { self.currentInstance.onDestroyed(); self.currentInstance = null; self.currentTemplateID = null; + teardownSettingsMirror(self); region.clear(); } } +// Template.render walks the subtemplate's data via assignInPlace during +// setDataContext / renderer.setData. For reactiveData paths the data is +// a lazy-getter record whose reads register source-signal deps on the +// active Reaction. Wrap the call so those reads don't register on the +// block's outer Reaction — bindings inside the subtemplate register +// their own deps via the record at evaluation time. +function renderInstance(instance, node, blobData) { + if (node.reactiveData) { + return Reaction.nonreactive(() => instance.render(blobData)); + } + return instance.render(blobData); +} + const templateBlock = defineBlock({ name: 'template', syntax: (node) => `{> ${node.name}}`, @@ -189,6 +323,7 @@ const templateBlock = defineBlock({ kind: null, currentTemplateID: null, currentInstance: null, + settingsScope: null, }; }, @@ -199,7 +334,7 @@ const templateBlock = defineBlock({ if (kind === 'snippet') { const snippet = resolveSnippet(node.name, data, self); if (!snippet) { fatal(`Snippet name resolved to a missing snippet`); } - const snippetData = buildSnippetProxy(node, data, self.evaluator); + const snippetData = buildArgsRecord({ node, parentData: data, evaluator: self.evaluator, target: data }); const fragment = renderAST({ ast: snippet.content, data: snippetData, scope, isSVG }); region.setContent(fragment); return; @@ -207,24 +342,32 @@ const templateBlock = defineBlock({ self.dataDep.depend(); const { template, templateName } = resolveSubtemplate(node.name, data, self); - const templateData = unpackNodeData(node, data, self.evaluator); + const blobData = unpackBlobData(node, data, self.evaluator); if (!template) { return; } self.currentTemplateID = template.id; - self.currentInstance = cloneInstance({ template, templateName, templateData, self }); - const fragment = self.currentInstance.render(); + self.currentInstance = cloneInstance({ + template, + templateName, + templateData: blobData, + self, + parentData: data, + node, + }); + setupSettingsMirror({ node, data, scope, region, self }); + const fragment = renderInstance(self.currentInstance, node); region.setContent(fragment); attachToRenderRoot(self.currentInstance, region, self); }, - hydrate({ node, data, region, hydrateInto, self }) { + hydrate({ node, data, region, scope, hydrateInto, self }) { const kind = detectKind({ node, data, self }); if (kind === null) { return; } if (kind === 'snippet') { const snippet = resolveSnippet(node.name, data, self); if (!snippet) { fatal(`Snippet name resolved to a missing snippet`); } - const snippetData = buildSnippetProxy(node, data, self.evaluator); + const snippetData = buildArgsRecord({ node, parentData: data, evaluator: self.evaluator, target: data }); if (region.ownedNodes.length > 0) { // Snippet args reactivity is anchored on the block scope; a child // would dispose with the next region.clear() and break arg reactivity. @@ -235,11 +378,19 @@ const templateBlock = defineBlock({ self.dataDep.depend(); const { template, templateName } = resolveSubtemplate(node.name, data, self); - const templateData = unpackNodeData(node, data, self.evaluator); + const blobData = unpackBlobData(node, data, self.evaluator); if (!template) { return; } self.currentTemplateID = template.id; - self.currentInstance = cloneInstance({ template, templateName, templateData, self }); + self.currentInstance = cloneInstance({ + template, + templateName, + templateData: blobData, + self, + parentData: data, + node, + }); + setupSettingsMirror({ node, data, scope, region, self }); if (region.ownedNodes.length > 0) { self.currentInstance.renderer.hydrateInto({ @@ -257,7 +408,7 @@ const templateBlock = defineBlock({ } }, - update({ node, data, region, self }) { + update({ node, data, region, scope, self }) { // Snippets are one-shot at mount — name reactivity at the outer level // is documented as undefined (kind shouldn't change), and inner // expression reactivity is handled by the snippet-arg proxy's lazy @@ -266,7 +417,7 @@ const templateBlock = defineBlock({ self.dataDep.depend(); const { template, templateName } = resolveSubtemplate(node.name, data, self); - const templateData = unpackNodeData(node, data, self.evaluator); + const blobData = unpackBlobData(node, data, self.evaluator); if (!template) { clearInstance(self, region); @@ -275,25 +426,42 @@ const templateBlock = defineBlock({ if (template.id !== self.currentTemplateID) { if (self.currentInstance) { self.currentInstance.onDestroyed(); } + teardownSettingsMirror(self); self.currentTemplateID = template.id; - self.currentInstance = cloneInstance({ template, templateName, templateData, self }); - const fragment = self.currentInstance.render(); + self.currentInstance = cloneInstance({ + template, + templateName, + templateData: blobData, + self, + parentData: data, + node, + }); + setupSettingsMirror({ node, data, scope, region, self }); + const fragment = renderInstance(self.currentInstance, node); region.setContent(fragment); attachToRenderRoot(self.currentInstance, region, self); } else { - self.currentInstance.setDataContext(templateData, { rerender: false }); - self.currentInstance.render(templateData); + // Same template — `instance.render(blobData)` merges blobData into + // the instance's dataContext via additionalData, then setDataContext + // assigns the full result onto this.data. A separate setDataContext + // call here would be a destructive partial sync (small source vs full + // target → assignInPlace deletes everything not in blobData), only + // for render() to immediately re-assign the full set. + renderInstance(self.currentInstance, node, blobData); } }, destroy({ self }) { // Snippets have no instance to destroy — region.clear() handles DOM, // inner reactions registered against parent scope auto-dispose. + // settingsScope is a child of the block's scope; its Reactions + // dispose when the block scope's onDispose runs after this hook. if (self.currentInstance) { self.currentInstance.onDestroyed(); self.currentInstance = null; } + self.settingsScope = null; }, evaluateText({ node, data, renderer }) { diff --git a/packages/renderer/src/engines/native/define-block.js b/packages/renderer/src/engines/native/define-block.js index d22082d2a..ed8656bbf 100644 --- a/packages/renderer/src/engines/native/define-block.js +++ b/packages/renderer/src/engines/native/define-block.js @@ -1,5 +1,27 @@ import { isRecovery, isTracing } from '../../helpers.js'; +// Block-author helper: create a child data context that inherits from +// `parent` via the prototype chain, with `extras` layered on as own +// properties. Use when a block renders inner content with the parent +// context plus a few additional keys (sample/async/each). +// +// Why prototype-chain inheritance (not spread): block data contexts +// can themselves be lazy-getter records (subtemplate / snippet args +// via buildArgsRecord). A spread `{ ...data, extras }` invokes any +// getters and snapshots their values, losing reactivity for parent +// reads inside the inner content. Object.create preserves those +// getters: child reads inherited keys live, source-signal deps +// register on whichever Reaction is running. +// +// Exported for server.js (SSR has no bag) and provided through the +// block bag (`bag.childContext`) so registered blocks don't need to +// import it. +export function childContext(parent, extras) { + const child = Object.create(parent); + if (extras) { Object.assign(child, extras); } + return child; +} + // Structured error log — opt-in via setTracing(true). Tree-shakes when off. // `syntax` is the block's own template-syntax representation (each block // owns its formatting via the `syntax` config hook). @@ -88,6 +110,7 @@ export function defineBlock(config) { renderAST, hydrateInnerContent, hydrateInto, + childContext, hook: null, err: null, }; diff --git a/packages/renderer/src/engines/native/reactive-context.js b/packages/renderer/src/engines/native/reactive-context.js new file mode 100644 index 000000000..159aa580a --- /dev/null +++ b/packages/renderer/src/engines/native/reactive-context.js @@ -0,0 +1,182 @@ +import { Dependency, Scheduler, Signal } from '@semantic-ui/reactivity'; + +/* + + ReactiveDataContext — a per-key reactive bag that reads like a plain + object. Composes raw Dependencies + a key-set Dependency to deliver + fine-grained invalidation at the three data-context push sites that + today collapse to coarse whole-context invalidation: {#each} per-item + data, subtemplate reactiveData, and snippet args. + + The Proxy fronts a parent data object. Property reads first consult + per-key values (registering a per-key Dependency on the active + Reaction); on miss they fall through to the parent. The keySetVersion + Dependency catches the late-declared-key hazard: a reader that fell + through to the parent for a key that did not yet exist must wake when + the key arrives so it can subscribe to the new per-key Dependency on + its next run. Without this, a key authored later in an item's lifetime + would silently never propagate to its readers. + + Per-key state is inlined as `values` and `deps`, both null-prototype + objects. The per-key Dependency is allocated at setKey time, not + lazily on first reactive read. Eager allocation keeps the RDC's hidden + class stable from construction so V8's IC at the trap dispatch site + sees one shape across all records. Null-prototype-object storage for + `deps` (over Map) lets `target.deps[prop]` inline-cache like a plain + property access; Map.get always pays a virtual call into the Map's + get method. + + We deliberately do not allocate a full Signal per key: Signal wraps + a Dependency with allowClone / equalityFunction / clone / currentValue + field assignments per instance. The wrapper allocation dominates at + scale where many records each carry several keys. Equality dedup is + preserved: `Signal.equalityFunction` is snapshotted at construction, + matching Signal's per-instance snapshot semantics so the inlined + dedup behaves identically to a Signal.set call on the same static. + Late overrides of `Signal.equalityFunction` after the RDC is + constructed will not retroactively retarget — same blind spot Signal + itself has. + + `values` uses Object.create(null) (not Map) because every record in a + bench-todo / bench-krausest mount adds the same keys in the same + order, letting V8 establish a stable hidden-class chain across all + records. Plain-object property access (target.values[prop]) inline- + caches at the call site once the shape is stable; Map.get always + pays a virtual call into the Map's get method. The existence check + uses (prop in target.values) — fast on null-prototype objects since + no prototype-chain walk is needed. + + `sealKeysAfterReplace` declares that the value-key set is fixed + after the seed replace() call. as-mode {#each todo in items} uses it + because getEachData returns a fixed shape ({[as], [indexAs]}) — no + key is ever added mid-life. When sealed, both keySetVersion.depend() + (in trapGet's fallthrough branch) and keySetVersion.changed() (in + setKey's new-key branch) are skipped. Avoids a per-fallthrough-read + subscribe/cleanup on a Dep that can never fire — measurable wins on + workloads where bindings read parent-context identifiers (helpers, + parent state) through the each-record proxy. Spread-mode keeps the + unsealed default because spread item shapes can gain keys. + + Closure-only readers (functions reading no per-key data) intentionally + do not register any record-level "anything changed" Dependency. The + whole-record-proxy alternative registers an item-level signal on every + property access — that is the coarseness this primitive exists to + remove. + + Proxy handler is module-scoped and stable across all instances. The + Proxy's target IS the ReactiveDataContext (`this`); handler functions + read instance state via `target.values` / `target.parent` / + `target.keySetVersion` / `target.deps`. This keeps V8's hidden-class + shape stable across all records, so the get-trap path can establish + a monomorphic inline cache. Per-instance closure-captured handlers + would split the shape per record and force polymorphic dispatch. + +*/ + +const itemContextProxies = new WeakSet(); + +export function isItemContext(data) { + return data != null && itemContextProxies.has(data); +} + +function trapGet(target, prop) { + if (typeof prop === 'symbol') { return target.parent[prop]; } + const values = target.values; + if (prop in values) { + if (Scheduler.current) { + target.deps[prop].depend(); + } + return values[prop]; + } + if (!target.keysSealed) { target.keySetVersion.depend(); } + return target.parent[prop]; +} + +function trapHas(target, prop) { + return (prop in target.values) || (prop in target.parent); +} + +function trapOwnKeys(target) { + const ownKeys = Reflect.ownKeys(target.parent); + const merged = Object.keys(target.values); + const values = target.values; + for (const key of ownKeys) { + if (!(key in values)) { merged.push(key); } + } + return merged; +} + +function trapGetOwnPropertyDescriptor(target, prop) { + if (prop in target.values) { + return { + configurable: true, + enumerable: true, + value: target.values[prop], + }; + } + return Object.getOwnPropertyDescriptor(target.parent, prop); +} + +const HANDLER = { + get: trapGet, + has: trapHas, + ownKeys: trapOwnKeys, + getOwnPropertyDescriptor: trapGetOwnPropertyDescriptor, +}; + +export class ReactiveDataContext { + constructor(parent, { + registerItemContext = false, + sealKeysAfterReplace = false, + } = {}) { + this.parent = parent; + this.sealKeysAfterReplace = sealKeysAfterReplace; + this.keysSealed = false; + this.values = Object.create(null); + this.deps = Object.create(null); + // Snapshot Signal.equalityFunction at construction. Mirrors Signal's + // own per-instance snapshot semantics — late overrides of the static + // do not retroactively retarget already-constructed instances. If + // userland breaks Signal.equalityFunction after this RDC is live, + // both Signal and RDC fail the same way; no divergence. + this.equalityFunction = Signal.equalityFunction; + this.keySetVersion = new Dependency(); + this.proxy = new Proxy(this, HANDLER); + + if (registerItemContext) { + itemContextProxies.add(this.proxy); + } + } + + setKey(key, value) { + if (!(key in this.values)) { + this.values[key] = value; + this.deps[key] = new Dependency(); + if (!this.keysSealed) { this.keySetVersion.changed(); } + return; + } + const old = this.values[key]; + if (this.equalityFunction(old, value)) { return; } + this.values[key] = value; + const dep = this.deps[key]; + if (dep !== undefined) { dep.changed(); } + } + + notifyKey(key) { + const dep = this.deps[key]; + if (dep !== undefined) { dep.changed(); } + } + + replace(nextValues) { + for (const key in nextValues) { + this.setKey(key, nextValues[key]); + } + if (this.sealKeysAfterReplace) { this.keysSealed = true; } + } + + dispose() { + this.values = Object.create(null); + this.deps = Object.create(null); + this.keysSealed = false; + } +} diff --git a/packages/renderer/src/engines/native/renderer.js b/packages/renderer/src/engines/native/renderer.js index 01c3e0fba..b0ae3bb42 100644 --- a/packages/renderer/src/engines/native/renderer.js +++ b/packages/renderer/src/engines/native/renderer.js @@ -540,7 +540,7 @@ export class Renderer { if (respectProtectedKeys && this.protectedKeys) { newData = filterObject(newData, (value, key) => !inArray(key, this.protectedKeys)); } - assignInPlace(this.data, newData, { preserveExistingKeys: preserveExistingData }); + assignInPlace(this.data, newData, { preserveExistingKeys: preserveExistingData, preserveGetters: true }); } bumpDataVersion() { diff --git a/packages/renderer/src/engines/native/server.js b/packages/renderer/src/engines/native/server.js index e9237c389..a3b2d729c 100644 --- a/packages/renderer/src/engines/native/server.js +++ b/packages/renderer/src/engines/native/server.js @@ -31,6 +31,7 @@ import { MAIN_BRANCH_INDEX, } from '../../build-html-string.js'; import { ExpressionEvaluator } from '../../expression-evaluator.js'; +import { childContext } from './define-block.js'; import { encodeItemKey, getEachData, getItemID, SUI_ITEM_MARKER } from './shared/each.js'; const REMOVE_ATTR = '__SUI_REMOVE__'; @@ -205,7 +206,7 @@ export class ServerRenderer { if (respectProtectedKeys && this.protectedKeys) { newData = filterObject(newData, (value, key) => !inArray(key, this.protectedKeys)); } - assignInPlace(this.data, newData, { preserveExistingKeys: preserveExistingData }); + assignInPlace(this.data, newData, { preserveExistingKeys: preserveExistingData, preserveGetters: true }); } bumpDataVersion() { @@ -408,7 +409,7 @@ export class ServerRenderer { // Per-item key marker — client adopts the matching node range at hydrate time. for (let i = 0; i < items.length; i++) { const eachData = getEachData(items[i], i, collectionType, node); - const itemData = { ...data, ...eachData }; + const itemData = childContext(data, eachData); const key = getItemID(items[i], i, collectionType); html += ``; html += this.renderNodes(node.content, itemData); @@ -504,7 +505,7 @@ export class ServerRenderer { *******************************/ resolveNodeData(node, data) { - let resolved = { ...data }; + let resolved = childContext(data); if (node.data) { if (isString(node.data)) { diff --git a/packages/renderer/test/browser/subtree-misc.test.js b/packages/renderer/test/browser/subtree-misc.test.js index 926d876b8..bc1375bdc 100644 --- a/packages/renderer/test/browser/subtree-misc.test.js +++ b/packages/renderer/test/browser/subtree-misc.test.js @@ -392,6 +392,43 @@ RENDERING_ENGINES.forEach(engine => { expect(shadowText(el)).not.toContain('msg:hi'); }); + it("exposes reactiveData on createComponent's closure-captured data param", async () => { + // Regression: subtemplates whose parent passes values via + // reactiveData (e.g. `{>child key=expr}`) must seed those + // values into the cloned template's `data` synchronously, + // BEFORE createComponent runs. Closures captured in + // createComponent (for example, methods that read + // data.lineNumbers later in lifecycle hooks) need the initial + // value at the moment they are built. If reactiveData is only + // pushed via the post-init Reaction, the closure-captured + // `data` ref appears empty at createComponent time. + const tag = uniqueTag(); + let observedAtCreate; + const child = defineComponent({ + renderingEngine: engine, + template: '{lineNumbers}', + defaultSettings: { lineNumbers: false }, + createComponent: ({ data }) => ({ + captureAtCreate() { + observedAtCreate = data.lineNumbers; + }, + }), + onCreated: ({ self }) => self.captureAtCreate(), + }); + defineComponent({ + renderingEngine: engine, + tagName: tag, + template: '{>child lineNumbers=lineNumbers}', + defaultState: { lineNumbers: true }, + subTemplates: { child }, + }); + const el = document.createElement(tag); + document.body.appendChild(el); + await el.rendered; + + expect(observedAtCreate).toBe(true); + }); + it('should update subtemplate with shorthand reactive props', async () => { const tag = uniqueTag(); const child = defineComponent({ diff --git a/packages/renderer/test/browser/subtree-spurious.test.js b/packages/renderer/test/browser/subtree-spurious.test.js index 88cb3c9eb..2427970b9 100644 --- a/packages/renderer/test/browser/subtree-spurious.test.js +++ b/packages/renderer/test/browser/subtree-spurious.test.js @@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { RENDERING_ENGINES } from './test-utils.js'; RENDERING_ENGINES.forEach(engine => { + const isLit = engine === 'lit'; + describe(engine, () => { /******************************* Test Helpers @@ -356,10 +358,13 @@ RENDERING_ENGINES.forEach(engine => { await updated; expect(shadowText(el)).toContain('updated-ok'); - // Ideally only item 1's expressions would re-evaluate (countAfterRender + 1), - // but since the each list source changed, the framework may re-evaluate all items. - // We test for the ideal: unchanged items should not cause extra calls. - expect(spyTotal).toBe(countAfterRender + 1); + // staticSpy reads no signal. Under per-key reactivity (native) it + // has no dependency that the item-key change would invalidate, + // so no re-evaluations fire. Under whole-record reactivity (lit) + // every property access registers itemSignal, so the changed-item + // notify drives one extra staticSpy call. + const expectedSpyCount = engine === 'native' ? countAfterRender : countAfterRender + 1; + expect(spyTotal).toBe(expectedSpyCount); }); }); @@ -511,15 +516,12 @@ RENDERING_ENGINES.forEach(engine => { }); }); - describe('reactiveData per-key granularity', () => { - // Documents the per-key isolation gap on reactiveData. Today's - // renderer flattens reactiveData through unpackNodeData - // (packages/renderer/src/engines/native/blocks/template.js) and - // bumps the whole subtemplate's dataDep on any field change, so - // every child expression re-evaluates. Fine-grained per-key deps - // would require a Proxy similar to each's itemProxy with per- - // property Dependency tracking. - it.fails('changing one reactiveData field should not re-evaluate subtemplate expressions that read a different field', async () => { + describe.skipIf(isLit)('reactiveData per-key granularity', () => { + // Per-key isolation on reactiveData: changing one field's source + // re-fires only bindings that read that field. Marker bindings + // (read no signals) and sibling-key bindings (read a different + // field) never re-fire — same contract as the snippet path. + it('changing one reactiveData field should not re-evaluate subtemplate expressions that read a different field', async () => { let labelEvalCount = 0; let statusEvalCount = 0; const tag = uniqueTag(); @@ -560,16 +562,18 @@ RENDERING_ENGINES.forEach(engine => { const labelCountAfterRender = labelEvalCount; const statusCountAfterRender = statusEvalCount; - // Only labelVal changes. The expectation under fine-grained - // reactiveData would be: label re-evaluates, status does NOT. + // Only labelVal changes — the {label} binding wakes (text + // updates), but markLabel/markStatus register no source signals + // and stay quiet. Same per-expression isolation the snippet + // path delivers above. const updated = $(el).onNext('updated'); el.template.state.labelVal.set('changed'); await updated; expect(shadowText(el)).toContain('changed'); expect(shadowText(el)).toContain('active'); - expect(labelEvalCount).toBeGreaterThan(labelCountAfterRender); - expect(statusEvalCount).toBe(statusCountAfterRender); // the claim under test + expect(labelEvalCount).toBe(labelCountAfterRender); + expect(statusEvalCount).toBe(statusCountAfterRender); }); }); @@ -629,5 +633,285 @@ RENDERING_ENGINES.forEach(engine => { expect(statusEvalCount).toBeGreaterThan(statusCountAfterRender); }); }); + + /******************************* + Per-key isolation across N subtemplates + (mirrors bench-template-reactivity's + subtemplate-reactive-data-100 scenario) +*******************************/ + + describe.skipIf(isLit)('per-key isolation across N subtemplates', () => { + it('mutating one reactiveData source should not re-fire bindings reading a different reactiveData key', async () => { + // Mirrors bench-reactivedata in + // packages/component/bench/tachometer/bench-template-reactivity.js: + // 100 child subtemplates, each receiving label + status via + // reactiveData. Mutating labelVal 50 times should re-fire the + // label-reading binding in each child but NOT the status-reading + // binding. Per-key isolation is the design promise; the bench is + // saturated by rAF so wall-clock can't see the win, but the + // binding-fire counter can. + let statusBindingFires = 0; + let labelBindingFires = 0; + const child = defineComponent({ + renderingEngine: engine, + template: '{readLabel}{readStatus}', + createComponent: ({ data }) => ({ + readLabel() { + labelBindingFires++; + return data.label; + }, + readStatus() { + statusBindingFires++; + return data.status; + }, + }), + }); + const tag = uniqueTag(); + defineComponent({ + renderingEngine: engine, + tagName: tag, + template: `{#each i in idxs}{>child label=getLabel status=getStatus}{/each}`, + subTemplates: { child }, + defaultState: { labelVal: 'init' }, + createComponent: ({ state }) => ({ + idxs: Array.from({ length: 100 }, (_, i) => i), + getLabel: () => state.labelVal.get(), + getStatus: () => 'static', + }), + }); + const el = document.createElement(tag); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + await rendered; + + expect(labelBindingFires).toBe(100); + expect(statusBindingFires).toBe(100); + + for (let i = 0; i < 50; i++) { + const updated = $(el).onNext('updated'); + el.template.state.labelVal.set(`v${i}`); + await updated; + } + + // Per-key isolation: every labelVal mutation re-fires the 100 + // label bindings. Status bindings should not re-fire — status + // never changed. + expect(labelBindingFires).toBe(100 + 50 * 100); + expect(statusBindingFires).toBe(100); + }); + }); + + /******************************* + FGR per-key isolation contract + — full enumeration of the + per-key isolation contract across + each adoption site. +*******************************/ + + describe.skipIf(isLit)('FGR contract: each-block in-place mutation', () => { + // Per-FIELD isolation under `as`-mode requires splitting the as-key + // into its constituent properties so a binding reading `todo.completed` + // subscribes to a per-field dep rather than the whole-item key. The + // test pins that contract for a future architectural pass. + it.fails('mutating one item field via setProperty re-fires only the binding reading that field', async () => { + // {#each item in items} body has two bindings reading two + // different fields of the same item. setProperty mutates one + // field. Per-key isolation: only that field's binding fires. + let textFires = 0; + let completedFires = 0; + const tag = uniqueTag(); + defineComponent({ + renderingEngine: engine, + tagName: tag, + template: '{#each todo in getTodos}{readText todo}{readCompleted todo}{/each}', + createComponent: ({ signal }) => { + const todos = signal([{ _id: 'a', text: 'first', completed: false }]); + return { + todos, + getTodos: () => todos.get(), + readText: (todo) => { + textFires++; + return todo.text; + }, + readCompleted: (todo) => { + completedFires++; + return String(todo.completed); + }, + toggle: () => todos.setProperty('a', 'completed', !todos.getItem('a').completed), + }; + }, + }); + const el = document.createElement(tag); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + await rendered; + + expect(textFires).toBe(1); + expect(completedFires).toBe(1); + + const updated = $(el).onNext('updated'); + el.component.toggle(); + await updated; + + expect(completedFires).toBe(2); + expect(textFires).toBe(1); + }); + + it('mutating one item field does NOT re-fire bindings of unaffected sibling items', async () => { + // Each block has 3 items. setProperty on item 'a'.completed + // should re-fire only item a's completed-binding. Items b and c + // should not re-fire any bindings (their data is unchanged). + const fires = { a: 0, b: 0, c: 0 }; + const tag = uniqueTag(); + defineComponent({ + renderingEngine: engine, + tagName: tag, + template: '{#each todo in getTodos}{readCompleted todo}{/each}', + createComponent: ({ signal }) => { + const todos = signal([ + { _id: 'a', completed: false }, + { _id: 'b', completed: false }, + { _id: 'c', completed: false }, + ]); + return { + todos, + getTodos: () => todos.get(), + readCompleted: (todo) => { + fires[todo._id]++; + return String(todo.completed); + }, + toggleA: () => todos.setProperty('a', 'completed', !todos.getItem('a').completed), + }; + }, + }); + const el = document.createElement(tag); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + await rendered; + + expect(fires).toEqual({ a: 1, b: 1, c: 1 }); + + const updated = $(el).onNext('updated'); + el.component.toggleA(); + await updated; + + expect(fires).toEqual({ a: 2, b: 1, c: 1 }); + }); + }); + + describe.skipIf(isLit)('FGR contract: subtemplate reactiveData (shorthand syntax)', () => { + it("mutating one shorthand reactive prop source re-fires only that key's binding", async () => { + // Mirrors the verbose-syntax test at the it.fails block above, + // but uses `{>child a=expr b=expr}` shorthand. Same per-key + // contract — the parser produces the same node.reactiveData + // shape regardless of syntax, but a separate test pins the + // shorthand path to the contract. + let labelFires = 0; + let statusFires = 0; + const child = defineComponent({ + renderingEngine: engine, + template: '{readLabel}{readStatus}', + createComponent: ({ data }) => ({ + readLabel() { + labelFires++; + return data.label; + }, + readStatus() { + statusFires++; + return data.status; + }, + }), + }); + const tag = uniqueTag(); + defineComponent({ + renderingEngine: engine, + tagName: tag, + template: '{>child label=getLabel status=getStatus}', + subTemplates: { child }, + defaultState: { labelVal: 'hello', statusVal: 'active' }, + createComponent: ({ state }) => ({ + getLabel: () => state.labelVal.get(), + getStatus: () => state.statusVal.get(), + }), + }); + const el = document.createElement(tag); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + await rendered; + + expect(labelFires).toBe(1); + expect(statusFires).toBe(1); + + const updated = $(el).onNext('updated'); + el.template.state.labelVal.set('changed'); + await updated; + + expect(labelFires).toBe(2); + expect(statusFires).toBe(1); + }); + }); + + describe.skipIf(isLit)('FGR contract: subtemplate-inside-each composition (bench-todo)', () => { + // Same as-mode per-FIELD gap surfaced through a subtemplate boundary. + // Inside `{#each todo in todos}`, mutating one field of an item fires + // the as-key dep which wakes every subtemplate binding reading + // `todo.X`, not just the field that changed. The test pins the + // contract for the per-field-isolation pass that closes the gap. + it.fails('mutating one item field via setProperty re-fires only the matching subtemplate-binding key', async () => { + // bench-todo's exact composition: {#each todo in todos}{>todoItem + // id=todo.id title=todo.title completed=todo.completed}{/each}. + // setProperty('a', 'completed', true) should re-fire only the + // completed-reading binding inside item a's todoItem subtemplate. + // Title and id bindings should not re-fire. + const fires = { id: 0, title: 0, completed: 0 }; + const todoItem = defineComponent({ + renderingEngine: engine, + template: '{readId}{readTitle}{readCompleted}', + createComponent: ({ data }) => ({ + readId() { + fires.id++; + return data.id; + }, + readTitle() { + fires.title++; + return data.title; + }, + readCompleted() { + fires.completed++; + return String(data.completed); + }, + }), + }); + const tag = uniqueTag(); + defineComponent({ + renderingEngine: engine, + tagName: tag, + template: '{#each todo in getTodos}{>todoItem id=todo._id title=todo.title completed=todo.completed}{/each}', + subTemplates: { todoItem }, + createComponent: ({ signal }) => { + const todos = signal([{ _id: 'a', title: 'first', completed: false }]); + return { + todos, + getTodos: () => todos.get(), + toggle: () => todos.setProperty('a', 'completed', !todos.getItem('a').completed), + }; + }, + }); + const el = document.createElement(tag); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + await rendered; + + expect(fires).toEqual({ id: 1, title: 1, completed: 1 }); + + const updated = $(el).onNext('updated'); + el.component.toggle(); + await updated; + + expect(fires.completed).toBe(2); + expect(fires.id).toBe(1); + expect(fires.title).toBe(1); + }); + }); }); // describe(engine) }); // RENDERING_ENGINES.forEach diff --git a/packages/templating/src/template.js b/packages/templating/src/template.js index 61f1056f9..403e7474a 100644 --- a/packages/templating/src/template.js +++ b/packages/templating/src/template.js @@ -138,7 +138,10 @@ export const Template = class Template { } setDataContext(data, { rerender = true } = {}) { - const changed = assignInPlace(this.data, data, { returnChanged: true }); + // preserveGetters keeps computed properties on this.data intact — + // both component-level getters (e.g. darkMode) and the lazy-getter + // record installed by buildArgsRecord for reactiveData subtemplates. + const changed = assignInPlace(this.data, data, { preserveGetters: true, returnChanged: true }); if (changed) { this.dataReplaced = true; } diff --git a/packages/utils/src/objects.js b/packages/utils/src/objects.js index f5ed7d94a..b5f7d7da6 100755 --- a/packages/utils/src/objects.js +++ b/packages/utils/src/objects.js @@ -119,20 +119,80 @@ const deepMerge = (target, source, options) => { } }; -export const assignInPlace = (target, source, { preserveExistingKeys = false, returnChanged = false } = {}) => { +// Cached own-getter keys per target — `getOwnPropertyDescriptor` +// allocates a descriptor object per key just to read `.get`. +const getterKeysCache = new WeakMap(); + +function getOwnGetterKeys(target) { + let keys = getterKeysCache.get(target); + if (keys !== undefined) { return keys; } + keys = null; + const ownKeys = Object.keys(target); + for (let i = 0; i < ownKeys.length; i++) { + const key = ownKeys[i]; + const desc = Object.getOwnPropertyDescriptor(target, key); + if (desc && desc.get) { + if (keys === null) { keys = new Set(); } + keys.add(key); + } + } + getterKeysCache.set(target, keys); + return keys; +} + +export const assignInPlace = (target, source, { + preserveExistingKeys = false, + preserveGetters = false, + returnChanged = false, +} = {}) => { let changed = false; + const ownGetters = preserveGetters ? getOwnGetterKeys(target) : null; if (!preserveExistingKeys) { - for (const key in target) { - if (!(key in source)) { - delete target[key]; + if (preserveGetters) { + // Own keys only — a `for...in` walk on a prototype-chained target + // would attempt `delete` on inherited keys (no-op), and that delete + // attempt deopts V8's hidden class for the target. The own-only + // path also matches the descriptor check's contract: getter + // descriptors are an own-property concept. + const ownKeys = Object.keys(target); + for (let i = 0; i < ownKeys.length; i++) { + const key = ownKeys[i]; + if (!(key in source)) { + if (ownGetters !== null && ownGetters.has(key)) { continue; } + delete target[key]; + changed = true; + } + } + } + else { + for (const key in target) { + if (!(key in source)) { + delete target[key]; + changed = true; + } + } + } + } + if (preserveGetters) { + for (const key in source) { + // Skip declared getter keys: their `set` is absorb-only by contract + // (any write would be a no-op), so the inequality compare burns two + // getter invocations to decide nothing. On hot reactive paths the + // target-side and source-side getters often run the expression + // evaluator, so skipping pays back per key. + if (ownGetters !== null && ownGetters.has(key)) { continue; } + if (target[key] !== source[key]) { + target[key] = source[key]; changed = true; } } } - for (const key in source) { - if (target[key] !== source[key]) { - target[key] = source[key]; - changed = true; + else { + for (const key in source) { + if (target[key] !== source[key]) { + target[key] = source[key]; + changed = true; + } } } return returnChanged ? changed : target; diff --git a/packages/utils/types/objects.d.ts b/packages/utils/types/objects.d.ts index a39757f3e..5cdf00519 100755 --- a/packages/utils/types/objects.d.ts +++ b/packages/utils/types/objects.d.ts @@ -129,6 +129,8 @@ export function deepExtend( export interface AssignInPlaceOptions { /** Keep keys in target that are not in source (default false) */ preserveExistingKeys?: boolean; + /** Skip own getter descriptors when deleting keys not present in source (default false). Useful when target carries computed properties that shouldn't be torn down by syncs. */ + preserveGetters?: boolean; /** Return whether any properties changed instead of the target object (default false) */ returnChanged?: boolean; }