Skip to content

Commit a7b3be8

Browse files
committed
feat: useSubscription
1 parent e0d8c8f commit a7b3be8

File tree

3 files changed

+196
-0
lines changed

3 files changed

+196
-0
lines changed
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { mount } from '@vue/test-utils';
2+
import nock from 'nock';
3+
import { defineComponent, h, nextTick, Suspense } from 'vue';
4+
5+
// Endpoints/entities from React subscriptions test
6+
import {
7+
PollingArticleResource,
8+
ArticleResource,
9+
} from '../../../../__tests__/new';
10+
import useSubscription from '../consumers/useSubscription';
11+
import useSuspense from '../consumers/useSuspense';
12+
import { provideDataClient } from '../providers/provideDataClient';
13+
14+
describe('vue useSubscription()', () => {
15+
const payload = {
16+
id: 5,
17+
title: 'hi ho',
18+
content: 'whatever',
19+
tags: ['a', 'best', 'react'],
20+
};
21+
22+
// Mutable payload to simulate server-side updates with polling
23+
let currentPollingPayload: typeof payload = { ...payload };
24+
25+
async function flushUntil(
26+
wrapper: any,
27+
predicate: () => boolean,
28+
tries = 100,
29+
) {
30+
for (let i = 0; i < tries; i++) {
31+
if (predicate()) return;
32+
await Promise.resolve();
33+
await nextTick();
34+
await new Promise(resolve => setTimeout(resolve, 0));
35+
}
36+
}
37+
38+
beforeAll(() => {
39+
// Global network stubs reused by tests
40+
nock(/.*/)
41+
.persist()
42+
.defaultReplyHeaders({
43+
'Access-Control-Allow-Origin': '*',
44+
'Access-Control-Allow-Headers': 'Access-Token',
45+
'Content-Type': 'application/json',
46+
})
47+
.options(/.*/)
48+
.reply(200)
49+
// ArticleResource and PollingArticleResource both hit /article/:id
50+
.get(`/article/${payload.id}`)
51+
.reply(200, () => currentPollingPayload)
52+
.put(`/article/${payload.id}`)
53+
.reply(200, (uri, requestBody: any) => ({
54+
...currentPollingPayload,
55+
...requestBody,
56+
}));
57+
});
58+
59+
afterAll(() => {
60+
nock.cleanAll();
61+
});
62+
63+
const ProvideWrapper = defineComponent({
64+
name: 'ProvideWrapper',
65+
setup(_props, { slots, expose }) {
66+
const { controller } = provideDataClient();
67+
expose({ controller });
68+
return () =>
69+
h(
70+
Suspense,
71+
{},
72+
{
73+
default: () => (slots.default ? slots.default() : null),
74+
fallback: () => h('div', { class: 'fallback' }, 'Loading'),
75+
},
76+
);
77+
},
78+
});
79+
80+
const ArticleComp = defineComponent({
81+
name: 'ArticleComp',
82+
props: { active: { type: Boolean, default: true } },
83+
async setup(props) {
84+
// Subscribe BEFORE any await to preserve current instance for inject()
85+
useSubscription(
86+
PollingArticleResource.get,
87+
props.active ? { id: payload.id } : (null as any),
88+
);
89+
const article = await useSuspense(PollingArticleResource.get, {
90+
id: payload.id,
91+
});
92+
return () =>
93+
h('div', [
94+
h('h3', (article as any).value.title),
95+
h('p', (article as any).value.content),
96+
]);
97+
},
98+
});
99+
100+
it('subscribes and re-renders on updates (simulated poll)', async () => {
101+
currentPollingPayload = { ...payload };
102+
103+
const wrapper = mount(ProvideWrapper, {
104+
slots: { default: () => h(ArticleComp, { active: true }) },
105+
});
106+
107+
// Initially should render fallback while Suspense is pending
108+
expect(wrapper.find('.fallback').exists()).toBe(true);
109+
110+
// Flush initial fetch
111+
await flushUntil(wrapper, () => wrapper.find('h3').exists());
112+
113+
// Verify initial values
114+
expect(wrapper.find('h3').text()).toBe(payload.title);
115+
expect(wrapper.find('p').text()).toBe(payload.content);
116+
117+
// Simulate a polling update by changing server payload and manually fetching
118+
const updatedTitle = payload.title + ' fiver';
119+
currentPollingPayload = { ...payload, title: updatedTitle } as any;
120+
const exposed: any = wrapper.vm as any;
121+
await exposed.controller.fetch(PollingArticleResource.get, {
122+
id: payload.id,
123+
});
124+
125+
await flushUntil(wrapper, () => wrapper.find('h3').text() === updatedTitle);
126+
expect(wrapper.find('h3').text()).toBe(updatedTitle);
127+
});
128+
129+
it('can subscribe to endpoint without pollFrequency (no-op) and render', async () => {
130+
// Minimal component that subscribes to non-polling endpoint
131+
const NoFreqComp = defineComponent({
132+
name: 'NoFreqComp',
133+
async setup() {
134+
// Subscribe first (no poller attached)
135+
useSubscription(ArticleResource.get, { id: payload.id } as any);
136+
// Then resolve suspense for stable render
137+
const article = await useSuspense(ArticleResource.get, {
138+
id: payload.id,
139+
});
140+
return () => h('div', (article as any).value.title);
141+
},
142+
});
143+
144+
const wrapper = mount(ProvideWrapper, {
145+
slots: { default: () => h(NoFreqComp) },
146+
});
147+
148+
await flushUntil(wrapper, () => wrapper.text() !== '');
149+
expect(wrapper.text()).not.toEqual('');
150+
});
151+
});

packages/vue/src/consumers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { default as useSuspense } from './useSuspense.js';
2+
export { default as useSubscription } from './useSubscription.js';
23
export {
34
injectController as useController,
45
injectState as useStateRef,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type {
2+
EndpointInterface,
3+
Schema,
4+
FetchFunction,
5+
} from '@data-client/core';
6+
import { computed, unref, watch } from 'vue';
7+
8+
import { injectController } from '../context.js';
9+
10+
/**
11+
* Keeps a resource fresh by subscribing to updates.
12+
* Mirrors React hook API. Pass `null` as first arg to unsubscribe.
13+
* @see https://dataclient.io/docs/api/useSubscription
14+
*/
15+
export default function useSubscription<
16+
E extends EndpointInterface<
17+
FetchFunction,
18+
Schema | undefined,
19+
undefined | false
20+
>,
21+
>(endpoint: E, ...args: readonly [...Parameters<E>] | readonly [null]) {
22+
const controller = injectController();
23+
24+
// Track top-level reactive args (Refs are unwrapped). This allows props/refs to trigger resubscribe.
25+
const resolvedArgs = computed(() => args.map(a => unref(a as any)) as any);
26+
const key = computed(() => {
27+
if (resolvedArgs.value[0] === null) return '';
28+
return endpoint.key(...(resolvedArgs.value as readonly [...Parameters<E>]));
29+
});
30+
31+
// Subscribe when key exists; unsubscribe on change or unmount
32+
watch(
33+
key,
34+
(_newKey, _oldKey, onCleanup) => {
35+
if (!key.value) return;
36+
const cleanedArgs = resolvedArgs.value as readonly [...Parameters<E>];
37+
controller.subscribe(endpoint, ...cleanedArgs);
38+
onCleanup(() => {
39+
controller.unsubscribe(endpoint, ...cleanedArgs);
40+
});
41+
},
42+
{ immediate: true },
43+
);
44+
}

0 commit comments

Comments
 (0)