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-31 13:59:37 +02:00
parent dd48e47d02
commit e9f0cdff3d
4 changed files with 98 additions and 49 deletions
+5
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.
@@ -56,12 +56,12 @@ const modelValue = useVModel(props, 'modelValue', emit)
const modelNodes = useVModel(props, 'nodes', emit) const modelNodes = useVModel(props, 'nodes', emit)
const modelEdges = useVModel(props, 'edges', emit) const modelEdges = useVModel(props, 'edges', emit)
const instance = useVueFlow(props) const vfInstance = useVueFlow(props)
// watch props and update store state // 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() useOnInitHandler()
@@ -72,12 +72,9 @@ useStylesLoadedWarning()
// as that would require a lot of boilerplate and causes significant performance drops // as that would require a lot of boilerplate and causes significant performance drops
provide(Slots, slots) provide(Slots, slots)
onUnmounted(() => { onUnmounted(disposeWatchers)
// clean up watcher scope
dispose()
})
defineExpose<VueFlowStore>(instance) defineExpose<VueFlowStore>(vfInstance)
</script> </script>
<script lang="ts"> <script lang="ts">
@@ -88,7 +85,7 @@ export default {
</script> </script>
<template> <template>
<div :ref="instance.vueFlowRef" class="vue-flow"> <div :ref="vfInstance.vueFlowRef" class="vue-flow">
<Viewport> <Viewport>
<EdgeRenderer /> <EdgeRenderer />
+30 -9
View File
@@ -1,10 +1,9 @@
import { tryOnScopeDispose } from '@vueuse/core' import { tryOnScopeDispose } from '@vueuse/core'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { onBeforeMount } from 'vue' import { getCurrentInstance, onBeforeMount } from 'vue'
import type { FlowHooks } from '../types' import type { FlowEvents, FlowHooks } from '../types'
import { createExtendedEventHook, warn } from '../utils' import { createExtendedEventHook, warn } from '../utils'
// flow event hooks
export function createHooks(): FlowHooks { export function createHooks(): FlowHooks {
return { return {
edgesChange: createExtendedEventHook(), edgesChange: createExtendedEventHook(),
@@ -64,18 +63,40 @@ export function createHooks(): FlowHooks {
} }
export function useHooks(emit: (...args: any[]) => void, hooks: Ref<FlowHooks>) { export function useHooks(emit: (...args: any[]) => void, hooks: Ref<FlowHooks>) {
const inst = getCurrentInstance()
onBeforeMount(() => { onBeforeMount(() => {
for (const [key, value] of Object.entries(hooks.value)) { for (const [key, value] of Object.entries(hooks.value)) {
const listener = (data: any) => { const listener = (data: unknown) => {
emit(key, data) 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 // push into fns instead of using `on` to avoid overwriting default handlers - the emitter should be called in addition to the default handlers
value.fns.add(listener) value.setEmitter(listener)
tryOnScopeDispose(value.removeEmitter)
tryOnScopeDispose(() => { value.setHasEmitListeners(() => hasVNodeListener(key as keyof FlowEvents))
value.off(listener) 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(':')}` : ''}`
} }
@@ -1,56 +1,78 @@
import type { EventHook } from '@vueuse/core' import type { EventHook } from '@vueuse/core'
import { tryOnScopeDispose } 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> { export interface EventHookExtended<T> extends EventHook<T> {
/** true if any user listeners are registered (emitter ignored) */
hasListeners: () => boolean 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> { 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 const setEmitter = (fn: Handler) => {
emitter = fn
if (defaultHandler) {
hasDefaultHandler = true
fns.add(defaultHandler)
} }
const off = (fn: (param: T) => void) => { const removeEmitter = () => {
fns.delete(fn) emitter = noop
} }
const on = (fn: (param: T) => void) => { const setHasEmitListeners = (fn: () => boolean) => {
if (defaultHandler && hasDefaultHandler) { hasEmitListeners = fn
fns.delete(defaultHandler) }
}
fns.add(fn) const removeHasEmitListeners = () => {
hasEmitListeners = () => false
}
const offFn = () => { const off = (fn: Handler) => {
off(fn) listeners.delete(fn)
}
if (defaultHandler && hasDefaultHandler) { const on = (fn: Handler) => {
fns.add(defaultHandler) listeners.add(fn)
}
}
const offFn = () => off(fn)
tryOnScopeDispose(offFn) tryOnScopeDispose(offFn)
return { return { off: offFn }
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) => { 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 { return {
@@ -58,6 +80,10 @@ export function createExtendedEventHook<T = any>(defaultHandler?: (param: T) =>
off, off,
trigger, trigger,
hasListeners, hasListeners,
fns, listeners,
setEmitter,
removeEmitter,
setHasEmitListeners,
removeHasEmitListeners,
} }
} }