Skip to content

Commit d52fa38

Browse files
committed
fix(vue): useSuspense() works when args change while initial promise is still resolving
1 parent 733091f commit d52fa38

File tree

3 files changed

+73
-4
lines changed

3 files changed

+73
-4
lines changed

.changeset/clever-geckos-smash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@data-client/vue': patch
3+
---
4+
5+
Fixed race condition in useSuspense() where args change while initial suspense is not complete

packages/vue/src/__tests__/useSuspense.web.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import { Endpoint } from '@data-client/endpoint';
12
import nock from 'nock';
23
import { computed, defineComponent, h, nextTick, reactive } from 'vue';
34

4-
import { CoolerArticleResource } from '../../../../__tests__/new';
5+
import {
6+
CoolerArticleResource,
7+
CoolerArticle,
8+
} from '../../../../__tests__/new';
59
import useSuspense from '../consumers/useSuspense';
610
import { renderDataCompose, mountDataClient } from '../test';
711

@@ -289,4 +293,64 @@ describe('vue useSuspense()', () => {
289293

290294
cleanup();
291295
});
296+
297+
it('should initiate second fetch when props change even if first promise never resolves', async () => {
298+
let fetchInitialCalled = false;
299+
let fetchFinalCalled = false;
300+
let resolveInitial: ((value: any) => void) | undefined;
301+
let resolveFinal: ((value: any) => void) | undefined;
302+
303+
// Create custom endpoint with controllable promises
304+
const ControlledEndpoint = new Endpoint(
305+
({ id }: { id: number }) => {
306+
if (id === payload.id) {
307+
fetchInitialCalled = true;
308+
// Initial fetch - manually controlled
309+
return new Promise(resolve => {
310+
resolveInitial = resolve;
311+
});
312+
} else if (id === payload2.id) {
313+
fetchFinalCalled = true;
314+
// Final fetch - manually controlled
315+
return new Promise(resolve => {
316+
resolveFinal = resolve;
317+
});
318+
}
319+
throw new Error(`Unexpected id: ${id}`);
320+
},
321+
{ schema: CoolerArticle, name: 'ControlledEndpoint' },
322+
);
323+
324+
const props = reactive({ id: payload.id });
325+
326+
// Start the composable (don't await yet since initial fetch needs manual resolution)
327+
const setupPromise = renderDataCompose(
328+
(props: { id: number }) =>
329+
useSuspense(
330+
ControlledEndpoint,
331+
computed(() => ({ id: props.id })),
332+
),
333+
{ props },
334+
);
335+
336+
// Wait for initial fetch to be called
337+
expect(fetchInitialCalled).toBe(true);
338+
339+
props.id = payload2.id;
340+
await nextTick();
341+
342+
resolveFinal?.(payload2);
343+
344+
// Resolve the initial fetch so renderDataCompose completes
345+
resolveInitial?.(payload);
346+
347+
const { result, cleanup } = await setupPromise;
348+
const articleRef = await result;
349+
350+
// The data should now be from the final fetch (payload2)
351+
expect(articleRef.value?.title).toBe(payload2.title);
352+
expect(articleRef.value?.content).toBe(payload2.content);
353+
354+
cleanup();
355+
});
292356
});

packages/vue/src/consumers/useSuspense.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,6 @@ export default async function useSuspense(
9191
await controller.fetch(endpoint, ...resolvedArgs.value);
9292
};
9393

94-
// Trigger on initial call
95-
await maybeFetch();
96-
9794
// Watch for changes to key, expiry, or store state that require refetch
9895
watch(
9996
() => {
@@ -120,6 +117,9 @@ export default async function useSuspense(
120117
{ immediate: true },
121118
);
122119

120+
// Trigger on initial call
121+
await maybeFetch();
122+
123123
// Return readonly computed ref - Vue automatically unwraps in templates and reactive contexts
124124
return readonly(computed(() => responseMeta.value.data));
125125
}

0 commit comments

Comments
 (0)