diff --git a/packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap b/packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap index 377dd3df61a..3f9756f3853 100644 --- a/packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap @@ -1,13 +1,12 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`compile > bindings 1`] = ` -"import { txt as _txt, toDisplayString as _toDisplayString, setText as _setText, renderEffect as _renderEffect, template as _template } from 'vue'; +"import { toDisplayString as _toDisplayString, setTextBinding as _setTextBinding, template as _template } from 'vue'; const t0 = _template("
", 1) export function render(_ctx, $props, $emit, $attrs, $slots) { const n0 = t0() - const x0 = _txt(n0) - _renderEffect(() => _setText(x0, "count is " + _toDisplayString(_ctx.count) + ".")) + _setTextBinding(n0, () => "count is " + _toDisplayString(_ctx.count) + ".") return n0 }" `; @@ -148,7 +147,7 @@ export function render(_ctx, $props, $emit, $attrs, $slots) { `; exports[`compile > directives > v-pre > should not affect siblings after it 1`] = ` -"import { setProp as _setProp, renderEffect as _renderEffect, setInsertionState as _setInsertionState, createAssetComponent as _createAssetComponent, child as _child, toDisplayString as _toDisplayString, setText as _setText, template as _template } from 'vue'; +"import { setPropBinding as _setPropBinding, setInsertionState as _setInsertionState, createAssetComponent as _createAssetComponent, child as _child, toDisplayString as _toDisplayString, setText as _setText, renderEffect as _renderEffect, template as _template } from 'vue'; const t0 = _template("
{{ bar }}", 2) const t1 = _template("
") @@ -156,7 +155,7 @@ export function render(_ctx, $props, $emit, $attrs, $slots) { const n0 = t0() const n3 = t1() const n2 = _child(n3, 1) - _renderEffect(() => _setProp(n3, "id", _ctx.foo)) + _setPropBinding(n3, "id", () => _ctx.foo) _setInsertionState(n3, 0, 0) const n1 = _createAssetComponent("Comp") _renderEffect(() => _setText(n2, _toDisplayString(_ctx.bar))) @@ -236,12 +235,12 @@ export function render(_ctx, $props, $emit, $attrs, $slots) { `; exports[`compile > execution order > flushes parent props before creating child component 1`] = ` -"import { setProp as _setProp, renderEffect as _renderEffect, setInsertionState as _setInsertionState, createAssetComponent as _createAssetComponent, template as _template } from 'vue'; +"import { setPropBinding as _setPropBinding, setInsertionState as _setInsertionState, createAssetComponent as _createAssetComponent, template as _template } from 'vue'; const t0 = _template("
", 1) export function render(_ctx, $props, $emit, $attrs, $slots) { const n1 = t0() - _renderEffect(() => _setProp(n1, "id", _ctx.useId())) + _setPropBinding(n1, "id", () => _ctx.useId()) _setInsertionState(n1, null, 0) const n0 = _createAssetComponent("Child") return n1 @@ -249,20 +248,19 @@ export function render(_ctx, $props, $emit, $attrs, $slots) { `; exports[`compile > execution order > flushes previous effects before creating child component 1`] = ` -"import { txt as _txt, toDisplayString as _toDisplayString, setText as _setText, renderEffect as _renderEffect, createAssetComponent as _createAssetComponent, template as _template } from 'vue'; +"import { toDisplayString as _toDisplayString, setTextBinding as _setTextBinding, createAssetComponent as _createAssetComponent, template as _template } from 'vue'; const t0 = _template("
") export function render(_ctx, $props, $emit, $attrs, $slots) { const n0 = t0() - const x0 = _txt(n0) - _renderEffect(() => _setText(x0, "parent: " + _toDisplayString(_ctx.useId()))) + _setTextBinding(n0, () => "parent: " + _toDisplayString(_ctx.useId())) const n1 = _createAssetComponent("Child") return [n0, n1] }" `; exports[`compile > execution order > setInsertionState > next, child and nthChild should be above the setInsertionState 1`] = ` -"import { child as _child, next as _next, setInsertionState as _setInsertionState, createAssetComponent as _createAssetComponent, nthChild as _nthChild, createIf as _createIf, setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue'; +"import { child as _child, next as _next, setInsertionState as _setInsertionState, createAssetComponent as _createAssetComponent, nthChild as _nthChild, createIf as _createIf, setPropBinding as _setPropBinding, template as _template } from 'vue'; const t0 = _template("
", 2) const t1 = _template("
')() as HTMLButtonElement + setDynamicEventsBinding(button, () => events.value) + return button + }, + }) + }, + }).render() + + expect( + `onEffectCleanup() was called when there was no active effect`, + ).not.toHaveBeenWarned() + + button.click() + expect(calls).toEqual(['click']) + + events.value = { + mouseover: () => { + calls.push('mouseover') + }, + } + await nextTick() + + button.dispatchEvent(new Event('mouseover')) + button.click() + expect(calls).toEqual(['click', 'click']) }) }) diff --git a/packages/runtime-vapor/__tests__/renderEffect.spec.ts b/packages/runtime-vapor/__tests__/renderEffect.spec.ts index 68ea0c125f3..b05fbadd869 100644 --- a/packages/runtime-vapor/__tests__/renderEffect.spec.ts +++ b/packages/runtime-vapor/__tests__/renderEffect.spec.ts @@ -11,7 +11,25 @@ import { watchPostEffect, watchSyncEffect, } from '@vue/runtime-dom' -import { renderEffect, template } from '../src' +import { + renderEffect, + setAttrBinding, + setBlockHtmlBinding, + setBlockTextBinding, + setClassBinding, + setClassNameBinding, + setDOMPropBinding, + setDynamicEventsBinding, + setDynamicPropsBinding, + setEventBinding, + setHtmlBinding, + setMergedDynamicPropsBinding, + setPropBinding, + setStyleBinding, + setTextBinding, + setValueBinding, + template, +} from '../src' import { RenderEffect } from '../src/renderEffect' import { onEffectCleanup } from '@vue/reactivity' import { makeRender } from './_utils' @@ -137,6 +155,311 @@ describe('renderEffect', () => { expect(dummy).toBe(3) }) + test('setTextBinding updates text with render lifecycle', async () => { + const calls: string[] = [] + const { instance, html } = define({ + setup() { + const source = ref('one') + const update = () => (source.value = 'two') + onBeforeUpdate(() => calls.push(`beforeUpdate ${source.value}`)) + onUpdated(() => calls.push(`updated ${source.value}`)) + return { source, update } + }, + render(ctx: any) { + const t0 = template('
', 1) + const n0 = t0() as ParentNode + setTextBinding(n0, () => ctx.source) + return n0 + }, + }).render() + + expect(html()).toBe('
one
') + expect(calls).toEqual([]) + + const { update } = instance?.setupState as any + update() + await nextTick() + + expect(html()).toBe('
two
') + expect(calls).toEqual(['beforeUpdate two', 'updated two']) + }) + + test('setTextBinding getter runs with current instance and scope', async () => { + const source = ref('one') + const scope = new EffectScope() + let instanceSnap: GenericComponentInstance | null = null + let scopeSnap: EffectScope | undefined = undefined + const { instance, html } = define(() => { + const t0 = template('
', 1) + const n0 = t0() as ParentNode + scope.run(() => { + setTextBinding(n0, () => { + instanceSnap = currentInstance + scopeSnap = getCurrentScope() + return source.value + }) + }) + return n0 + }).render() + + expect(html()).toBe('
one
') + expect(instanceSnap).toBe(instance) + expect(scopeSnap).toBe(scope) + + source.value = 'two' + await nextTick() + expect(html()).toBe('
two
') + expect(instanceSnap).toBe(instance) + expect(scopeSnap).toBe(scope) + scope.stop() + }) + + test('DOM binding helpers update with their source values', async () => { + let input!: HTMLInputElement + let eventTarget!: HTMLButtonElement + let dynamicEventTarget!: HTMLButtonElement + const eventCalls: string[] = [] + const { instance, html } = define({ + setup() { + const source = ref('one') + const active = ref(true) + const color = ref('red') + const eventName = ref('click') + const events = ref void>>({ + click: () => eventCalls.push(`dynamic ${source.value}`), + }) + const update = () => { + source.value = 'two' + active.value = false + color.value = 'blue' + eventName.value = 'mouseover' + events.value = { + mouseover: () => eventCalls.push(`dynamic ${source.value}`), + } + } + return { source, active, color, eventName, events, update } + }, + render(ctx: any) { + const root = document.createElement('div') + const attr = document.createElement('div') + const prop = document.createElement('div') + const domProp = document.createElement('div') + input = document.createElement('input') + const cls = document.createElement('div') + const clsName = document.createElement('div') + const style = document.createElement('div') + const html = document.createElement('div') + const blockText = document.createElement('div') + const blockHtml = document.createElement('div') + const dynamic = document.createElement('div') + const mergedDynamic = document.createElement('div') + eventTarget = document.createElement('button') + dynamicEventTarget = document.createElement('button') + + root.append( + attr, + prop, + domProp, + input, + cls, + clsName, + style, + html, + blockText, + blockHtml, + dynamic, + mergedDynamic, + eventTarget, + dynamicEventTarget, + ) + + setAttrBinding(attr, 'data-test', () => ctx.source) + setPropBinding(prop, 'id', () => ctx.source) + setDOMPropBinding(domProp, 'title', () => ctx.source) + setValueBinding(input, () => ctx.source) + setClassBinding(cls, () => ctx.source) + setClassNameBinding(clsName, () => (ctx.active ? 1 : 0), 'active') + setStyleBinding(style, () => ({ color: ctx.color })) + setHtmlBinding(html, () => `${ctx.source}`) + setBlockTextBinding(blockText, () => ctx.source) + setBlockHtmlBinding(blockHtml, () => `${ctx.source}`) + setDynamicPropsBinding(dynamic, () => [ + { id: ctx.source, class: ctx.source }, + ]) + setMergedDynamicPropsBinding( + mergedDynamic, + { id: 'static-id' }, + () => ({ title: ctx.source, class: ctx.source }), + { class: 'static-class' }, + ) + setEventBinding( + eventTarget, + () => ctx.eventName, + () => eventCalls.push(`event ${ctx.source}`), + ) + setDynamicEventsBinding(dynamicEventTarget, () => ctx.events) + + return root + }, + }).render() + + expect(html()).toBe( + '
one
one
one
', + ) + expect(input.value).toBe('one') + eventTarget.dispatchEvent(new Event('click')) + dynamicEventTarget.dispatchEvent(new Event('click')) + expect(eventCalls).toEqual(['event one', 'dynamic one']) + + const { update } = instance?.setupState as any + update() + await nextTick() + + expect(html()).toBe( + '
two
two
two
', + ) + expect(input.value).toBe('two') + eventTarget.dispatchEvent(new Event('click')) + dynamicEventTarget.dispatchEvent(new Event('click')) + eventTarget.dispatchEvent(new Event('mouseover')) + dynamicEventTarget.dispatchEvent(new Event('mouseover')) + expect(eventCalls).toEqual([ + 'event one', + 'dynamic one', + 'event two', + 'dynamic two', + ]) + }) + + test('setMergedDynamicPropsBinding handles nullish source updates', async () => { + let el!: HTMLElement + const { instance } = define({ + setup() { + const mode = ref<'value' | 'null' | 'undefined'>('value') + const setNull = () => { + mode.value = 'null' + } + const setValue = () => { + mode.value = 'value' + } + const setUndefined = () => { + mode.value = 'undefined' + } + return { mode, setNull, setValue, setUndefined } + }, + render(ctx: any) { + el = document.createElement('div') + setMergedDynamicPropsBinding( + el, + { id: 'static-id', class: 'before', style: { color: 'red' } }, + () => + ctx.mode === 'value' + ? { + title: 'live', + 'data-dyn': 'yes', + class: 'dynamic', + style: { backgroundColor: 'blue' }, + } + : ctx.mode === 'null' + ? null + : undefined, + { class: 'after', style: { fontSize: '12px' } }, + ) + return el + }, + }).render() + + expect(el.id).toBe('static-id') + expect(el.title).toBe('live') + expect(el.dataset.dyn).toBe('yes') + expect(el.className).toBe('before dynamic after') + expect(el.style.color).toBe('red') + expect(el.style.backgroundColor).toBe('blue') + expect(el.style.fontSize).toBe('12px') + + const { setNull, setValue, setUndefined } = instance?.setupState as any + setNull() + await nextTick() + + expect(el.id).toBe('static-id') + expect(el.title).toBe('') + expect(el.dataset.dyn).toBe(undefined) + expect(el.className).toBe('before after') + expect(el.style.color).toBe('red') + expect(el.style.backgroundColor).toBe('') + expect(el.style.fontSize).toBe('12px') + + setValue() + await nextTick() + + expect(el.title).toBe('live') + expect(el.dataset.dyn).toBe('yes') + expect(el.className).toBe('before dynamic after') + expect(el.style.backgroundColor).toBe('blue') + + setUndefined() + await nextTick() + + expect(el.id).toBe('static-id') + expect(el.title).toBe('') + expect(el.dataset.dyn).toBe(undefined) + expect(el.className).toBe('before after') + expect(el.style.color).toBe('red') + expect(el.style.backgroundColor).toBe('') + expect(el.style.fontSize).toBe('12px') + }) + + test('setEventBinding preserves listener options across event name updates', async () => { + let button!: HTMLButtonElement + const calls: string[] = [] + const { instance } = define({ + setup() { + const eventName = ref('click') + const update = () => { + eventName.value = 'mouseover' + } + return { eventName, update } + }, + render(ctx: any) { + button = document.createElement('button') + setEventBinding( + button, + () => ctx.eventName, + () => calls.push(ctx.eventName), + { once: true }, + ) + return button + }, + }).render() + + const { update } = instance?.setupState as any + update() + await nextTick() + + button.dispatchEvent(new Event('click')) + button.dispatchEvent(new Event('mouseover')) + button.dispatchEvent(new Event('mouseover')) + expect(calls).toEqual(['mouseover']) + }) + + test('setEventBinding does not mutate listener options', () => { + const options = { once: true } + const button = document.createElement('button') + const scope = new EffectScope() + + scope.run(() => { + setEventBinding( + button, + () => 'click', + () => {}, + options, + ) + }) + scope.stop() + + expect(options).toEqual({ once: true }) + }) + test('should run with the scheduling order', async () => { const calls: string[] = [] diff --git a/packages/runtime-vapor/src/dom/bindingEffect.ts b/packages/runtime-vapor/src/dom/bindingEffect.ts new file mode 100644 index 00000000000..416d50dc9f2 --- /dev/null +++ b/packages/runtime-vapor/src/dom/bindingEffect.ts @@ -0,0 +1,152 @@ +import { inOnceSlot } from '../componentSlots' +import { renderEffect } from '../renderEffect' +import { on, onBinding, setDynamicEvents } from './event' +import { txt } from './node' +import { + setAttr, + setBlockHtml, + setBlockText, + setClass, + setClassName, + setDOMProp, + setDynamicProps, + setHtml, + setProp, + setStyle, + setText, + setValue, +} from './prop' + +type TextNodeWithCache = Text & { $txt?: string } +type MergedDynamicPropsSource = Record | null | undefined +type DynamicPropsGetter = () => MergedDynamicPropsSource + +export function setTextBinding(parent: ParentNode, getter: () => string): void { + const text = txt(parent) as TextNodeWithCache + renderEffect(() => setText(text, getter())) +} + +export function setHtmlBinding(el: any, getter: () => any): void { + renderEffect(() => setHtml(el, getter())) +} + +export function setBlockHtmlBinding(block: any, getter: () => any): void { + renderEffect(() => setBlockHtml(block, getter())) +} + +export function setBlockTextBinding(block: any, getter: () => any): void { + renderEffect(() => setBlockText(block, getter())) +} + +export function setClassBinding( + el: any, + getter: () => any, + isSVG: boolean = false, +): void { + renderEffect(() => setClass(el, getter(), isSVG)) +} + +export function setClassNameBinding( + el: any, + getter: () => number, + cls: string | string[], + prefix: string = '', + suffix: string = '', +): void { + renderEffect(() => setClassName(el, getter(), cls, prefix, suffix)) +} + +export function setStyleBinding(el: any, getter: () => any): void { + renderEffect(() => setStyle(el, getter())) +} + +export function setValueBinding(el: any, getter: () => any): void { + renderEffect(() => setValue(el, getter())) +} + +export function setAttrBinding( + el: any, + key: string, + getter: () => any, + isSVG: boolean = false, +): void { + renderEffect(() => setAttr(el, key, getter(), isSVG)) +} + +export function setPropBinding(el: any, key: string, getter: () => any): void { + renderEffect(() => setProp(el, key, getter())) +} + +export function setDOMPropBinding( + el: any, + key: string, + getter: () => any, +): void { + renderEffect(() => setDOMProp(el, key, getter())) +} + +export function setDynamicPropsBinding( + el: any, + getter: () => any[], + isSVG: boolean = false, +): void { + renderEffect(() => setDynamicProps(el, getter(), isSVG)) +} + +export function setMergedDynamicPropsBinding( + el: any, + before: MergedDynamicPropsSource, + getter: DynamicPropsGetter, + after?: MergedDynamicPropsSource, + isSVG?: boolean, +): void { + const values = createMergedDynamicPropsValues(before, undefined, after) + const index = before != null ? 1 : 0 + renderEffect(() => { + values[index] = getter() + setDynamicProps(el, values, isSVG === true) + }) +} + +function createMergedDynamicPropsValues( + before: MergedDynamicPropsSource, + value: MergedDynamicPropsSource, + after: MergedDynamicPropsSource, +): any[] { + return before != null + ? after != null + ? [before, value, after] + : [before, value] + : after != null + ? [value, after] + : [value] +} + +export function setEventBinding( + el: Element, + getter: () => string, + handler: (e: Event) => any | ((e: Event) => any)[], + options?: AddEventListenerOptions, +): void { + if (inOnceSlot) { + on(el, getter(), handler, options) + return + } + + renderEffect(() => onBinding(el, getter(), handler, options)) +} + +export function setDynamicEventsBinding( + el: HTMLElement, + getter: () => Record any>, +): void { + if (inOnceSlot) { + const events = getter() + for (const name in events) { + on(el, name, events[name]) + } + return + } + + renderEffect(() => setDynamicEvents(el, getter())) +} diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index 52bbda96129..d10b3345640 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -34,6 +34,23 @@ export { renderEffect } from './renderEffect' export { createSlot, withVaporCtx } from './componentSlots' export { template } from './dom/template' export { createTextNode, child, nthChild, next, txt } from './dom/node' +export { + setAttrBinding, + setBlockHtmlBinding, + setBlockTextBinding, + setClassBinding, + setClassNameBinding, + setDOMPropBinding, + setDynamicEventsBinding, + setDynamicPropsBinding, + setEventBinding, + setHtmlBinding, + setMergedDynamicPropsBinding, + setPropBinding, + setStyleBinding, + setTextBinding, + setValueBinding, +} from './dom/bindingEffect' export { setText, setBlockText,