Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/dedupe-cancel-first-tracking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'houdini': patch
---

Fix `@dedupe(cancelFirst: true)` not cancelling in-flight requests after the first cancellation.
44 changes: 42 additions & 2 deletions packages/houdini/src/runtime/documentStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ArtifactKind } from '../lib/types.js'
import { setMockConfig, getCurrentConfig } from './config.js'
import type { ClientPlugin } from './documentStore.js'
import { DocumentStore } from './documentStore.js'
import { DataSource } from './types.js'
import { DataSource, type QueryArtifact } from './types.js'

beforeEach(() => {
setMockConfig({
Expand Down Expand Up @@ -1205,9 +1205,48 @@ test('plugins can kick off a brand new request through ctx.documentStore', async
)
})

// a cancel: 'first' replacement takes over the dedupe entry of the request it aborted,
// so the aborted request settling must not untrack the replacement
test('dedupe cancels the request that replaced an earlier cancelled one', async () => {
const requests: { signal: AbortSignal; finish: () => void }[] = []
const fakeFetch: ClientPlugin = () => ({
network(ctx, { resolve }) {
const finish = () =>
resolve(ctx, {
data: { id: ctx.variables?.id },
errors: null,
fetching: false,
partial: false,
stale: false,
source: DataSource.Network,
variables: ctx.variables,
})
requests.push({ signal: ctx.abortController.signal, finish })
// settle aborted requests so their cleanup runs, like a real fetch rejecting
ctx.abortController.signal.addEventListener('abort', finish, { once: true })
},
})

const store = createStore([fakeFetch], undefined, { cancel: 'first', match: 'Operation' })

const first = store.send({ variables: { id: 1 } })
const second = store.send({ variables: { id: 2 } })
expect(requests[0].signal.aborted).toBe(true)
await first

const third = store.send({ variables: { id: 3 } })
expect(requests[1].signal.aborted).toBe(true)
await second

expect(requests[2].signal.aborted).toBe(false)
requests[2].finish()
await expect(third).resolves.toMatchObject({ data: { id: 3 } })
})

export function createStore(
plugins: ClientPlugin[],
fetching: boolean | undefined = undefined
fetching: boolean | undefined = undefined,
dedupe?: QueryArtifact['dedupe']
): DocumentStore<GraphQLObject, Record<string, any>> {
const client = new HoudiniClient({
url: 'URL',
Expand Down Expand Up @@ -1235,6 +1274,7 @@ export function createStore(
runtimeScalars: {},
},
pluginData: {},
dedupe,
},
// turn off the cache since we aren't pushing actual graphql documents through by default
cache: undefined,
Expand Down
14 changes: 9 additions & 5 deletions packages/houdini/src/runtime/documentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,17 +144,17 @@ export class DocumentStore<
silenceEcho = false,
abortController = new AbortController(),
}: SendParams = {}) {
// if we are matching on variables then we should use that for the controller key, otherwise
// just use an empty object
const dedupeKey = this.controllerKey(variables)

// if the document we are sending is meant to be deduped, then we need to look for an existing
// controller for the document
if (
'dedupe' in this.artifact &&
this.artifact.dedupe &&
this.artifact.dedupe.match !== 'None'
) {
// if we are matching on variables then we should use that for the controller key, otherwise
// just use an empty object
const dedupeKey = this.controllerKey(variables)

// if there is already a live pending request
const existingRequest = inflightRequests[dedupeKey]
if (existingRequest && !existingRequest.controller.signal.aborted) {
Expand Down Expand Up @@ -241,7 +241,11 @@ export class DocumentStore<
try {
return await promise
} finally {
delete inflightRequests[this.controllerKey(variables)]
// another request may own this entry by now, and removing it would stop the
// next send from aborting that request
if (inflightRequests[dedupeKey]?.controller === abortController) {
delete inflightRequests[dedupeKey]
}
}
}

Expand Down
Loading