From 724bbddd1b14d8046378b6dfb9c5b6687dc05517 Mon Sep 17 00:00:00 2001 From: hell Date: Mon, 26 Oct 2026 10:51:47 +0700 Subject: [PATCH] fix: create ParticipantSelect's debounced loader before mount v-autocomplete syncs its search model while it mounts, which runs the `search` watcher before the parent's onMounted hook. The debounced loader was only created in onMounted, so the watcher called null and the console showed "debouncedGetIndividualData is not a function" (minified: "l is not a function") every time a participant select opened. Create the debounced function in setup; onMounted keeps the initial load. Regression test mounts the component and asserts no error escapes and the individuals API is queried. Introduced upstream in Netflix/dispatch#3925. Co-Authored-By: Claude Fable 5.1 --- .../src/components/ParticipantSelect.vue | 7 ++-- ...participantSelectSearchBeforeMount.spec.js | 39 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 src/dispatch/static/dispatch/src/tests/participantSelectSearchBeforeMount.spec.js diff --git a/src/dispatch/static/dispatch/src/components/ParticipantSelect.vue b/src/dispatch/static/dispatch/src/components/ParticipantSelect.vue index 91c22dd199a6..3e5e879ad18e 100644 --- a/src/dispatch/static/dispatch/src/components/ParticipantSelect.vue +++ b/src/dispatch/static/dispatch/src/components/ParticipantSelect.vue @@ -73,8 +73,6 @@ export default { let total = ref(0) const search = ref(props.initialValue.name) - let debouncedGetIndividualData = null - const getIndividualData = async (searchVal, page = currentPage.value) => { loading.value = true let filterOptions = { @@ -115,8 +113,11 @@ export default { loading.value = false } + // Created here, not in onMounted: v-autocomplete syncs its search model while + // it mounts, so the watcher below can run before the parent's onMounted hook. + const debouncedGetIndividualData = debounce(getIndividualData, 300) + onMounted(() => { - debouncedGetIndividualData = debounce(getIndividualData, 300) debouncedGetIndividualData(search.value) }) diff --git a/src/dispatch/static/dispatch/src/tests/participantSelectSearchBeforeMount.spec.js b/src/dispatch/static/dispatch/src/tests/participantSelectSearchBeforeMount.spec.js new file mode 100644 index 000000000000..c437d7d371b8 --- /dev/null +++ b/src/dispatch/static/dispatch/src/tests/participantSelectSearchBeforeMount.spec.js @@ -0,0 +1,39 @@ +import { flushPromises, mount } from "@vue/test-utils" +import { afterEach, beforeEach, expect, test, vi } from "vitest" +import { createVuetify } from "vuetify" +import * as components from "vuetify/components" +import * as directives from "vuetify/directives" + +const getAll = vi.fn(() => Promise.resolve({ data: { items: [], total: 0 } })) +vi.mock("@/individual/api", () => ({ default: { getAll: (...args) => getAll(...args) } })) + +import ParticipantSelect from "@/components/ParticipantSelect.vue" + +const vuetify = createVuetify({ components, directives }) + +beforeEach(() => { + vi.useFakeTimers() + getAll.mockClear() +}) +afterEach(() => vi.useRealTimers()) + +test("a search change before onMounted still queries individuals instead of throwing", async () => { + const errors = [] + const onRejection = (event) => errors.push(event.reason) + window.addEventListener("unhandledrejection", onRejection) + + // v-autocomplete syncs its search model while it mounts, which is before the + // parent's onMounted hook: the debounced loader must already exist by then. + const wrapper = mount(ParticipantSelect, { + props: { modelValue: [] }, + global: { plugins: [vuetify], config: { errorHandler: (e) => errors.push(e) } }, + }) + await flushPromises() + vi.advanceTimersByTime(300) + await flushPromises() + window.removeEventListener("unhandledrejection", onRejection) + + expect(errors.map(String)).toEqual([]) + expect(getAll).toHaveBeenCalled() + wrapper.unmount() +})