Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions resources/js/components/fieldtypes/assets/AssetsFieldtype.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
});
},

/**
Expand Down
15 changes: 8 additions & 7 deletions resources/js/components/fieldtypes/bard/Image.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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';
Expand Down
71 changes: 71 additions & 0 deletions resources/js/tests/dedupeInFlight.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
36 changes: 36 additions & 0 deletions resources/js/util/dedupeInFlight.js
Original file line number Diff line number Diff line change
@@ -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<any>} factory
* @returns {Promise<any>}
*/
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;
}
Loading