Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
92 changes: 82 additions & 10 deletions src/directives/single-prop.ts
Original file line number Diff line number Diff line change
@@ -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<symbol, unknown>)?.[modelBridgeSymbol]
const markModelBridge = (value: AnyRef): void => {
;(value as unknown as Record<symbol, unknown>)[modelBridgeSymbol] = 1
}
const createModelBridge = (source: AnyRef): AnyRef => {
const bridge = ref(source()) as unknown as AnyRef
markModelBridge(bridge)
return bridge
}

/**
* @internal
Expand All @@ -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,
)
Expand Down
140 changes: 140 additions & 0 deletions tests/directives/model.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`<input type="number" r-model="amount" />` },
)
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`<input type="number" r-model="amount" />` },
)
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`<input type="number" r-model="amount" />` },
)
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`<input type="number" r-model="selectedHost.port" />`,
},
)

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`<input type="number" r-model="model" />`,
['model'],
)

createApp(
{
selectedHost,
components: {
hostInputField,
},
},
{
element: root,
template: html`<HostInputField :model="selectedHost.port"></HostInputField>`,
},
)

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<string[]>([])
Expand Down Expand Up @@ -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`<input type="text" r-model="model" />`,
['model'],
)

createApp(
{
selectedHost,
components: {
hostInputField,
},
},
{
element: root,
template: html`<HostInputField :model="selectedHost.hostname"></HostInputField>`,
},
)

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')
})