diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71ea0984..90e089b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,3 +100,6 @@ jobs: - name: Vitest run: npm test + + - name: Smoke tests + run: npm run test:smoke diff --git a/vueManager/package.json b/vueManager/package.json index 2213434d..956e92da 100644 --- a/vueManager/package.json +++ b/vueManager/package.json @@ -11,6 +11,7 @@ "preview": "vite preview", "lint": "eslint . --fix", "lint:ci": "eslint . --max-warnings 0", + "test:smoke": "node --test tests/*.test.js", "format": "prettier --write \"src/**/*.{js,vue,scss,css}\"", "format:check": "prettier --check \"src/**/*.{js,vue,scss,css}\"", "lint:all": "npm run lint && npm run format:check && npm run lint:scss", diff --git a/vueManager/src/components/CategoryProductsGrid.vue b/vueManager/src/components/CategoryProductsGrid.vue index 8eeaa88e..3af2b20c 100644 --- a/vueManager/src/components/CategoryProductsGrid.vue +++ b/vueManager/src/components/CategoryProductsGrid.vue @@ -16,6 +16,7 @@ import draggable from 'vuedraggable' import { useCategoryProductsInlineEdit } from '../composables/useCategoryProductsInlineEdit.js' import { useSelection } from '../composables/useSelection.js' +import { useStaleRequestGuard } from '../composables/useStaleRequestGuard.js' import { GridColumnEditorType, isSelectLikeEditorType, @@ -58,6 +59,7 @@ const { const columns = ref([]) const filters = ref({}) +const { runGuarded } = useStaleRequestGuard() const loading = ref(false) const products = ref([]) const totalRecords = ref(0) @@ -128,43 +130,51 @@ function nestedMutationParams() { * Load products list */ async function loadProducts() { - loading.value = true - try { - const params = { - start: first.value, - limit: rows.value, - sort: sortField.value, - dir: sortOrder.value === 1 ? 'ASC' : 'DESC', - nested: nested.value ? 1 : 0, - } + await runGuarded(loading, async (signal, isCurrent) => { + const params = { + start: first.value, + limit: rows.value, + sort: sortField.value, + dir: sortOrder.value === 1 ? 'ASC' : 'DESC', + nested: nested.value ? 1 : 0, + } + + // Apply filter values. Option-type columns are JOIN-ed at runtime — backend + // reads their filters as `filter_{fieldName}` (see CategoryProductsListService). + // Builtin product/data filters keep the original direct-param contract. + Object.keys(filterValues.value).forEach(key => { + const value = filterValues.value[key] + if (value === null || value === undefined || value === '') { + return + } + const col = columns.value.find(c => c.name === key) + if (col && col.type === 'option') { + params[`filter_${key}`] = value + } else { + params[key] = value + } + }) + + const response = await request.get( + `/api/mgr/categories/${props.categoryId}/products`, + params, + { signal } + ) - // Apply filter values. Option-type columns are JOIN-ed at runtime — backend - // reads their filters as `filter_{fieldName}` (see CategoryProductsListService). - // Builtin product/data filters keep the original direct-param contract. - Object.keys(filterValues.value).forEach(key => { - const value = filterValues.value[key] - if (value === null || value === undefined || value === '') { + if (!isCurrent()) { return } - const col = columns.value.find(c => c.name === key) - if (col && col.type === 'option') { - params[`filter_${key}`] = value + + if (response && response.results) { + products.value = response.results + totalRecords.value = response.total || 0 } else { - params[key] = value + console.error('[CategoryProductsGrid] Invalid response:', response) + products.value = [] + totalRecords.value = 0 } }) - - const response = await request.get(`/api/mgr/categories/${props.categoryId}/products`, params) - - if (response && response.results) { - products.value = response.results - totalRecords.value = response.total || 0 - } else { - console.error('[CategoryProductsGrid] Invalid response:', response) - products.value = [] - totalRecords.value = 0 - } } catch (error) { console.error('[CategoryProductsGrid] Error loading products:', error) toast.add({ @@ -173,8 +183,6 @@ async function loadProducts() { detail: error.message || _('error_loading_data'), life: 5000, }) - } finally { - loading.value = false } } diff --git a/vueManager/src/components/OrdersGrid.vue b/vueManager/src/components/OrdersGrid.vue index fdfb8301..8e7b1675 100644 --- a/vueManager/src/components/OrdersGrid.vue +++ b/vueManager/src/components/OrdersGrid.vue @@ -16,6 +16,7 @@ import { computed, onMounted, ref } from 'vue' import { useGridFilterParams } from '../composables/useGridFilterParams.js' import { useSelection } from '../composables/useSelection.js' +import { useStaleRequestGuard } from '../composables/useStaleRequestGuard.js' import request from '../request.js' import { formatLocalDateYmd } from '../utils/formatLocalDateYmd.js' import ActionsColumn from './ActionsColumn.vue' @@ -60,6 +61,7 @@ const { const columns = ref([]) const filters = ref({}) const { setDirectFilterKeys, addFilterParam } = useGridFilterParams() +const { runGuarded } = useStaleRequestGuard() const loading = ref(false) const orders = ref([]) const totalRecords = ref(0) @@ -86,58 +88,62 @@ const sortedFilters = computed(() => { * Load orders list */ async function loadOrders() { - loading.value = true - try { - const params = { - start: first.value, - limit: rows.value, - sort: sortField.value, - dir: sortOrder.value === 1 ? 'ASC' : 'DESC', - show_drafts: showDrafts.value ? 1 : 0, - } + await runGuarded(loading, async (signal, isCurrent) => { + const params = { + start: first.value, + limit: rows.value, + sort: sortField.value, + dir: sortOrder.value === 1 ? 'ASC' : 'DESC', + show_drafts: showDrafts.value ? 1 : 0, + } - // Apply filter values - Object.keys(filterValues.value).forEach(key => { - const value = filterValues.value[key] - if (value !== null && value !== undefined && value !== '') { - const filterConfig = filters.value[key] - if (filterConfig?.type === 'daterange' && Array.isArray(value)) { - if (value[0]) { - addFilterParam( - params, - filterConfig.fields?.from || `${key}_from`, - formatLocalDateYmd(value[0]) - ) + // Apply filter values + Object.keys(filterValues.value).forEach(key => { + const value = filterValues.value[key] + if (value !== null && value !== undefined && value !== '') { + const filterConfig = filters.value[key] + if (filterConfig?.type === 'daterange' && Array.isArray(value)) { + if (value[0]) { + addFilterParam( + params, + filterConfig.fields?.from || `${key}_from`, + formatLocalDateYmd(value[0]) + ) + } + if (value[1]) { + addFilterParam( + params, + filterConfig.fields?.to || `${key}_to`, + formatLocalDateYmd(value[1]) + ) + } + } else if (filterConfig?.type === 'datepicker' && value) { + addFilterParam(params, key, formatLocalDateYmd(value)) + } else { + addFilterParam(params, key, value) } - if (value[1]) { - addFilterParam( - params, - filterConfig.fields?.to || `${key}_to`, - formatLocalDateYmd(value[1]) - ) - } - } else if (filterConfig?.type === 'datepicker' && value) { - addFilterParam(params, key, formatLocalDateYmd(value)) - } else { - addFilterParam(params, key, value) } - } - }) + }) - const response = await request.get('/api/mgr/orders', params) + const response = await request.get('/api/mgr/orders', params, { signal }) - if (response && response.results) { - orders.value = response.results - totalRecords.value = response.total || 0 - if (response.stats) { - stats.value = response.stats + if (!isCurrent()) { + return } - } else { - console.error('[OrdersGrid] Invalid response:', response) - orders.value = [] - totalRecords.value = 0 - } + + if (response && response.results) { + orders.value = response.results + totalRecords.value = response.total || 0 + if (response.stats) { + stats.value = response.stats + } + } else { + console.error('[OrdersGrid] Invalid response:', response) + orders.value = [] + totalRecords.value = 0 + } + }) } catch (error) { console.error('[OrdersGrid] Error loading orders:', error) toast.add({ @@ -146,8 +152,6 @@ async function loadOrders() { detail: error.message || _('error_loading_data'), life: 5000, }) - } finally { - loading.value = false } } diff --git a/vueManager/src/composables/useStaleRequestGuard.js b/vueManager/src/composables/useStaleRequestGuard.js new file mode 100644 index 00000000..2cc20cf6 --- /dev/null +++ b/vueManager/src/composables/useStaleRequestGuard.js @@ -0,0 +1,61 @@ +import { onScopeDispose } from 'vue' + +/** + * Guards async list loads against stale responses when pagination/filters change quickly (#385). + * + * Each beginLoad() aborts the previous in-flight fetch and bumps a sequence id. + * Prefer runGuarded() in grid loaders to centralize loading/abort/stale handling. + */ +export function useStaleRequestGuard() { + let loadSeq = 0 + /** @type {AbortController | null} */ + let abortController = null + + onScopeDispose(() => { + abortController?.abort() + abortController = null + }) + + /** + * @returns {{ seq: number, signal: AbortSignal, isCurrent: () => boolean }} + */ + function beginLoad() { + abortController?.abort() + abortController = new AbortController() + const seq = ++loadSeq + + return { + seq, + signal: abortController.signal, + isCurrent: () => seq === loadSeq, + } + } + + function isAbortError(error) { + return error?.name === 'AbortError' + } + + /** + * @param {import('vue').Ref} loadingRef + * @param {(signal: AbortSignal, isCurrent: () => boolean) => Promise} task + */ + async function runGuarded(loadingRef, task) { + const load = beginLoad() + loadingRef.value = true + + try { + await task(load.signal, load.isCurrent) + } catch (error) { + if (isAbortError(error) || !load.isCurrent()) { + return + } + throw error + } finally { + if (load.isCurrent()) { + loadingRef.value = false + } + } + } + + return { beginLoad, isAbortError, runGuarded } +} diff --git a/vueManager/src/request.js b/vueManager/src/request.js index f2f10365..d9aab741 100644 --- a/vueManager/src/request.js +++ b/vueManager/src/request.js @@ -94,6 +94,10 @@ class Request { credentials: 'same-origin', } + if (options.signal) { + fetchOptions.signal = options.signal + } + let url if (method === 'GET' && data) { @@ -144,6 +148,10 @@ class Request { throw error } + if (error?.name === 'AbortError') { + throw error + } + throw new RequestError(error.message || 'Network error', 0, { originalError: error }) } } diff --git a/vueManager/tests/useStaleRequestGuard.test.js b/vueManager/tests/useStaleRequestGuard.test.js new file mode 100644 index 00000000..a660721c --- /dev/null +++ b/vueManager/tests/useStaleRequestGuard.test.js @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { effectScope, ref } from 'vue' + +import { useStaleRequestGuard } from '../src/composables/useStaleRequestGuard.js' + +test('beginLoad aborts the previous request signal', () => { + const scope = effectScope() + scope.run(() => { + const { beginLoad } = useStaleRequestGuard() + const first = beginLoad() + const second = beginLoad() + + assert.equal(first.signal.aborted, true) + assert.equal(second.signal.aborted, false) + }) + scope.stop() +}) + +test('isCurrent is false after a newer beginLoad', () => { + const scope = effectScope() + scope.run(() => { + const { beginLoad } = useStaleRequestGuard() + const first = beginLoad() + beginLoad() + + assert.equal(first.isCurrent(), false) + }) + scope.stop() +}) + +test('scope dispose aborts in-flight signal', () => { + const scope = effectScope() + let load + + scope.run(() => { + const { beginLoad } = useStaleRequestGuard() + load = beginLoad() + }) + + scope.stop() + assert.equal(load.signal.aborted, true) +}) + +test('runGuarded clears loading only for the latest load', async () => { + const scope = effectScope() + + await scope.run(async () => { + const { runGuarded } = useStaleRequestGuard() + const loading = ref(false) + let resolveFirst + const firstBlocked = new Promise(resolve => { + resolveFirst = resolve + }) + + const firstRun = runGuarded(loading, async signal => { + await firstBlocked + assert.equal(signal.aborted, true) + }) + + const secondRun = runGuarded(loading, async () => { + await Promise.resolve() + }) + + resolveFirst() + await firstRun + await secondRun + + assert.equal(loading.value, false) + }) + + scope.stop() +}) + +test('runGuarded rethrows non-abort errors', async () => { + const scope = effectScope() + + await scope.run(async () => { + const { runGuarded } = useStaleRequestGuard() + const loading = ref(false) + + await assert.rejects( + () => + runGuarded(loading, async () => { + throw new Error('boom') + }), + /boom/ + ) + + assert.equal(loading.value, false) + }) + + scope.stop() +}) + +test('isAbortError detects AbortError', () => { + const scope = effectScope() + scope.run(() => { + const { isAbortError } = useStaleRequestGuard() + + assert.equal(isAbortError({ name: 'AbortError' }), true) + assert.equal(isAbortError(new Error('fail')), false) + }) + scope.stop() +})