From 854706befc7b46e76dc585e5b23b9393c5e0a466 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Sun, 22 Feb 2026 03:04:52 +0100 Subject: [PATCH 1/3] Retarget model binding target when source changes. --- src/directives/model.ts | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/directives/model.ts b/src/directives/model.ts index 9649c77..7752fb5 100644 --- a/src/directives/model.ts +++ b/src/directives/model.ts @@ -146,20 +146,20 @@ const attachDOMChangeListener = ( number: f1.number || f2.number, trim: f1.trim || f2.trim, } - const modelRef = parseResult.refs[0] - if (!modelRef) { + if (!parseResult.refs[0]) { warning(WarningType.ModelRequiresRef, el) return () => {} } + const getModelRef = (): AnyRef | undefined => parseResult.refs[0] const isAnInput = isInput(el) if (isAnInput && isCheckBox(el)) { - return handleCheckBox(el, modelRef) + return handleCheckBox(el, getModelRef) } else if (isAnInput && isRadio(el)) { - return handleRadio(el, modelRef) + return handleRadio(el, getModelRef) } else if (isAnInput || isTextArea(el)) { - return handleInputAndTextArea(el, flags, modelRef, parsedValue) + return handleInputAndTextArea(el, flags, getModelRef, parsedValue) } else if (isSelect(el)) { - return handleSelect(el, modelRef, parsedValue) + return handleSelect(el, getModelRef, parsedValue) } else { warning(WarningType.ModelNotSupportOnElement, el) return () => {} @@ -171,7 +171,7 @@ const decimalSeparators = /[.,' ·٫]/ const handleInputAndTextArea = ( el: HTMLInputElement | HTMLTextAreaElement, flags: ModelFlags, - modelRef: AnyRef, + getModelRef: () => AnyRef | undefined, parsedValue: SRef, ): Unbinder => { const isLazy = flags.lazy @@ -203,6 +203,8 @@ const handleInputAndTextArea = ( } const listener = (event: Event): void => { + const modelRef = getModelRef() + if (!modelRef) return const target = event.target as any if (!target || target.composing) return let value = target.value @@ -237,13 +239,18 @@ const handleInputAndTextArea = ( return unbinder } -const handleCheckBox = (el: HTMLInputElement, modelRef: AnyRef): Unbinder => { +const handleCheckBox = ( + el: HTMLInputElement, + getModelRef: () => AnyRef | undefined, +): Unbinder => { const eventType = 'change' const unbinder = (): void => { el.removeEventListener(eventType, listener) } const listener = (): void => { + const modelRef = getModelRef() + if (!modelRef) return const elementValue = getValue(el) const checked = el.checked const modelValue = modelRef() @@ -314,13 +321,18 @@ const getCheckboxChecked = ( return looseEqual(value, true) } -const handleRadio = (el: HTMLInputElement, modelRef: AnyRef): Unbinder => { +const handleRadio = ( + el: HTMLInputElement, + getModelRef: () => AnyRef | undefined, +): Unbinder => { const eventType = 'change' const unbinder = (): void => { el.removeEventListener(eventType, listener) } const listener = (): void => { + const modelRef = getModelRef() + if (!modelRef) return const elementValue = getValue(el) modelRef(elementValue) } @@ -330,7 +342,7 @@ const handleRadio = (el: HTMLInputElement, modelRef: AnyRef): Unbinder => { const handleSelect = ( el: HTMLSelectElement, - modelRef: AnyRef, + getModelRef: () => AnyRef | undefined, parsedValue: SRef, ): Unbinder => { const eventType = 'change' @@ -338,6 +350,8 @@ const handleSelect = ( el.removeEventListener(eventType, listener) } const listener = (): void => { + const modelRef = getModelRef() + if (!modelRef) return const flags = getFlags(parsedValue()[1]) const number = flags.number const selectedValue = Array.prototype.filter From d7bd3d8deff0b196efa36452066db74d99ab5ea2 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Sun, 22 Feb 2026 03:46:41 +0100 Subject: [PATCH 2/3] retarget single-props binding on source change. --- package.json | 2 +- src/directives/single-prop.ts | 25 +++++++++++++++++++++---- src/parser/Parser.ts | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 560e278..e1d5b66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "regor", - "version": "1.2.5", + "version": "1.2.6", "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 803d8f3..287ae09 100644 --- a/src/directives/single-prop.ts +++ b/src/directives/single-prop.ts @@ -1,6 +1,7 @@ import { 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' /** @@ -18,21 +19,37 @@ export const singlePropDirective: Directive = { ): Unbinder => { if (!option) return () => {} const key = camelize(option) + let stopEntangle: Unbinder = () => {} const stopObserving = observe( parseResult.value, () => { const value = parseResult.refs[0] ?? parseResult.value()[0] const ctx = parseResult.context - const ctxKey = ctx[option] + const ctxKey = ctx[key] if (ctxKey === value) return + + if (isRef(value)) { + if (isRef(ctxKey)) { + stopEntangle() + stopEntangle = entangle(value, ctxKey) + } + ctx[key] = value + return + } + + stopEntangle() + stopEntangle = () => {} if (isRef(ctxKey)) { ctxKey(value) - } else { - ctx[key] = value + return } + ctx[key] = value }, true, ) - return stopObserving + return () => { + stopEntangle() + stopObserving() + } }, } diff --git a/src/parser/Parser.ts b/src/parser/Parser.ts index faf7d59..b36df42 100644 --- a/src/parser/Parser.ts +++ b/src/parser/Parser.ts @@ -136,8 +136,8 @@ export class Parser { stopObserverList.push(stopObserving) } } - value(evaluated.map((x) => x.value)) result.refs = evaluated.map((x) => x.ref) + value(evaluated.map((x) => x.value)) } refresh() } catch (e) { From 79bdbc04f59e0877ed812559e6c929834158d297 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Sun, 22 Feb 2026 04:00:41 +0100 Subject: [PATCH 3/3] Add tests and set ctx stable on single-props. --- src/directives/single-prop.ts | 1 + tests/directives/model.spec.ts | 409 ++++++++++++++++++++++++++++++++- 2 files changed, 409 insertions(+), 1 deletion(-) diff --git a/src/directives/single-prop.ts b/src/directives/single-prop.ts index 287ae09..0ce5d69 100644 --- a/src/directives/single-prop.ts +++ b/src/directives/single-prop.ts @@ -32,6 +32,7 @@ export const singlePropDirective: Directive = { if (isRef(ctxKey)) { stopEntangle() stopEntangle = entangle(value, ctxKey) + return } ctx[key] = value return diff --git a/tests/directives/model.spec.ts b/tests/directives/model.spec.ts index 1eda046..f6464e6 100644 --- a/tests/directives/model.spec.ts +++ b/tests/directives/model.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest' -import { createApp, html, ref } from '../../src' +import { Component, createApp, createComponent, html, ref } from '../../src' test('r-model expression flag int converts input to integer', () => { const root = document.createElement('div') @@ -110,3 +110,410 @@ test('r-model with select multiple populates set', () => { select.dispatchEvent(new Event('change')) expect(Array.from(opts())).toStrictEqual(['y']) }) + +test('demonstrates stale model target through component prop after selected ref replacement', () => { + const root = document.createElement('div') + const selectedHost = ref({ hostname: 'alpha' }) + 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({ hostname: 'beta' })) + expect(input.value).toBe('beta') + selectedHost().hostname('new-value') + expect(input.value).toBe('new-value') + input.value = 'updated-beta' + input.dispatchEvent(new Event('input')) + + // This currently fails: model keeps targeting the old hostname ref created before selectedHost replacement. + expect(selectedHost().hostname()).toBe('updated-beta') +}) + +test('demonstrates stale model target after selected ref replacement without component', () => { + const root = document.createElement('div') + const selectedHost = ref({ hostname: 'alpha' }) + + createApp( + { selectedHost }, + { + element: root, + template: html``, + }, + ) + + const input = root.querySelector('input') as HTMLInputElement | null + if (!input) throw new Error('missing input') + + selectedHost(ref({ hostname: 'beta' })) + expect(input.value).toBe('beta') + selectedHost().hostname('new-value') + expect(input.value).toBe('new-value') + input.value = 'updated-beta' + input.dispatchEvent(new Event('input')) + + // Mirrors the component case but with a direct binding target. + expect(selectedHost().hostname()).toBe('updated-beta') +}) + +test('r-model through component prop tracks writes after selected ref replacement', () => { + const root = document.createElement('div') + const selectedHost = ref({ hostname: 'alpha' }) + 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({ hostname: 'beta' })) + expect(input.value).toBe('beta') + selectedHost().hostname('new-value') + expect(input.value).toBe('new-value') + input.value = 'updated-beta' + input.dispatchEvent(new Event('input')) + expect(selectedHost().hostname()).toBe('updated-beta') + + input.value = 'updated-beta-2' + input.dispatchEvent(new Event('input')) + expect(selectedHost().hostname()).toBe('updated-beta-2') +}) + +test('r-model through component prop keeps parent-to-input sync after selected ref replacement', () => { + const root = document.createElement('div') + const selectedHost = ref({ hostname: 'alpha' }) + 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({ hostname: 'beta' })) + expect(input.value).toBe('beta') + selectedHost().hostname('from-parent') + + expect(input.value).toBe('from-parent') +}) + +test('r-model direct binding tracks latest ref across multiple selected ref replacements', () => { + const root = document.createElement('div') + const selectedHost = ref({ hostname: 'alpha' }) + + createApp( + { selectedHost }, + { + element: root, + template: html``, + }, + ) + + const input = root.querySelector('input') as HTMLInputElement | null + if (!input) throw new Error('missing input') + + selectedHost(ref({ hostname: 'beta' })) + expect(input.value).toBe('beta') + selectedHost().hostname('new-value') + expect(input.value).toBe('new-value') + input.value = 'updated-beta' + input.dispatchEvent(new Event('input')) + expect(selectedHost().hostname()).toBe('updated-beta') + + selectedHost(ref({ hostname: 'gamma' })) + expect(input.value).toBe('gamma') + selectedHost().hostname('new-value-2') + expect(input.value).toBe('new-value-2') + input.value = 'updated-gamma' + input.dispatchEvent(new Event('input')) + expect(selectedHost().hostname()).toBe('updated-gamma') +}) + +test('edge case: r-model through component prop breaks after second selected ref replacement', () => { + const root = document.createElement('div') + const selectedHost = ref({ hostname: 'alpha' }) + 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({ hostname: 'beta' })) + expect(input.value).toBe('beta') + selectedHost().hostname('new-value') + expect(input.value).toBe('new-value') + input.value = 'updated-beta' + input.dispatchEvent(new Event('input')) + expect(selectedHost().hostname()).toBe('updated-beta') + + selectedHost(ref({ hostname: 'gamma' })) + expect(input.value).toBe('gamma') + selectedHost().hostname('new-value-2') + expect(input.value).toBe('new-value-2') + input.value = 'updated-gamma' + input.dispatchEvent(new Event('input')) + + // Current behavior: this fails; write no longer targets latest ref after second replacement. + expect(selectedHost().hostname()).toBe('updated-gamma') +}) + +test('two-way component model binding syncs parent and child without replacement', () => { + const root = document.createElement('div') + const selectedHost = ref({ hostname: 'alpha' }) + 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().hostname('from-parent') + expect(input.value).toBe('from-parent') + + input.value = 'from-child' + input.dispatchEvent(new Event('input')) + expect(selectedHost().hostname()).toBe('from-child') +}) + +test('two-way component model binding survives alternating parent and child updates after replacement', () => { + const root = document.createElement('div') + const selectedHost = ref({ hostname: 'alpha' }) + 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({ hostname: 'beta' })) + expect(input.value).toBe('beta') + selectedHost().hostname('parent-beta') + expect(input.value).toBe('parent-beta') + + input.value = 'child-beta' + input.dispatchEvent(new Event('input')) + expect(selectedHost().hostname()).toBe('child-beta') + + selectedHost().hostname('parent-beta-2') + expect(input.value).toBe('parent-beta-2') +}) + +test('two-way component model binding stays connected across multiple replacements', () => { + const root = document.createElement('div') + const selectedHost = ref({ hostname: 'alpha' }) + 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({ hostname: 'beta' })) + expect(input.value).toBe('beta') + selectedHost().hostname('new-value') + expect(input.value).toBe('new-value') + input.value = 'child-beta' + input.dispatchEvent(new Event('input')) + expect(selectedHost().hostname()).toBe('child-beta') + + selectedHost(ref({ hostname: 'gamma' })) + expect(input.value).toBe('gamma') + selectedHost().hostname('new-value-2') + expect(input.value).toBe('new-value-2') + selectedHost().hostname('parent-gamma') + expect(input.value).toBe('parent-gamma') + + input.value = 'child-gamma' + input.dispatchEvent(new Event('input')) + expect(selectedHost().hostname()).toBe('child-gamma') +}) + +test('three nested components keep :model two-way binding across replacements', () => { + const root = document.createElement('div') + const selectedHost = ref({ hostname: 'alpha' }) + + const leafInputField = createComponent( + html``, + ['model'], + ) + const middleInputField = createComponent( + html``, + { + props: ['model'], + context: () => ({ + components: { + leafInputField, + }, + }), + }, + ) + const outerInputField = createComponent( + html``, + { + props: ['model'], + context: () => ({ + components: { + middleInputField, + }, + }), + }, + ) + + createApp( + { + selectedHost, + components: { + outerInputField, + } as unknown as Record, + }, + { + element: root, + template: html``, + }, + ) + + const input = root.querySelector('input') as HTMLInputElement | null + if (!input) throw new Error('missing input') + + selectedHost().hostname('parent-alpha') + expect(input.value).toBe('parent-alpha') + + input.value = 'child-alpha' + input.dispatchEvent(new Event('input')) + expect(selectedHost().hostname()).toBe('child-alpha') + + selectedHost(ref({ hostname: 'beta' })) + expect(input.value).toBe('beta') + selectedHost().hostname('parent-beta') + expect(input.value).toBe('parent-beta') + + input.value = 'child-beta' + input.dispatchEvent(new Event('input')) + expect(selectedHost().hostname()).toBe('child-beta') + selectedHost().hostname('new-value') + expect(input.value).toBe('new-value') + + selectedHost(ref({ hostname: 'gamma' })) + expect(input.value).toBe('gamma') + input.value = 'child-gamma' + input.dispatchEvent(new Event('input')) + expect(selectedHost().hostname()).toBe('child-gamma') + selectedHost().hostname('new-value-2') + expect(input.value).toBe('new-value-2') +})