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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<METHOD>:<resolvedPath>`, 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:<METHOD>:<resolvedPath>`, 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.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
23 changes: 18 additions & 5 deletions src/openapi-mutation.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -119,12 +119,20 @@ 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
// `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<boolean | string | undefined>)
// 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}'. ` +
Expand All @@ -133,7 +141,12 @@ export function useEndpointMutation<
}
const serializeScope =
!explicitScope && serializeEnabled
? { id: typeof serialize === 'string' ? serialize : `serialize:${config.method}:${resolvedPath.value}` }
? computed(() => ({
id:
typeof serializeValue === 'string'
? serializeValue
: `serialize:${config.method}:${resolvePath(config.path, toValue(resolvedPathParamsInput) || {})}`,
}))
Comment thread
zedrdave marked this conversation as resolved.
: undefined

const mutation = useMutation(
Expand Down
47 changes: 46 additions & 1 deletion tests/unit/mutation-serialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
Loading