From ae9fb5c5c6f07260b02ebb11ba14f0284b92d634 Mon Sep 17 00:00:00 2001 From: Dave dV Date: Sat, 18 Jul 2026 12:58:45 +0200 Subject: [PATCH 1/2] fix: make serialize:true scope id track reactive hook-time path params The derived scope id was frozen at hook setup from resolvedPath.value. With reactive hook-time path params (ref/getter), a hook whose param changed kept the stale scope id, so two hooks converging on the same resource could run concurrently: exactly the last-write-wins race that serialize documents preventing. The scope is now a computed derived from hook-time params only (vue-query unrefs it per dispatch via cloneDeepUnref). Mutate-time extraPathParams are deliberately excluded so deferred params keep the documented template-fallback behaviour and never leak between calls. Regression test verified to fail against the previous implementation. --- CHANGELOG.md | 6 ++++ README.md | 2 +- package-lock.json | 4 +-- package.json | 2 +- src/openapi-mutation.ts | 17 +++++++--- tests/unit/mutation-serialize.test.ts | 47 ++++++++++++++++++++++++++- 6 files changed, 69 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23fbe45..65b1893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.23.1] - 2026-07-18 + +### Fixed + +- `serialize: true` scope id now tracks reactive hook-time path params. Previously the id was frozen at hook setup, so two hooks converging on the same resource after a reactive param change could run concurrently instead of queueing. + ## [0.23.0] - 2026-07-18 ### Added diff --git a/README.md b/README.md index 0825cac..dfe6b82 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ const updateStatus = api.updatePetStatus.useMutation({ petId: '1' }, { serialize const mutation = api.updatePet.useMutation({ petId: '1' }, { scope: { id: 'my-scope' } }) ``` -**`serialize: true` scope derivation:** the scope id is `serialize::`, e.g. `serialize:PATCH:/api/contract/123`. Two components mutating the same resource (same path params at hook time) share a queue automatically; different resources do not block each other. +**`serialize: true` scope derivation:** the scope id is `serialize::`, e.g. `serialize:PATCH:/api/contract/123`. Two components mutating the same resource (same path params at hook time) share a queue automatically; different resources do not block each other. The scope id is reactive: if hook-time path params are refs/getters and change, subsequent mutations use the updated scope. **Deferred path params caveat:** when path parameters are supplied at `mutateAsync` time rather than hook time, the scope id falls back to the path template (e.g. `serialize:PATCH:/api/contract/{contract_id}`), serialising all mutations of that operation regardless of target resource. For per-resource granularity, supply path parameters at hook-creation time or use a string scope. diff --git a/package-lock.json b/package-lock.json index c5d9d32..b017bb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qualisero/openapi-endpoint", - "version": "0.23.0", + "version": "0.23.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qualisero/openapi-endpoint", - "version": "0.23.0", + "version": "0.23.1", "license": "MIT", "bin": { "openapi-codegen": "bin/openapi-codegen.js" diff --git a/package.json b/package.json index 78793d4..81b06ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qualisero/openapi-endpoint", - "version": "0.23.0", + "version": "0.23.1", "repository": { "type": "git", "url": "https://github.com/qualisero/openapi-endpoint.git" diff --git a/src/openapi-mutation.ts b/src/openapi-mutation.ts index a28577d..ee73d8f 100644 --- a/src/openapi-mutation.ts +++ b/src/openapi-mutation.ts @@ -1,4 +1,4 @@ -import { computed, ref, type ComputedRef, type Ref } from 'vue' +import { computed, ref, toValue, type ComputedRef, type Ref } from 'vue' import type { MaybeRefOrGetter } from '@vue/reactivity' import { useMutation } from '@tanstack/vue-query' import { type AxiosResponse } from 'axios' @@ -119,8 +119,12 @@ export function useEndpointMutation< // Compute the serialization scope. Explicit caller `scope` (passed through // useMutationOptions) always takes priority. When `serialize` is set and no - // explicit scope is present, derive the scope id from the resolved path (or - // fall back to the path template when path params are still unresolved). + // explicit scope is present, derive the scope id from the path resolved with + // hook-time params (or fall back to the path template when path params are + // deferred to mutate time). The scope is a computed so reactive hook-time + // params keep the scope id in sync; it deliberately excludes mutate-time + // `extraPathParams` so one call's deferred params never leak into the scope + // of the next. const explicitScope = (useMutationOptions as { scope?: { id: string } }).scope // A string scope id is used verbatim, so any string (including '') enables // serialization; only `undefined`/`false` mean "not set". @@ -133,7 +137,12 @@ export function useEndpointMutation< } const serializeScope = !explicitScope && serializeEnabled - ? { id: typeof serialize === 'string' ? serialize : `serialize:${config.method}:${resolvedPath.value}` } + ? computed(() => ({ + id: + typeof serialize === 'string' + ? serialize + : `serialize:${config.method}:${resolvePath(config.path, toValue(resolvedPathParamsInput) || {})}`, + })) : undefined const mutation = useMutation( diff --git a/tests/unit/mutation-serialize.test.ts b/tests/unit/mutation-serialize.test.ts index 308b31a..7d7e1ca 100644 --- a/tests/unit/mutation-serialize.test.ts +++ b/tests/unit/mutation-serialize.test.ts @@ -10,7 +10,7 @@ * explicit `scope` wins over `serialize` (dev warning emitted when both set) */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { effectScope } from 'vue' +import { effectScope, nextTick, ref } from 'vue' import { createApiClient } from '../fixtures/api-client' import { createTestScope } from '../helpers' import { mockAxios } from '../setup' @@ -206,6 +206,51 @@ describe('serialize mutation option', () => { expect(secondStarted).toHaveBeenCalledTimes(1) }) + // --------------------------------------------------------------------------- + // (c3) Reactive hook-time path params keep the derived scope id in sync + // --------------------------------------------------------------------------- + it('reactive path params update the derived scope id — hooks converging on the same resource serialize', async () => { + // Hook A starts on pet '1', then its reactive param moves to pet '42'. + const petIdA = ref('1') + const mutationA = run(() => api.updatePet.useMutation(() => ({ petId: petIdA.value }), { serialize: true })) + // Hook B statically targets pet '42'. + const mutationB = run(() => api.updatePet.useMutation({ petId: '42' }, { serialize: true })) + + // Move hook A onto the same resource as hook B, let vue-query pick up options + petIdA.value = '42' + await nextTick() + + let resolveFirst!: () => void + const firstStarted = vi.fn() + const secondStarted = vi.fn() + + mockAxios.mockImplementationOnce(() => { + firstStarted() + return new Promise((r) => { + resolveFirst = () => r({ data: { id: '42', name: 'A' } }) + }) + }) + mockAxios.mockImplementationOnce(() => { + secondStarted() + return Promise.resolve({ data: { id: '42', name: 'B' } }) + }) + + const p1 = mutationA.mutateAsync({ data: { name: 'A' } }) + const p2 = mutationB.mutateAsync({ data: { name: 'B' } }) + + await new Promise((r) => setTimeout(r, 10)) + + // Both hooks now share scope `serialize:PUT:/pets/42`: B must queue behind A. + // Before the reactivity fix, A kept the stale `/pets/1` scope id and both + // ran concurrently (last-write-wins race). + expect(firstStarted).toHaveBeenCalledTimes(1) + expect(secondStarted).toHaveBeenCalledTimes(0) + + resolveFirst() + await Promise.all([p1, p2]) + expect(secondStarted).toHaveBeenCalledTimes(1) + }) + // --------------------------------------------------------------------------- // (d) Explicit scope + serialize → explicit scope wins, dev warning emitted once // --------------------------------------------------------------------------- From 321e5f75d7e0c119955e79d584565a21192e143e Mon Sep 17 00:00:00 2001 From: Dave dV Date: Sat, 18 Jul 2026 15:11:49 +0200 Subject: [PATCH 2/2] fix: defensively unwrap serialize option with toValue for untyped callers serialize is typed as plain boolean | string, but untyped JS callers could pass a ref/getter and get silently wrong behaviour (Ref(false) truthy -> enabled, Ref('group') non-string -> template scope id). Unwrap with toValue() before the enable check and scope-id derivation. --- src/openapi-mutation.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/openapi-mutation.ts b/src/openapi-mutation.ts index ee73d8f..cfbe165 100644 --- a/src/openapi-mutation.ts +++ b/src/openapi-mutation.ts @@ -126,9 +126,13 @@ export function useEndpointMutation< // `extraPathParams` so one call's deferred params never leak into the scope // of the next. const explicitScope = (useMutationOptions as { scope?: { id: string } }).scope + // `serialize` is typed as a plain `boolean | string`, but unwrap defensively + // with toValue() so untyped (JS) callers passing a ref/getter do not get + // silently wrong behaviour (e.g. Ref(false) treated as enabled). + const serializeValue = toValue(serialize as MaybeRefOrGetter) // A string scope id is used verbatim, so any string (including '') enables // serialization; only `undefined`/`false` mean "not set". - const serializeEnabled = serialize !== undefined && serialize !== false + const serializeEnabled = serializeValue !== undefined && serializeValue !== false if (serializeEnabled && explicitScope) { console.warn( `[openapi-endpoint] Both 'serialize' and 'scope' are set on mutation '${config.path}'. ` + @@ -139,8 +143,8 @@ export function useEndpointMutation< !explicitScope && serializeEnabled ? computed(() => ({ id: - typeof serialize === 'string' - ? serialize + typeof serializeValue === 'string' + ? serializeValue : `serialize:${config.method}:${resolvePath(config.path, toValue(resolvedPathParamsInput) || {})}`, })) : undefined