Skip to content
Merged
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
9 changes: 8 additions & 1 deletion docs/src/pages/docs/api/utils/objects.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down
20 changes: 12 additions & 8 deletions packages/component/test/browser/component-lit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
8 changes: 4 additions & 4 deletions packages/renderer/src/engines/native/blocks/async.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,15 @@ 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;

const renderState = (ast, extraData = {}) => {
const stateScope = scope.child();
const fragment = renderAST({
ast,
data: { ...data, ...extraData },
data: childContext(data, extraData),
scope: stateScope,
isSVG,
});
Expand Down Expand Up @@ -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,
});
Expand Down
12 changes: 6 additions & 6 deletions packages/renderer/src/engines/native/blocks/sample.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,15 @@ 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++;

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);
Expand Down Expand Up @@ -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 }) });
}
},

Expand All @@ -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;
Expand All @@ -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);
Expand Down
Loading
Loading