diff --git a/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue b/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue index d1c52c6dc5a..ea6cc68c59e 100644 --- a/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue +++ b/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue @@ -201,6 +201,7 @@ import { isEqual } from 'lodash-es'; import { Button, Dropdown, DropdownMenu, DropdownItem, Stack } from '@/components/ui'; import ItemActions from '@/components/actions/ItemActions.vue'; import useCheckerboard from '@/composables/checkerboard.js'; +import { dedupeInFlight } from '@/util/dedupeInFlight.js'; export default { components: { @@ -496,14 +497,16 @@ export default { this.loading = true; - this.$axios - .post(cp_url('assets-fieldtype'), { - assets, - }) - .then((response) => { - this.assets = response.data; - this.loading = false; - }); + const cacheKey = JSON.stringify([...assets].slice().sort()); + + dedupeInFlight('assets-fieldtype', cacheKey, () => + this.$axios.post(cp_url('assets-fieldtype'), { assets }), + ).then((response) => { + // Clone so mutations on one field's asset rows don't bleed into others + // sharing the same in-flight response. + this.assets = clone(response.data); + this.loading = false; + }); }, /** diff --git a/resources/js/components/fieldtypes/bard/Image.vue b/resources/js/components/fieldtypes/bard/Image.vue index 0dead0b49ed..5f6fc51cab7 100644 --- a/resources/js/components/fieldtypes/bard/Image.vue +++ b/resources/js/components/fieldtypes/bard/Image.vue @@ -71,6 +71,7 @@ import { NodeViewWrapper } from '@tiptap/vue-3'; import Selector from '../../assets/Selector.vue'; import { Input, Button, Stack } from '@ui'; import { containerContextKey } from '@/components/ui/Publish/Container.vue'; +import { dedupeInFlight } from '@/util/dedupeInFlight.js'; export default { mixins: [Asset], @@ -184,13 +185,13 @@ export default { return; } - this.$axios - .post(cp_url('assets-fieldtype'), { - assets: [id], - }) - .then((response) => { - this.setAsset(response.data[0]); - }); + const cacheKey = JSON.stringify([id]); + + dedupeInFlight('assets-fieldtype', cacheKey, () => + this.$axios.post(cp_url('assets-fieldtype'), { assets: [id] }), + ).then((response) => { + this.setAsset(response.data[0]); + }); }, setAsset(asset) { diff --git a/resources/js/components/fieldtypes/markdown/MarkdownFieldtype.vue b/resources/js/components/fieldtypes/markdown/MarkdownFieldtype.vue index e2b6adb043c..c140ecfd078 100644 --- a/resources/js/components/fieldtypes/markdown/MarkdownFieldtype.vue +++ b/resources/js/components/fieldtypes/markdown/MarkdownFieldtype.vue @@ -186,6 +186,7 @@ import Uploader from '../../assets/Uploader.vue'; import Uploads from '../../assets/Uploads.vue'; import MarkdownToolbar from './MarkdownToolbar.vue'; import { useContentDirection } from '@/composables/content-direction'; +import { dedupeInFlight } from '@/util/dedupeInFlight.js'; // Keymaps import 'codemirror/keymap/sublime'; @@ -587,8 +588,12 @@ export default { this.closeAssetSelector(); this.selectedAssets = []; - this.$axios.post(cp_url('assets-fieldtype'), { assets }).then(({ data }) => { - data.forEach(asset => { + const cacheKey = JSON.stringify([...assets].slice().sort()); + + dedupeInFlight('assets-fieldtype', cacheKey, () => + this.$axios.post(cp_url('assets-fieldtype'), { assets }), + ).then(({ data }) => { + data.forEach((asset) => { const alt = asset.values.alt || ''; const url = encodeURI(`statamic://${asset.reference}`); const method = assets.length === 1 ? 'insert' : 'append'; diff --git a/resources/js/tests/dedupeInFlight.test.js b/resources/js/tests/dedupeInFlight.test.js new file mode 100644 index 00000000000..02a3ddbb189 --- /dev/null +++ b/resources/js/tests/dedupeInFlight.test.js @@ -0,0 +1,71 @@ +import { describe, test, expect, vi } from 'vitest'; +import { dedupeInFlight } from '../util/dedupeInFlight'; + +function deferred() { + let resolve, reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('dedupeInFlight', () => { + test('calls the factory once for concurrent callers sharing a key', async () => { + const d = deferred(); + const factory = vi.fn(() => d.promise); + + const a = dedupeInFlight('ns', 'key', factory); + const b = dedupeInFlight('ns', 'key', factory); + + expect(factory).toHaveBeenCalledTimes(1); + + d.resolve('value'); + + expect(await a).toBe('value'); + expect(await b).toBe('value'); + }); + + test('calls the factory immediately rather than deferring it', () => { + const factory = vi.fn(() => Promise.resolve()); + + dedupeInFlight('ns', 'sync', factory); + + expect(factory).toHaveBeenCalledTimes(1); + }); + + test('does not share between different keys or namespaces', async () => { + const factory = vi.fn(() => Promise.resolve()); + + dedupeInFlight('ns', 'one', factory); + dedupeInFlight('ns', 'two', factory); + dedupeInFlight('other', 'one', factory); + + expect(factory).toHaveBeenCalledTimes(3); + }); + + test('releases the entry once settled so a later call fetches fresh', async () => { + const factory = vi.fn(() => Promise.resolve('value')); + + await dedupeInFlight('ns', 'settled', factory); + await dedupeInFlight('ns', 'settled', factory); + + expect(factory).toHaveBeenCalledTimes(2); + }); + + test('rejects every caller when the shared work fails, and releases the entry', async () => { + const d = deferred(); + const failing = vi.fn(() => d.promise); + + const a = dedupeInFlight('ns', 'failure', failing); + const b = dedupeInFlight('ns', 'failure', failing); + + d.reject(new Error('nope')); + + await expect(a).rejects.toThrow('nope'); + await expect(b).rejects.toThrow('nope'); + + await dedupeInFlight('ns', 'failure', () => Promise.resolve('ok')); + expect(failing).toHaveBeenCalledTimes(1); + }); +}); diff --git a/resources/js/util/dedupeInFlight.js b/resources/js/util/dedupeInFlight.js new file mode 100644 index 00000000000..160ed23161a --- /dev/null +++ b/resources/js/util/dedupeInFlight.js @@ -0,0 +1,36 @@ +const namespaces = new Map(); + +/** + * Share identical in-flight async work across callers within a namespace. + * Once the promise settles the entry is removed — this is not a settled-result cache. + * + * The factory is invoked synchronously so callers that assert immediately after + * kicking off the request still see the underlying work start. + * + * @param {string} namespace + * @param {string} key + * @param {() => Promise} factory + * @returns {Promise} + */ +export function dedupeInFlight(namespace, key, factory) { + let map = namespaces.get(namespace); + + if (!map) { + map = new Map(); + namespaces.set(namespace, map); + } + + let entry = map.get(key); + + if (entry) return entry; + + entry = Promise.resolve(factory()).finally(() => { + if (map.get(key) === entry) { + map.delete(key); + } + }); + + map.set(key, entry); + + return entry; +}