fix(core): correctly check if an event has any listeners bound to it (#1935)

* fix(core): separate listeners from emitter for correct check on hasListeners

Signed-off-by: braks <78412429+bcakmakoglu@users.noreply.github.com>

* fix(core): check for listeners bound via emits

Signed-off-by: braks <78412429+bcakmakoglu@users.noreply.github.com>

* chore(changeset): add

---------

Signed-off-by: braks <78412429+bcakmakoglu@users.noreply.github.com>
This commit is contained in:
Braks
2025-08-28 08:50:19 +02:00
parent dd48e47d02
commit e9f0cdff3d
4 changed files with 98 additions and 49 deletions

View File

@@ -0,0 +1,5 @@
---
"@vue-flow/core": patch
---
Correctly check if an event listener was bound to the VueFlow component, using for example `@node-click` or if a listener was bound using the exposed event hooks from `useVueFlow` when determening if a listener for an event exists at all.

View File

@@ -56,12 +56,12 @@ const modelValue = useVModel(props, 'modelValue', emit)
const modelNodes = useVModel(props, 'nodes', emit)
const modelEdges = useVModel(props, 'edges', emit)
const instance = useVueFlow(props)
const vfInstance = useVueFlow(props)
// watch props and update store state
const dispose = useWatchProps({ modelValue, nodes: modelNodes, edges: modelEdges }, props, instance)
const disposeWatchers = useWatchProps({ modelValue, nodes: modelNodes, edges: modelEdges }, props, vfInstance)
useHooks(emit, instance.hooks)
useHooks(emit, vfInstance.hooks)
useOnInitHandler()
@@ -72,12 +72,9 @@ useStylesLoadedWarning()
// as that would require a lot of boilerplate and causes significant performance drops
provide(Slots, slots)
onUnmounted(() => {
// clean up watcher scope
dispose()
})
onUnmounted(disposeWatchers)
defineExpose<VueFlowStore>(instance)
defineExpose<VueFlowStore>(vfInstance)
</script>
<script lang="ts">
@@ -88,7 +85,7 @@ export default {
</script>
<template>
<div :ref="instance.vueFlowRef" class="vue-flow">
<div :ref="vfInstance.vueFlowRef" class="vue-flow">
<Viewport>
<EdgeRenderer />

View File

@@ -1,10 +1,9 @@
import { tryOnScopeDispose } from '@vueuse/core'
import type { Ref } from 'vue'
import { onBeforeMount } from 'vue'
import type { FlowHooks } from '../types'
import { getCurrentInstance, onBeforeMount } from 'vue'
import type { FlowEvents, FlowHooks } from '../types'
import { createExtendedEventHook, warn } from '../utils'
// flow event hooks
export function createHooks(): FlowHooks {
return {
edgesChange: createExtendedEventHook(),
@@ -64,18 +63,40 @@ export function createHooks(): FlowHooks {
}
export function useHooks(emit: (...args: any[]) => void, hooks: Ref<FlowHooks>) {
const inst = getCurrentInstance()
onBeforeMount(() => {
for (const [key, value] of Object.entries(hooks.value)) {
const listener = (data: any) => {
const listener = (data: unknown) => {
emit(key, data)
}
// push into fns instead of using `on` to avoid overwriting default handlers - the emits should be called in addition to the default handlers
value.fns.add(listener)
// push into fns instead of using `on` to avoid overwriting default handlers - the emitter should be called in addition to the default handlers
value.setEmitter(listener)
tryOnScopeDispose(value.removeEmitter)
tryOnScopeDispose(() => {
value.off(listener)
})
value.setHasEmitListeners(() => hasVNodeListener(key as keyof FlowEvents))
tryOnScopeDispose(value.removeHasEmitListeners)
}
})
function hasVNodeListener(event: keyof FlowEvents) {
const key = toHandlerKey(event)
// listeners live on vnode.props; value can be a Function or an array of Functions
const h = inst?.vnode.props?.[key]
return !!h
}
}
/**
* Converts an event name to the corresponding handler key.
* E.g. 'nodeClick' -> 'onNodeClick'
*
* @param event The event name to convert.
* @returns The corresponding handler key.
*/
function toHandlerKey(event: string) {
const [head, ...rest] = event.split(':')
const camel = head.replace(/(?:^|-)(\w)/g, (_, c: string) => c.toUpperCase())
return `on${camel}${rest.length ? `:${rest.join(':')}` : ''}`
}

View File

@@ -1,56 +1,78 @@
import type { EventHook } from '@vueuse/core'
import { tryOnScopeDispose } from '@vueuse/core'
/**
* Source code taken from https://github.com/vueuse/vueuse/blob/main/packages/shared/createEventHook/index.ts
*
* Modified to be able to check if there are any event listeners
*/
export interface EventHookExtended<T> extends EventHook<T> {
/** true if any user listeners are registered (emitter ignored) */
hasListeners: () => boolean
fns: Set<(param: T) => void>
/** current user listeners (read-only; do not mutate externally) */
listeners: ReadonlySet<(param: T) => void>
/** wire a single external emitter (e.g., for `emit`) */
setEmitter: (fn: (param: T) => void) => void
/** remove the external emitter */
removeEmitter: () => void
/** wire a function to detect if any emit listeners exist (e.g., for `$listeners` in Vue 2) */
setHasEmitListeners: (fn: () => boolean) => void
/** remove the emit listeners detector */
removeHasEmitListeners: () => void
}
type Handler<T = any> = (param: T) => any | Promise<any>
const noop: Handler = () => {}
export function createExtendedEventHook<T = any>(defaultHandler?: (param: T) => void): EventHookExtended<T> {
const fns = new Set<(param: T) => void>()
const listeners = new Set<Handler>()
let emitter: Handler = noop
let hasEmitListeners = () => false
let hasDefaultHandler = false
const hasListeners = () => listeners.size > 0 || hasEmitListeners()
const hasListeners = () => fns.size > 0
if (defaultHandler) {
hasDefaultHandler = true
fns.add(defaultHandler)
const setEmitter = (fn: Handler) => {
emitter = fn
}
const off = (fn: (param: T) => void) => {
fns.delete(fn)
const removeEmitter = () => {
emitter = noop
}
const on = (fn: (param: T) => void) => {
if (defaultHandler && hasDefaultHandler) {
fns.delete(defaultHandler)
}
const setHasEmitListeners = (fn: () => boolean) => {
hasEmitListeners = fn
}
fns.add(fn)
const removeHasEmitListeners = () => {
hasEmitListeners = () => false
}
const offFn = () => {
off(fn)
const off = (fn: Handler) => {
listeners.delete(fn)
}
if (defaultHandler && hasDefaultHandler) {
fns.add(defaultHandler)
}
}
const on = (fn: Handler) => {
listeners.add(fn)
const offFn = () => off(fn)
tryOnScopeDispose(offFn)
return {
off: offFn,
}
return { off: offFn }
}
/**
* Trigger order:
* 1) If any user listeners OR an emitter exist -> call all of those (defaultHandler is skipped)
* 2) Else (no listeners and no emitter) -> call defaultHandler (if provided)
*
* Errors are isolated via allSettled so one failing handler doesn't break others.
*/
const trigger = (param: T) => {
return Promise.all(Array.from(fns).map((fn) => fn(param)))
const queue: Handler[] = [emitter]
if (hasListeners()) {
queue.push(...listeners)
} else if (defaultHandler) {
queue.push(defaultHandler)
}
return Promise.allSettled(queue.map((fn) => fn(param)))
}
return {
@@ -58,6 +80,10 @@ export function createExtendedEventHook<T = any>(defaultHandler?: (param: T) =>
off,
trigger,
hasListeners,
fns,
listeners,
setEmitter,
removeEmitter,
setHasEmitListeners,
removeHasEmitListeners,
}
}