From 7b471864e8d47e80c48504b8f3f8e9c3ad061062 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 1 May 2026 10:08:25 -0400 Subject: [PATCH 1/4] Deduplicate RelationshipInput in-flight item-data requests Co-authored-by: Waldemar Pankratz --- .../inputs/relationship/RelationshipInput.vue | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/resources/js/components/inputs/relationship/RelationshipInput.vue b/resources/js/components/inputs/relationship/RelationshipInput.vue index cdcf943082f..a5df7e3d729 100644 --- a/resources/js/components/inputs/relationship/RelationshipInput.vue +++ b/resources/js/components/inputs/relationship/RelationshipInput.vue @@ -108,6 +108,8 @@ import { Button, Icon, Stack } from '@/components/ui'; import { router } from '@inertiajs/vue3'; import axios from 'axios'; +const inFlightRequests = new Map(); + export default { props: { canCreate: { type: Boolean }, @@ -246,7 +248,7 @@ export default { created() { this.removeNavigationListener = router.on('before', () => { - if (this.abortController) this.abortController.abort(); + if (this.abortController && this._ownsRequest) this.abortController.abort(); }); }, @@ -260,7 +262,7 @@ export default { }, beforeUnmount() { - if (this.abortController) this.abortController.abort(); + if (this.abortController && this._ownsRequest) this.abortController.abort(); if (this.removeNavigationListener) this.removeNavigationListener(); if (this.sortable) { this.sortable.destroy(); @@ -331,8 +333,18 @@ export default { if (this.abortController) this.abortController.abort(); this.abortController = new AbortController(); - return this.$axios + const cacheKey = this.itemDataUrl + '|' + (this.site || '') + '|' + JSON.stringify(selections?.slice().sort()); + const existing = inFlightRequests.get(cacheKey); + + this._ownsRequest = !existing; + + const request = existing ?? this.$axios .post(this.itemDataUrl, { site: this.site, selections }, { signal: this.abortController.signal }) + .finally(() => inFlightRequests.delete(cacheKey)); + + if (!existing) inFlightRequests.set(cacheKey, request); + + return request .then((response) => { this.$emit('item-data-updated', response.data.data); }) From 9b5d8a3c0d0e67995336db65819bc12f3b39ad03 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 1 May 2026 10:35:03 -0400 Subject: [PATCH 2/4] Use JSON-encoded cache key for RelationshipInput dedup --- .../js/components/inputs/relationship/RelationshipInput.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/components/inputs/relationship/RelationshipInput.vue b/resources/js/components/inputs/relationship/RelationshipInput.vue index a5df7e3d729..8572242535a 100644 --- a/resources/js/components/inputs/relationship/RelationshipInput.vue +++ b/resources/js/components/inputs/relationship/RelationshipInput.vue @@ -333,7 +333,7 @@ export default { if (this.abortController) this.abortController.abort(); this.abortController = new AbortController(); - const cacheKey = this.itemDataUrl + '|' + (this.site || '') + '|' + JSON.stringify(selections?.slice().sort()); + const cacheKey = JSON.stringify([this.itemDataUrl, this.site, selections?.slice().sort()]); const existing = inFlightRequests.get(cacheKey); this._ownsRequest = !existing; From f7ab35343f288f05f1da6768d948393a8cd23131 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 1 May 2026 10:35:06 -0400 Subject: [PATCH 3/4] Add tests for RelationshipInput request deduplication --- .../relationship/RelationshipInput.test.js | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 resources/js/tests/components/inputs/relationship/RelationshipInput.test.js diff --git a/resources/js/tests/components/inputs/relationship/RelationshipInput.test.js b/resources/js/tests/components/inputs/relationship/RelationshipInput.test.js new file mode 100644 index 00000000000..62e8b96c696 --- /dev/null +++ b/resources/js/tests/components/inputs/relationship/RelationshipInput.test.js @@ -0,0 +1,165 @@ +import { mount, flushPromises } from '@vue/test-utils'; +import { describe, expect, test, vi } from 'vitest'; + +vi.mock('@inertiajs/vue3', () => ({ + router: { on: () => () => {} }, +})); + +globalThis.__ = (key) => key; + +import RelationshipInput from '@/components/inputs/relationship/RelationshipInput.vue'; + +const stubs = { + RelationshipSelectField: true, + ItemSelector: true, + CreateButton: true, + RelatedItem: true, + Button: true, + Icon: true, + Stack: true, +}; + +function deferred() { + let resolve, reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function mountInput({ axiosPost, itemDataUrl, site = 'default' }) { + return mount(RelationshipInput, { + props: { + value: [], + data: [], + config: { type: 'entries' }, + itemDataUrl, + site, + selectionsUrl: '/api/selections', + filtersUrl: '/api/filters', + mode: 'default', + }, + global: { + mocks: { + $axios: { post: axiosPost }, + $progress: { loading: () => {} }, + }, + stubs, + }, + }); +} + +describe('RelationshipInput in-flight request deduplication', () => { + test('shares a single request across instances with the same selections', async () => { + const d = deferred(); + const post = vi.fn(() => d.promise); + + const a = mountInput({ axiosPost: post, itemDataUrl: '/test/dedup-same' }); + const b = mountInput({ axiosPost: post, itemDataUrl: '/test/dedup-same' }); + + a.vm.getDataForSelections(['1', '2']); + b.vm.getDataForSelections(['1', '2']); + + await flushPromises(); + expect(post).toHaveBeenCalledTimes(1); + + d.resolve({ data: { data: [{ id: '1' }, { id: '2' }] } }); + await flushPromises(); + + expect(a.emitted('item-data-updated')).toBeTruthy(); + expect(b.emitted('item-data-updated')).toBeTruthy(); + + a.unmount(); + b.unmount(); + }); + + test('cache key is order-insensitive across instances', async () => { + const d = deferred(); + const post = vi.fn(() => d.promise); + + const a = mountInput({ axiosPost: post, itemDataUrl: '/test/dedup-order' }); + const b = mountInput({ axiosPost: post, itemDataUrl: '/test/dedup-order' }); + + a.vm.getDataForSelections(['1', '2']); + b.vm.getDataForSelections(['2', '1']); + + await flushPromises(); + expect(post).toHaveBeenCalledTimes(1); + + d.resolve({ data: { data: [] } }); + await flushPromises(); + + a.unmount(); + b.unmount(); + }); + + test('different selections each fire their own request', async () => { + const d1 = deferred(); + const d2 = deferred(); + const post = vi.fn().mockReturnValueOnce(d1.promise).mockReturnValueOnce(d2.promise); + + const a = mountInput({ axiosPost: post, itemDataUrl: '/test/dedup-different' }); + const b = mountInput({ axiosPost: post, itemDataUrl: '/test/dedup-different' }); + + a.vm.getDataForSelections(['1']); + b.vm.getDataForSelections(['2']); + + await flushPromises(); + expect(post).toHaveBeenCalledTimes(2); + + d1.resolve({ data: { data: [] } }); + d2.resolve({ data: { data: [] } }); + await flushPromises(); + + a.unmount(); + b.unmount(); + }); + + test('different sites with the same selections each fire their own request', async () => { + const d1 = deferred(); + const d2 = deferred(); + const post = vi.fn().mockReturnValueOnce(d1.promise).mockReturnValueOnce(d2.promise); + + const a = mountInput({ axiosPost: post, itemDataUrl: '/test/dedup-sites', site: 'en' }); + const b = mountInput({ axiosPost: post, itemDataUrl: '/test/dedup-sites', site: 'fr' }); + + a.vm.getDataForSelections(['1']); + b.vm.getDataForSelections(['1']); + + await flushPromises(); + expect(post).toHaveBeenCalledTimes(2); + + d1.resolve({ data: { data: [] } }); + d2.resolve({ data: { data: [] } }); + await flushPromises(); + + a.unmount(); + b.unmount(); + }); + + test('cache entry clears after settle so a later identical request fires fresh', async () => { + const d1 = deferred(); + const d2 = deferred(); + const post = vi.fn().mockReturnValueOnce(d1.promise).mockReturnValueOnce(d2.promise); + + const a = mountInput({ axiosPost: post, itemDataUrl: '/test/dedup-cleanup' }); + a.vm.getDataForSelections(['1']); + await flushPromises(); + expect(post).toHaveBeenCalledTimes(1); + + d1.resolve({ data: { data: [] } }); + await flushPromises(); + + const b = mountInput({ axiosPost: post, itemDataUrl: '/test/dedup-cleanup' }); + b.vm.getDataForSelections(['1']); + await flushPromises(); + expect(post).toHaveBeenCalledTimes(2); + + d2.resolve({ data: { data: [] } }); + await flushPromises(); + + a.unmount(); + b.unmount(); + }); +}); From e158220f029dc55a4f2229efc468fec77ee93c82 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 1 May 2026 10:48:15 -0400 Subject: [PATCH 4/4] Keep RelationshipInput shared request alive while followers are waiting --- .../inputs/relationship/RelationshipInput.vue | 49 ++++++++---- .../relationship/RelationshipInput.test.js | 76 +++++++++++++++++++ 2 files changed, 111 insertions(+), 14 deletions(-) diff --git a/resources/js/components/inputs/relationship/RelationshipInput.vue b/resources/js/components/inputs/relationship/RelationshipInput.vue index 8572242535a..7d010f0c4eb 100644 --- a/resources/js/components/inputs/relationship/RelationshipInput.vue +++ b/resources/js/components/inputs/relationship/RelationshipInput.vue @@ -110,6 +110,18 @@ import axios from 'axios'; const inFlightRequests = new Map(); +function detachFromInFlightRequest(component) { + const entry = component._activeRequest; + if (!entry) return; + component._activeRequest = null; + entry.subscribers--; + if (entry.subscribers > 0) return; + entry.controller.abort(); + if (inFlightRequests.get(entry.cacheKey) === entry) { + inFlightRequests.delete(entry.cacheKey); + } +} + export default { props: { canCreate: { type: Boolean }, @@ -163,7 +175,6 @@ export default { loading: true, inline: false, sortable: null, - abortController: null, removeNavigationListener: null, }; }, @@ -248,7 +259,7 @@ export default { created() { this.removeNavigationListener = router.on('before', () => { - if (this.abortController && this._ownsRequest) this.abortController.abort(); + detachFromInFlightRequest(this); }); }, @@ -262,7 +273,7 @@ export default { }, beforeUnmount() { - if (this.abortController && this._ownsRequest) this.abortController.abort(); + detachFromInFlightRequest(this); if (this.removeNavigationListener) this.removeNavigationListener(); if (this.sortable) { this.sortable.destroy(); @@ -330,29 +341,39 @@ export default { getDataForSelections(selections) { this.loading = true; - if (this.abortController) this.abortController.abort(); - this.abortController = new AbortController(); + detachFromInFlightRequest(this); const cacheKey = JSON.stringify([this.itemDataUrl, this.site, selections?.slice().sort()]); - const existing = inFlightRequests.get(cacheKey); - - this._ownsRequest = !existing; - - const request = existing ?? this.$axios - .post(this.itemDataUrl, { site: this.site, selections }, { signal: this.abortController.signal }) - .finally(() => inFlightRequests.delete(cacheKey)); + let entry = inFlightRequests.get(cacheKey); + + if (!entry) { + const controller = new AbortController(); + entry = { cacheKey, controller, subscribers: 0 }; + entry.promise = this.$axios + .post(this.itemDataUrl, { site: this.site, selections }, { signal: controller.signal }) + .finally(() => { + if (inFlightRequests.get(cacheKey) === entry) { + inFlightRequests.delete(cacheKey); + } + }); + inFlightRequests.set(cacheKey, entry); + } - if (!existing) inFlightRequests.set(cacheKey, request); + entry.subscribers++; + this._activeRequest = entry; - return request + return entry.promise .then((response) => { + if (this._activeRequest !== entry) return; this.$emit('item-data-updated', response.data.data); }) .catch((e) => { if (axios.isCancel(e)) return; + if (this._activeRequest !== entry) return; throw e; }) .finally(() => { + if (this._activeRequest !== entry) return; this.loading = false; }); }, diff --git a/resources/js/tests/components/inputs/relationship/RelationshipInput.test.js b/resources/js/tests/components/inputs/relationship/RelationshipInput.test.js index 62e8b96c696..bc2eda8a64e 100644 --- a/resources/js/tests/components/inputs/relationship/RelationshipInput.test.js +++ b/resources/js/tests/components/inputs/relationship/RelationshipInput.test.js @@ -28,6 +28,23 @@ function deferred() { return { promise, resolve, reject }; } +function signalAwareDeferred() { + const d = deferred(); + return { + promise: d.promise, + resolve: d.resolve, + reject: d.reject, + attach(signal) { + if (!signal) return; + signal.addEventListener('abort', () => { + const err = new Error('canceled'); + err.__CANCEL__ = true; + d.reject(err); + }); + }, + }; +} + function mountInput({ axiosPost, itemDataUrl, site = 'default' }) { return mount(RelationshipInput, { props: { @@ -138,6 +155,65 @@ describe('RelationshipInput in-flight request deduplication', () => { b.unmount(); }); + test('leader changing selections mid-flight does not abort the shared request for followers', async () => { + const d1 = signalAwareDeferred(); + const d2 = signalAwareDeferred(); + const post = vi.fn((url, body, config) => { + const next = post.mock.calls.length === 1 ? d1 : d2; + next.attach(config?.signal); + return next.promise; + }); + + const a = mountInput({ axiosPost: post, itemDataUrl: '/test/leader-changes' }); + const b = mountInput({ axiosPost: post, itemDataUrl: '/test/leader-changes' }); + + a.vm.getDataForSelections(['1']); + b.vm.getDataForSelections(['1']); + await flushPromises(); + expect(post).toHaveBeenCalledTimes(1); + + a.vm.getDataForSelections(['2']); + await flushPromises(); + expect(post).toHaveBeenCalledTimes(2); + + d1.resolve({ data: { data: [{ id: '1' }] } }); + await flushPromises(); + expect(b.emitted('item-data-updated')).toBeTruthy(); + expect(b.emitted('item-data-updated')[0]).toEqual([[{ id: '1' }]]); + + d2.resolve({ data: { data: [{ id: '2' }] } }); + await flushPromises(); + expect(a.emitted('item-data-updated')).toBeTruthy(); + expect(a.emitted('item-data-updated').at(-1)).toEqual([[{ id: '2' }]]); + + a.unmount(); + b.unmount(); + }); + + test('aborts the shared request when the last subscriber detaches', async () => { + const d = signalAwareDeferred(); + const post = vi.fn((url, body, config) => { + d.attach(config?.signal); + return d.promise; + }); + + const a = mountInput({ axiosPost: post, itemDataUrl: '/test/abort-last' }); + const b = mountInput({ axiosPost: post, itemDataUrl: '/test/abort-last' }); + + a.vm.getDataForSelections(['1']); + b.vm.getDataForSelections(['1']); + await flushPromises(); + expect(post).toHaveBeenCalledTimes(1); + + a.unmount(); + await flushPromises(); + + b.unmount(); + await flushPromises(); + + await expect(d.promise).rejects.toMatchObject({ __CANCEL__: true }); + }); + test('cache entry clears after settle so a later identical request fires fresh', async () => { const d1 = deferred(); const d2 = deferred();