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/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/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/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/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 54456ae29..2ee062af3 100644 --- a/packages/renderer/src/engines/native/blocks/template.js +++ b/packages/renderer/src/engines/native/blocks/template.js @@ -1,6 +1,6 @@ import { Reaction } from '@semantic-ui/reactivity'; import { Template } from '@semantic-ui/templating'; -import { each, fatal, isPlainObject, isString } from '@semantic-ui/utils'; +import { each, extend, fatal, isPlainObject, isString } from '@semantic-ui/utils'; import { defineBlock } from '../define-block.js'; import { isItemContext } from '../reactive-context.js'; import { registerBlock } from './registry.js'; @@ -25,18 +25,19 @@ import { registerBlock } from './registry.js'; if name changed. Snippets and subtemplates share the same data-propagation primitive: - a lazy-getter Proxy fronting the parent context (buildArgsProxy). - Each reactiveData entry becomes a getter that calls + 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 - proxy.label registers labelVal; a sibling binding reading proxy.status + 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 proxy is installed + 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 the proxy and route through it on + 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. @@ -73,110 +74,107 @@ function unpackBlobData(node, data, evaluator) { return blobData; } -// Lazy-getter Proxy used by both snippets and subtemplates. Each +// 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 getter that calls -// evaluator.lookupExpressionValue at access time. Source-signal deps -// register on whichever Reaction is current at that read, so a -// binding's Reaction subscribes directly to its inputs — per-key -// isolation is structural, not mediated by an intermediate Dep layer. +// 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. // -// The Proxy target is a small holder object (target / allGetters / -// getterKeys); the trap handler is module-scoped (ARGS_HANDLER) so V8 -// sees the same handler shape across every subtemplate / snippet -// mount and can establish monomorphic inline caches at the trap -// dispatch sites. With per-call closure-captured handlers V8 falls -// back to polymorphic dispatch, which is what made bulk-add-500 / -// filter-cycle-20 regress when the lazy proxy moved onto the -// subtemplate path. +// 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 — even when 1000 records +// exist for an each-block iteration. `Object.create(target)` produced +// a per-record prototype chain off `target`'s identity that V8 split +// into distinct shapes, regressing read-heavy reconciles by ~3.5x. // -// has / ownKeys / getOwnPropertyDescriptor make declared keys visible -// to `prop in proxy`, Object.keys, and descriptor-aware spreads (extend -// uses Object.getOwnPropertyDescriptor and re-defines as a getter — so -// descriptor-style copies preserve laziness). +// 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. // -// defineProperty / deleteProperty / getPrototypeOf forward to -// holder.target so callers that mutate the proxy via descriptor APIs -// (e.g. Template.overlaySettingsSignals) hit the underlying data -// object, not the holder. - -// No-op setter included in the getter descriptor so `extend`-style -// copies (Object.defineProperty against this descriptor) accept -// assignment when overlays write the same key. Writes are absorbed; -// subsequent reads still resolve through the lazy getter. +// 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 (matching the prior Proxy's +// set trap on declared keys). 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 = () => {}; -const ARGS_HANDLER = { - get(holder, prop) { - if (typeof prop === 'symbol') { return holder.target[prop]; } - const getter = holder.allGetters[prop]; - if (getter !== undefined) { return getter(); } - return holder.target[prop]; - }, - set(holder, prop, value) { - if (prop in holder.allGetters) { return true; } - holder.target[prop] = value; - return true; - }, - has(holder, prop) { - return (prop in holder.allGetters) || (prop in holder.target); - }, - ownKeys(holder) { - const ownKeys = Reflect.ownKeys(holder.target); - const merged = [...holder.getterKeys]; - for (const key of ownKeys) { - if (!(key in holder.allGetters)) { merged.push(key); } +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 = []; } - return merged; - }, - getOwnPropertyDescriptor(holder, prop) { - const getter = holder.allGetters[prop]; - if (getter !== undefined) { - return { configurable: true, enumerable: true, get: getter, set: ABSORB_SET }; - } - return Object.getOwnPropertyDescriptor(holder.target, prop); - }, - defineProperty(holder, prop, descriptor) { - return Reflect.defineProperty(holder.target, prop, descriptor); - }, - deleteProperty(holder, prop) { - return delete holder.target[prop]; - }, - getPrototypeOf(holder) { - return Reflect.getPrototypeOf(holder.target); - }, -}; - -function buildArgsProxy({ node, parentData, evaluator, target }) { - const allGetters = Object.create(null); + keys.push(key); + kinds.push(kind); + values.push(val); + }; if (node.data) { if (isString(node.data)) { const evaluated = evaluator.lookupExpressionValue(node.data, parentData); if (isPlainObject(evaluated)) { - each(evaluated, (val, key) => { - allGetters[key] = () => val; - }); + each(evaluated, (val, key) => declare(key, 's', val)); } } else if (isPlainObject(node.data)) { - each(node.data, (expr, key) => { - allGetters[key] = () => evaluator.lookupExpressionValue(expr, parentData); - }); + each(node.data, (expr, key) => declare(key, 'e', expr)); } } if (node.reactiveData) { - each(node.reactiveData, (expr, key) => { - allGetters[key] = () => evaluator.lookupExpressionValue(expr, parentData); - }); + each(node.reactiveData, (expr, key) => declare(key, 'e', expr)); } - const getterKeys = Object.keys(allGetters); // Empty-args fast path — no-arg snippet invocations (`{>name}`) skip - // the Proxy entirely and use the parent data context directly. - if (getterKeys.length === 0) { return target; } - - return new Proxy({ target, allGetters, getterKeys }, ARGS_HANDLER); + // 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 @@ -231,13 +229,14 @@ function cloneInstance({ template, templateName, templateData, self, parentData, if (self.parentTemplate?.element) { instance.setElement(self.parentTemplate.element); } if (self.parentTemplate) { instance.setParent(self.parentTemplate); } - // For reactiveData subtemplates, install the lazy proxy as the - // instance's data ref. The proxy's target is the seeded blob already - // on instance.data, so non-arg keys (state, instance overlay added - // during initialize, blob keys) resolve via fall-through. + // For reactiveData subtemplates, install the lazy-getter record as + // the instance's data ref. The record's prototype is the seeded blob + // already on instance.data, so non-arg keys (state, instance overlay + // added during initialize, blob keys) resolve via prototype-chain + // fall-through. if (node?.reactiveData) { - const proxy = buildArgsProxy({ node, parentData, evaluator: self.evaluator, target: instance.data }); - instance.data = proxy; + const record = buildArgsRecord({ node, parentData, evaluator: self.evaluator, target: instance.data }); + instance.data = record; } Reaction.nonreactive(() => instance.initialize()); @@ -341,7 +340,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 = buildArgsProxy({ node, parentData: data, evaluator: self.evaluator, target: data }); + 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; @@ -378,7 +377,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 = buildArgsProxy({ node, parentData: data, evaluator: self.evaluator, target: data }); + 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. @@ -453,10 +452,13 @@ const templateBlock = defineBlock({ attachToRenderRoot(self.currentInstance, region, self); } else { - // Same template — push blob data only. reactiveData propagation - // happens through the lazy proxy reads inside the subtemplate's - // bindings, not through this update path. - self.currentInstance.setDataContext(blobData, { rerender: false }); + // 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. That cycle was + // shape-thrashing V8 hidden classes on hot toggle paths. renderInstance(self.currentInstance, node, blobData); } }, 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/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/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..e127eec68 100755 --- a/packages/utils/src/objects.js +++ b/packages/utils/src/objects.js @@ -119,20 +119,60 @@ const deepMerge = (target, source, options) => { } }; -export const assignInPlace = (target, source, { preserveExistingKeys = false, returnChanged = false } = {}) => { +export const assignInPlace = (target, source, { + preserveExistingKeys = false, + preserveGetters = false, + returnChanged = false, +} = {}) => { let changed = false; 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)) { + const desc = Object.getOwnPropertyDescriptor(target, key); + if (desc && desc.get) { 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. + const desc = Object.getOwnPropertyDescriptor(target, key); + if (desc && desc.get) { 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; }