diff --git a/package-lock.json b/package-lock.json index 0afd09a0..2557e4fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6369,10 +6369,9 @@ } }, "node_modules/alien-signals": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", - "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", - "dev": true, + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", "license": "MIT" }, "node_modules/ansi-html-community": { @@ -21163,6 +21162,7 @@ "version": "1.9.0-beta.0", "license": "MIT", "dependencies": { + "alien-signals": "^3.2.1", "deepmerge": "^4.3.1", "morphdom": "^2.7.8" }, diff --git a/packages/tests/Data/DataBind.spec.ts b/packages/tests/Data/DataBind.spec.ts index 6e078762..e6aa352c 100644 --- a/packages/tests/Data/DataBind.spec.ts +++ b/packages/tests/Data/DataBind.spec.ts @@ -1,5 +1,13 @@ import { it, describe, expect, vi } from 'vitest'; -import { Action, DataBind, DataComputed, DataEffect, DataModel, DataScope } from '@studiometa/ui'; +import { + Action, + DataBind, + DataComputed, + DataEffect, + DataModel, + DataScope, + type DataValue, +} from '@studiometa/ui'; import { nextTick } from '@studiometa/js-toolkit/utils'; import { destroy, hConnected as h, mount } from '#test-utils'; @@ -306,6 +314,108 @@ describe('The DataBind component', () => { expect(instance4.$el.id).toBe('bar'); }); + it('should deliver group updates through the public set method', async () => { + const legacySource = new DataBind(h('div', { dataOptionGroup: 'custom-set' })); + const legacySubscriber = new DataBind(h('div', { dataOptionGroup: 'custom-set' })); + const legacySet = vi.spyOn(legacySubscriber, 'set'); + await mount(legacySource, legacySubscriber); + + legacySource.set('legacy'); + expect(legacySet).toHaveBeenCalledWith('legacy', false); + + const root = h('div', { dataOptionGroup: 'person' }); + const input = h('input', { name: 'first' }); + const output = h('div', { dataOptionKey: 'first' }); + root.append(input, output); + const scope = new DataScope(root); + const scopedSource = new DataModel(input); + const scopedSubscriber = new DataBind(output); + const scopedSet = vi.spyOn(scopedSubscriber, 'set'); + await mount(scope, scopedSource, scopedSubscriber); + + scopedSource.set('scoped'); + expect(scopedSet).toHaveBeenCalledWith('scoped', false); + + await destroy(legacySource, legacySubscriber, scope, scopedSource, scopedSubscriber); + }); + + it('should preserve the latest value during reentrant group updates', async () => { + class ReentrantDataBind extends DataBind { + private dispatched = false; + + set(value: DataValue, dispatch = true) { + super.set(value, dispatch); + + if (!dispatch && value === 'outer' && !this.dispatched) { + this.dispatched = true; + super.set('inner'); + } + } + } + + const source = new DataBind(h('div', { dataOptionGroup: 'reentrant' })); + const reentrant = new ReentrantDataBind(h('div', { dataOptionGroup: 'reentrant' })); + const output = new DataBind(h('div', { dataOptionGroup: 'reentrant' })); + await mount(source, reentrant, output); + + source.set('outer'); + + expect(source.value).toBe('inner'); + expect(reentrant.value).toBe('inner'); + expect(output.value).toBe('inner'); + + await destroy(source, reentrant, output); + }); + + it('should not run effects on mount unless an immediate value is dispatched', async () => { + const passiveElement = h('div', { + dataOptionGroup: 'passive-effect', + dataOptionEffect: 'target.dataset.called = "true"', + }); + const immediateElement = h('div', { + dataOptionGroup: 'immediate-effect', + dataOptionEffect: + 'target.dataset.calls = String(Number(target.dataset.calls || 0) + 1)', + dataOptionImmediate: true, + }); + const passive = new DataEffect(passiveElement); + const immediate = new DataEffect(immediateElement); + + await mount(passive, immediate); + expect(passiveElement.dataset.called).toBeUndefined(); + expect(immediateElement.dataset.calls).toBeUndefined(); + + await nextTick(); + expect(passiveElement.dataset.called).toBeUndefined(); + expect(immediateElement.dataset.calls).toBe('1'); + + await destroy(passive, immediate); + }); + + it('should dispose and recreate group subscriptions across lifecycle changes', async () => { + const source = new DataBind(h('div', { dataOptionGroup: 'lifecycle' })); + const effectElement = h('div', { + dataOptionGroup: 'lifecycle', + dataOptionEffect: + 'target.dataset.calls = String(Number(target.dataset.calls || 0) + 1)', + }); + const effect = new DataEffect(effectElement); + await mount(source, effect); + + source.set('first'); + expect(effectElement.dataset.calls).toBe('1'); + + await destroy(effect); + source.set('second'); + expect(effectElement.dataset.calls).toBe('1'); + + await mount(effect); + source.set('third'); + expect(effectElement.dataset.calls).toBe('2'); + + await destroy(source, effect); + }); + it('should forget related instances not in the DOM anymore', async () => { const fragment = new Document(); const inputA = h('input', { diff --git a/packages/tests/Data/DataScope.spec.ts b/packages/tests/Data/DataScope.spec.ts index c581a9b8..3422f72e 100644 --- a/packages/tests/Data/DataScope.spec.ts +++ b/packages/tests/Data/DataScope.spec.ts @@ -269,6 +269,37 @@ describe('The DataScope component', () => { }); }); + it('should synchronously notify scoped subscribers for repeated equal writes', async () => { + const root = h('div', { dataOptionGroup: 'person' }); + const input = h('input', { name: 'first' }); + const output = h('div', { dataOptionKey: 'first' }); + const effectElement = h('div', { + dataOptionEffect: + 'target.dataset.calls = String(Number(target.dataset.calls || 0) + 1); target.dataset.value = value', + }); + root.append(input, output, effectElement); + + const scope = new DataScope(root); + const model = new DataModel(input); + const bind = new DataBind(output); + const effect = new DataEffect(effectElement); + await mount(scope, model, bind, effect); + + model.set('Ada'); + const firstSnapshot = model.$data; + expect(bind.value).toBe('Ada'); + expect(effectElement.dataset.calls).toBe('1'); + expect(effectElement.dataset.value).toBe('Ada'); + + model.set('Ada'); + expect(bind.value).toBe('Ada'); + expect(effectElement.dataset.calls).toBe('2'); + expect(model.$data).toEqual({ first: 'Ada' }); + expect(model.$data).not.toBe(firstSnapshot); + + await destroy(scope, model, bind, effect); + }); + it('should recompute unkeyed subscribers for every keyed update', async () => { const root = h('div', { dataOptionGroup: 'values' }); const firstInput = h('input', { @@ -340,6 +371,37 @@ describe('The DataScope component', () => { await destroy(scope, first, last, computed, effect); }); + it('should notify once when hydrating multiple immediate sources for the same key', async () => { + const root = h('div', { dataOptionGroup: 'person' }); + const firstInput = h('input', { + name: 'first', + value: 'Ada', + dataOptionImmediate: true, + }); + const secondInput = h('input', { + name: 'first', + value: 'Ada', + dataOptionImmediate: true, + }); + const effectElement = h('div', { + dataOptionEffect: + 'target.dataset.values = (target.dataset.values || "") + $data.first + "|"', + }); + root.append(firstInput, secondInput, effectElement); + + const scope = new DataScope(root); + const first = new DataModel(firstInput); + const second = new DataModel(secondInput); + const effect = new DataEffect(effectElement); + await mount(scope, first, second, effect); + await nextTick(); + + expect(effectElement.dataset.values?.split('|').filter(Boolean)).toEqual(['Ada']); + expect(scope.getData('person')).toEqual({ first: 'Ada' }); + + await destroy(scope, first, second, effect); + }); + it('should hydrate and track only the selected radio', async () => { const root = h('div', { dataOptionGroup: 'tabs' }); const overviewElement = h('input', { diff --git a/packages/ui/Data/DataBind.ts b/packages/ui/Data/DataBind.ts index 0e344f9b..0e4024ec 100644 --- a/packages/ui/Data/DataBind.ts +++ b/packages/ui/Data/DataBind.ts @@ -1,9 +1,20 @@ import { Base, withGroup } from '@studiometa/js-toolkit'; import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; -import { isArray, nextTick } from '@studiometa/js-toolkit/utils'; +import { nextTick } from '@studiometa/js-toolkit/utils'; +import { getDataChannel } from './DataChannel.js'; import { DataScope, getDataScope } from './DataScope.js'; -import type { DataValue } from './DataScope.js'; -import { getCallback, isInput, isCheckbox, isSelect } from './utils.js'; +import type { DataScopeMember, DataValue } from './DataScope.js'; +import { + type DataControlContext, + isCheckbox, + isInput, + readControlValue, + resolvePropertyName, + setProperty, + valuesEqual, + writeControlValue, +} from './formControl.js'; +import { getCallback } from './utils.js'; export interface DataBindProps extends BaseProps { $options: { @@ -15,70 +26,10 @@ export interface DataBindProps extends BaseProps { const EMPTY_DATA = Object.freeze({}); -function valuesEqual(left: DataValue, right: DataValue) { - if (Object.is(left, right)) { - return true; - } - - if (left instanceof Date && right instanceof Date) { - return left.getTime() === right.getTime(); - } - - if (Array.isArray(left) && Array.isArray(right)) { - return ( - left.length === right.length && left.every((value, index) => value === right[index]) - ); - } - - const isNumberAndString = - (typeof left === 'number' && typeof right === 'string') || - (typeof left === 'string' && typeof right === 'number'); - const isArrayAndString = - (Array.isArray(left) && typeof right === 'string') || - (typeof left === 'string' && Array.isArray(right)); - - return (isNumberAndString || isArrayAndString) && String(left) === String(right); -} - type VirtualBinding = | { type: 'text'; expression: string } | { type: 'prop' | 'attr' | 'class' | 'style'; name: string; expression: string }; -function setProperty(target: HTMLElement, name: string, value: unknown) { - if (value !== null && value !== undefined) { - target[name] = value; - return; - } - - switch (name) { - case 'valueAsDate': - target[name] = null; - break; - case 'valueAsNumber': - target[name] = Number.NaN; - break; - default: - target[name] = ''; - } -} - -function resolvePropertyName(target: HTMLElement, name: string) { - const normalizedName = name.replaceAll('-', '').toLowerCase(); - let current: object | null = target; - - while (current) { - const property = Object.getOwnPropertyNames(current).find( - (candidate) => candidate.toLowerCase() === normalizedName, - ); - if (property) { - return property; - } - current = Object.getPrototypeOf(current); - } - - return name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); -} - /** * DataBind class. * @link https://ui.studiometa.dev/components/DataBind/ @@ -97,6 +48,7 @@ export class DataBind extends withGroup(Base, ' private __dataScopeResolved = false; private __dataScope?: DataScope; + private __stopUpdates?: () => void; private __virtualBindings?: VirtualBinding[]; private __virtualValue?: DataValue; private __hasVirtualValue = false; @@ -186,6 +138,23 @@ export class DataBind extends withGroup(Base, ' return this.group.endsWith('[]'); } + private get channel() { + return ( + this.dataScope?.getChannel(this.group) ?? + getDataChannel(this.$group as Set) + ); + } + + protected get controlContext(): DataControlContext { + return { + dataKey: this.dataKey, + members: this.relatedInstances, + multiple: this.multiple, + prop: this.prop, + target: this.target, + }; + } + get target() { return this.$el; } @@ -227,62 +196,18 @@ export class DataBind extends withGroup(Base, ' } protected getTargetValue(): DataValue { - const { target, multiple } = this; - - if (isSelect(target)) { - if (multiple) { - const values = [] as string[]; - // @ts-ignore - for (const option of target.options) { - if (option.selected) { - values.push(option.value); - } - } - - return values; - } - - const option = target.options.item(target.selectedIndex); - return option?.value; - } - - if (isCheckbox(target)) { - if (multiple) { - const values = new Set(); - for (const instance of this.relatedInstances) { - if ( - (!this.dataKey || instance.dataKey === this.dataKey) && - isCheckbox(instance.target) && - instance.target.checked - ) { - values.add(instance.target.value); - } - } - return Array.from(values); - } else { - return target.checked; - } - } - - return target[this.prop]; + return readControlValue(this.controlContext); } set(value: DataValue, dispatch = true) { - if (dispatch && this.dataScope && this.dataKey) { - this.__dispatchScopedValue(value); - return; - } - - const { target, multiple, relatedInstances } = this; + const publication = dispatch ? this.publishValue(value) : undefined; - if (dispatch) { - for (const instance of relatedInstances) { - if (instance !== this && (instance.hasVirtualBindings || instance.value !== value)) { - instance.set(value, false); - } - } + if (!publication || publication.channel.isCurrent(publication.frame)) { + this.applyValue(value); } + } + private applyValue(value: DataValue) { if (this.hasVirtualBindings) { this.__virtualValue = value; this.__hasVirtualValue = true; @@ -290,28 +215,36 @@ export class DataBind extends withGroup(Base, ' return; } - if (isSelect(target)) { - // @ts-ignore - for (const option of target.options) { - option.selected = - multiple && isArray(value) ? value.includes(option.value) : option.value === value; - } - return; - } + writeControlValue(this.controlContext, value); + } - if (isInput(target)) { - switch (target.type) { - case 'radio': - target.checked = target.value === value; - return; - case 'checkbox': - target.checked = - multiple && isArray(value) ? value.includes(target.value) : Boolean(value); - return; + /** + * Publish a value to the resolved Data group without applying it locally. + * @internal + */ + protected publishValue(value: DataValue, force = false, updateData = true) { + if (this.dataScope && this.dataKey) { + if (updateData) { + this.dataScope.setValue(this.group, this.dataKey, value, this); } + + const channel = this.dataScope.getChannel(this.group); + const frame = channel.publish({ + force: true, + key: this.dataKey, + source: this, + value, + }); + return { channel, frame }; } - setProperty(target, this.prop, value); + const channel = this.channel; + const frame = channel.publish({ + force, + source: this, + value, + }); + return { channel, frame }; } private applyVirtualBindings(value: DataValue) { @@ -364,24 +297,11 @@ export class DataBind extends withGroup(Base, ' * @internal */ __dispatchScopedValue(value: DataValue, updateData = true) { - const { dataScope, dataKey, relatedInstances } = this; - - if (!dataScope || !dataKey) { - this.set(value); - return; - } - - if (updateData) { - dataScope.setValue(this.group, dataKey, value, this); - } + const publication = this.publishValue(value, true, updateData); - for (const instance of relatedInstances) { - if (instance !== this && (!instance.dataKey || instance.dataKey === dataKey)) { - instance.set(value, false); - } + if (publication.channel.isCurrent(publication.frame)) { + this.set(value, false); } - - this.set(value, false); } private validateMutation(method: string) { @@ -435,6 +355,23 @@ export class DataBind extends withGroup(Base, ' } mounted() { + this.__stopUpdates?.(); + this.__stopUpdates = this.channel.subscribe((update) => { + if (!this.$el.isConnected) { + this.__stopUpdates?.(); + this.__stopUpdates = undefined; + return; + } + + if ( + update.source !== this && + (!update.key || !this.dataKey || update.key === this.dataKey) && + (update.force || this.hasVirtualBindings || this.value !== update.value) + ) { + this.set(update.value, false); + } + }); + if (!this.$options.immediate) { return; } @@ -450,6 +387,9 @@ export class DataBind extends withGroup(Base, ' } destroyed() { + this.__stopUpdates?.(); + this.__stopUpdates = undefined; + if (this.dataScope && this.dataKey) { this.dataScope.deleteValue(this.group, this.dataKey, this); } diff --git a/packages/ui/Data/DataChannel.ts b/packages/ui/Data/DataChannel.ts new file mode 100644 index 00000000..b4bb36f1 --- /dev/null +++ b/packages/ui/Data/DataChannel.ts @@ -0,0 +1,80 @@ +import { effect, signal } from 'alien-signals'; +import type { DataScopeMember, DataValue } from './DataScope.js'; + +const INITIAL = Symbol('initial-data-update'); + +export interface DataUpdate { + readonly force: boolean; + readonly key?: string; + readonly source?: DataScopeMember; + readonly value: DataValue; +} + +type DataUpdateSubscriber = (update: DataUpdate) => void; + +/** + * Synchronously publish Data group updates through a signal-backed channel. + * @internal + */ +export class DataChannel { + private update = signal(INITIAL); + private latest?: DataUpdate; + + publish(update: DataUpdate) { + // Always publish a fresh frame so equal values remain observable events. + const frame = { ...update }; + this.latest = frame; + this.update(frame); + return frame; + } + + isCurrent(frame: DataUpdate) { + return this.latest === frame; + } + + subscribe(subscriber: DataUpdateSubscriber) { + let initialized = false; + + return effect(() => { + const update = this.update(); + + if (!initialized) { + initialized = true; + return; + } + + if (update !== INITIAL) { + subscriber(update); + } + }); + } +} + +type GlobalWithDataChannels = typeof globalThis & { + __STUDIOMETA_UI_DATA_CHANNELS__?: WeakMap, DataChannel>; +}; + +/** + * Get the global Data channel storage so it can be shared between different + * instances of the package. + */ +function getChannelsStorage() { + const global = globalThis as GlobalWithDataChannels; + return (global.__STUDIOMETA_UI_DATA_CHANNELS__ ??= new WeakMap()); +} + +/** + * Get the signal channel associated with a legacy Data group. + * @internal + */ +export function getDataChannel(group: Set) { + const channels = getChannelsStorage(); + let channel = channels.get(group); + + if (!channel) { + channel = new DataChannel(); + channels.set(group, channel); + } + + return channel; +} diff --git a/packages/ui/Data/DataModel.ts b/packages/ui/Data/DataModel.ts index 706ebc56..36c01a00 100644 --- a/packages/ui/Data/DataModel.ts +++ b/packages/ui/Data/DataModel.ts @@ -1,7 +1,7 @@ import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; import { DataBind } from './DataBind.js'; import type { DataBindProps } from './DataBind.js'; -import { isCheckbox } from './utils.js'; +import { serializeControlValue } from './formControl.js'; export interface DataModelProps extends DataBindProps {} @@ -11,22 +11,11 @@ export class DataModel extends DataBind; sources: Map>; values: Map; @@ -81,6 +83,7 @@ export class DataScope extends Base extends Base extends Base; + readonly multiple: boolean; + readonly prop: string; + readonly target: HTMLElement; +} + +export function isInput(element: Element): element is HTMLInputElement { + return element instanceof HTMLInputElement; +} + +export function isCheckbox(element: Element): element is HTMLInputElement { + return isInput(element) && element.type === 'checkbox'; +} + +export function isSelect(element: Element): element is HTMLSelectElement { + return element instanceof HTMLSelectElement; +} + +export function valuesEqual(left: DataValue, right: DataValue) { + if (Object.is(left, right)) { + return true; + } + + if (left instanceof Date && right instanceof Date) { + return left.getTime() === right.getTime(); + } + + if (Array.isArray(left) && Array.isArray(right)) { + return left.length === right.length && left.every((value, index) => value === right[index]); + } + + const isNumberAndString = + (typeof left === 'number' && typeof right === 'string') || + (typeof left === 'string' && typeof right === 'number'); + const isArrayAndString = + (Array.isArray(left) && typeof right === 'string') || + (typeof left === 'string' && Array.isArray(right)); + + return (isNumberAndString || isArrayAndString) && String(left) === String(right); +} + +export function setProperty(target: HTMLElement, name: string, value: unknown) { + if (value !== null && value !== undefined) { + target[name] = value; + return; + } + + switch (name) { + case 'valueAsDate': + target[name] = null; + break; + case 'valueAsNumber': + target[name] = Number.NaN; + break; + default: + target[name] = ''; + } +} + +export function resolvePropertyName(target: HTMLElement, name: string) { + const normalizedName = name.replaceAll('-', '').toLowerCase(); + let current: object | null = target; + + while (current) { + const property = Object.getOwnPropertyNames(current).find( + (candidate) => candidate.toLowerCase() === normalizedName, + ); + if (property) { + return property; + } + current = Object.getPrototypeOf(current); + } + + return name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); +} + +/** + * Read a value from a form control or generic element property. + * @internal + */ +export function readControlValue({ + dataKey, + members, + multiple, + prop, + target, +}: DataControlContext): DataValue { + if (isSelect(target)) { + if (multiple) { + const values = [] as string[]; + for (const option of target.options) { + if (option.selected) { + values.push(option.value); + } + } + + return values; + } + + return target.options.item(target.selectedIndex)?.value; + } + + if (isCheckbox(target)) { + if (multiple) { + const values = new Set(); + for (const member of members) { + if ((!dataKey || member.dataKey === dataKey) && isCheckbox(member.target)) { + if (member.target.checked) { + values.add(member.target.value); + } + } + } + return Array.from(values); + } + + return target.checked; + } + + return target[prop]; +} + +/** + * Serialize a model event while preserving grouped checkbox semantics. + * @internal + */ +export function serializeControlValue(context: DataControlContext): DataValue { + const value = readControlValue(context); + const { multiple, target } = context; + + if (multiple && isCheckbox(target) && !target.checked && Array.isArray(value)) { + const values = new Set(value); + values.delete(target.value); + return Array.from(values); + } + + return value; +} + +/** + * Write a value to a form control or generic element property. + * @internal + */ +export function writeControlValue( + { multiple, prop, target }: DataControlContext, + value: DataValue, +) { + if (isSelect(target)) { + for (const option of target.options) { + option.selected = + multiple && Array.isArray(value) ? value.includes(option.value) : option.value === value; + } + return; + } + + if (isInput(target)) { + switch (target.type) { + case 'radio': + target.checked = target.value === value; + return; + case 'checkbox': + target.checked = + multiple && Array.isArray(value) ? value.includes(target.value) : Boolean(value); + return; + } + } + + setProperty(target, prop, value); +} diff --git a/packages/ui/Data/utils.ts b/packages/ui/Data/utils.ts index 2f756300..3ad9df1f 100644 --- a/packages/ui/Data/utils.ts +++ b/packages/ui/Data/utils.ts @@ -1,15 +1,3 @@ -export function isInput(el: Element): el is HTMLInputElement { - return el instanceof HTMLInputElement; -} - -export function isCheckbox(el: Element): el is HTMLInputElement { - return isInput(el) && el.type === 'checkbox'; -} - -export function isSelect(el: Element): el is HTMLSelectElement { - return el instanceof HTMLSelectElement; -} - const callbacks = new Map(); export function getCallback(name: string, code: string): Function { diff --git a/packages/ui/package.json b/packages/ui/package.json index 306de82d..e82148b6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -29,6 +29,7 @@ }, "homepage": "https://github.com/studiometa/ui#readme", "dependencies": { + "alien-signals": "^3.2.1", "deepmerge": "^4.3.1", "morphdom": "^2.7.8" },