diff --git a/package.json b/package.json index e1d5b66..f29f03a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "regor", - "version": "1.2.6", + "version": "1.2.7", "description": "A modern UI framework for web and desktop applications, inspired by Vue's concepts and powered by simplicity and flexibility.", "author": "Ahmed Yasin Koculu", "license": "MIT", diff --git a/src/directives/single-prop.ts b/src/directives/single-prop.ts index 0ce5d69..372fb28 100644 --- a/src/directives/single-prop.ts +++ b/src/directives/single-prop.ts @@ -1,8 +1,51 @@ -import { type Directive, type ParseResult, type Unbinder } from '../api/types' +import { + type AnyRef, + type Directive, + type ParseResult, + type Unbinder, +} from '../api/types' import { camelize } from '../common/common' import { observe } from '../observer/observe' import { entangle } from '../reactivity/entangle' import { isRef } from '../reactivity/isRef' +import { ref } from '../reactivity/ref' + +/** + * Bridge strategy for `:prop="someRefPath"` bindings + * + * Problem this solves: + * - If a component prop is directly entangled to a source ref, retargeting + * (e.g. switching selected item refs) can mutate previous sources. + * - Nested component forwarding (`:model="model"`) still needs two-way sync. + * + * How this works: + * 1) First non-bridge ref source creates a stable bridge ref. + * - `ctx[key]` is pointed to this bridge. + * - Source `<->` bridge are entangled. + * 2) On source ref identity changes, we re-entangle new source `<->` same bridge. + * - Consumers stay connected to the stable bridge. + * 3) If incoming value is already a bridge (forwarded from parent), do not create + * another bridge. Forward via entangle/assignment only. + * 4) For non-ref values: + * - If currently using the bridge, write into bridge (`bridge(value)`) so + * linked refs receive updates. + * - Otherwise assign/update target directly. + * + * Why bridge creation is only at first non-bridge boundary: + * - Prevents stacking bridges at every forwarding level. + * - Preserves nested propagation while avoiding previous-source overwrite. + */ +const modelBridgeSymbol = Symbol('modelBridge') +const isModelBridge = (value: unknown): boolean => + !!(value as Record)?.[modelBridgeSymbol] +const markModelBridge = (value: AnyRef): void => { + ;(value as unknown as Record)[modelBridgeSymbol] = 1 +} +const createModelBridge = (source: AnyRef): AnyRef => { + const bridge = ref(source()) as unknown as AnyRef + markModelBridge(bridge) + return bridge +} /** * @internal @@ -19,32 +62,61 @@ export const singlePropDirective: Directive = { ): Unbinder => { if (!option) return () => {} const key = camelize(option) + let currentSource: AnyRef | undefined + let bridge: AnyRef | undefined let stopEntangle: Unbinder = () => {} + + const resetSync = (): void => { + stopEntangle() + stopEntangle = () => {} + currentSource = undefined + bridge = undefined + } + const clearEntangle = (): void => { + stopEntangle() + stopEntangle = () => {} + } + const syncRefs = (source: AnyRef, target: AnyRef): void => { + if (currentSource === source) return + clearEntangle() + stopEntangle = entangle(source, target) + currentSource = source + } + const stopObserving = observe( parseResult.value, () => { const value = parseResult.refs[0] ?? parseResult.value()[0] const ctx = parseResult.context const ctxKey = ctx[key] - if (ctxKey === value) return - if (isRef(value)) { + if (!isRef(value)) { + if (bridge && ctxKey === bridge) { + bridge(value) + return + } + resetSync() if (isRef(ctxKey)) { - stopEntangle() - stopEntangle = entangle(value, ctxKey) + ctxKey(value) return } ctx[key] = value return } - stopEntangle() - stopEntangle = () => {} - if (isRef(ctxKey)) { - ctxKey(value) + if (isModelBridge(value)) { + if (isRef(ctxKey)) { + syncRefs(value, ctxKey) + } else { + ctx[key] = value + } return } - ctx[key] = value + + if (!bridge) bridge = createModelBridge(value) + ctx[key] = bridge + + syncRefs(value, bridge) }, true, ) diff --git a/tests/directives/model.spec.ts b/tests/directives/model.spec.ts index f6464e6..310512d 100644 --- a/tests/directives/model.spec.ts +++ b/tests/directives/model.spec.ts @@ -59,6 +59,110 @@ test('r-model number flag converts to number', () => { expect(num()).toBe('') }) +test('r-model with input type number converts to number without number flag', () => { + const root = document.createElement('div') + const amount = ref(0) + createApp( + { amount }, + { element: root, template: html`` }, + ) + const input = root.querySelector('input') as HTMLInputElement + input.value = '12.5' + input.dispatchEvent(new Event('input')) + expect(amount()).toBe(12.5) +}) + +test('r-model with input type number sets empty string for invalid input', () => { + const root = document.createElement('div') + const amount = ref(0) + createApp( + { amount }, + { element: root, template: html`` }, + ) + const input = root.querySelector('input') as HTMLInputElement + input.value = 'foo' + input.dispatchEvent(new Event('input')) + expect(amount()).toBe('') +}) + +test('r-model with input type number reflects model updates in DOM', () => { + const root = document.createElement('div') + const amount = ref(0) + createApp( + { amount }, + { element: root, template: html`` }, + ) + const input = root.querySelector('input') as HTMLInputElement + + amount(42.75) + expect(input.value).toBe('42.75') +}) + +test('r-model input type number retargets after selected ref replacement without component', () => { + const root = document.createElement('div') + const selectedHost = ref({ port: 1 }) + + createApp( + { selectedHost }, + { + element: root, + template: html``, + }, + ) + + const input = root.querySelector('input') as HTMLInputElement | null + if (!input) throw new Error('missing input') + + selectedHost(ref({ port: 2 })) + expect(input.value).toBe('2') + selectedHost().port(3) + expect(input.value).toBe('3') + + input.value = '4' + input.dispatchEvent(new Event('input')) + expect(selectedHost().port()).toBe(4) +}) + +test('r-model input type number retargets through component prop after selected ref replacement', () => { + const root = document.createElement('div') + const selectedHost = ref({ port: 1 }) + const hostInputField = createComponent( + html``, + ['model'], + ) + + createApp( + { + selectedHost, + components: { + hostInputField, + }, + }, + { + element: root, + template: html``, + }, + ) + + const input = root.querySelector('input') as HTMLInputElement | null + if (!input) throw new Error('missing input') + + selectedHost(ref({ port: 2 })) + expect(input.value).toBe('2') + selectedHost().port(3) + expect(input.value).toBe('3') + + input.value = '4' + input.dispatchEvent(new Event('input')) + expect(selectedHost().port()).toBe(4) + + selectedHost(ref({ port: 5 })) + expect(input.value).toBe('5') + input.value = '6' + input.dispatchEvent(new Event('input')) + expect(selectedHost().port()).toBe(6) +}) + test('r-model with checkboxes populates array', () => { const root = document.createElement('div') const selected = ref([]) @@ -517,3 +621,39 @@ test('three nested components keep :model two-way binding across replacements', selectedHost().hostname('new-value-2') expect(input.value).toBe('new-value-2') }) + +test('regression: selecting host from list should not overwrite previous host item values', () => { + const root = document.createElement('div') + const hostItems = ref([{ hostname: 'host-a' }, { hostname: 'host-b' }]) + const selectedHost = ref(hostItems()[0]()) + const hostInputField = createComponent( + html``, + ['model'], + ) + + createApp( + { + selectedHost, + components: { + hostInputField, + }, + }, + { + element: root, + template: html``, + }, + ) + + const input = root.querySelector('input') as HTMLInputElement | null + if (!input) throw new Error('missing input') + + expect(hostItems()[0]().hostname()).toBe('host-a') + expect(hostItems()[1]().hostname()).toBe('host-b') + expect(input.value).toBe('host-a') + + // Switching selection should not mutate existing host item data. + selectedHost(hostItems()[1]()) + expect(input.value).toBe('host-b') + expect(hostItems()[1]().hostname()).toBe('host-b') + expect(hostItems()[0]().hostname()).toBe('host-a') +})