Skip to content

Commit 6164b84

Browse files
committed
feat: Add basics of vue
1 parent 5d246e8 commit 6164b84

File tree

12 files changed

+457
-4
lines changed

12 files changed

+457
-4
lines changed

eslint.config.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,14 @@ export default [
4747
'no-console': 'off',
4848
},
4949
},
50+
// Disable React-specific rules for Vue package
51+
{
52+
files: ['packages/vue/**/*.?(m|c)ts?(x)', 'packages/vue/**/*.?(m|c)js?(x)'],
53+
rules: {
54+
'react-hooks/rules-of-hooks': 'off',
55+
'react-hooks/exhaustive-deps': 'off',
56+
'react/react-in-jsx-scope': 'off',
57+
'react/jsx-uses-react': 'off',
58+
},
59+
},
5060
];
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { resource, Entity } from '@data-client/rest';
2+
import { describe, it, expect } from '@jest/globals';
3+
import { mount } from '@vue/test-utils';
4+
import { defineComponent, h, Suspense, nextTick } from 'vue';
5+
6+
import useSuspense from '../composables/useSuspense.js';
7+
import { DataProvider, useController } from '../index.js';
8+
9+
class Article extends Entity {
10+
id = 0;
11+
title = '';
12+
static key = 'Article';
13+
}
14+
15+
const ArticleResource = resource({
16+
path: '/articles/:id',
17+
schema: Article,
18+
});
19+
20+
function delay<T>(v: T, ms = 1) {
21+
return new Promise<T>(resolve => setTimeout(() => resolve(v), ms));
22+
}
23+
24+
describe('useSuspense (vue)', () => {
25+
it('renders data when preset via controller', async () => {
26+
// Combined component ensures preset happens before the child ArticleView's async setup runs
27+
const Combined = defineComponent({
28+
name: 'Combined',
29+
setup() {
30+
const ctrl = useController();
31+
ctrl.setResponse(
32+
ArticleResource.get,
33+
{ id: 5 },
34+
{ id: 5, title: 'Hello Vue' },
35+
);
36+
return () => h(ArticleView);
37+
},
38+
});
39+
40+
// Component that awaits data via useSuspense
41+
const ArticleView = defineComponent({
42+
name: 'ArticleView',
43+
async setup() {
44+
const data = await useSuspense(ArticleResource.get, { id: 5 });
45+
return () => h('div', data.value.title);
46+
},
47+
});
48+
49+
const App = defineComponent({
50+
name: 'App',
51+
setup() {
52+
return () =>
53+
h(
54+
DataProvider as any,
55+
{},
56+
{ default: () => h(Suspense, {}, { default: () => h(Combined) }) },
57+
);
58+
},
59+
});
60+
61+
const wrapper = mount(App);
62+
await nextTick();
63+
await delay(0);
64+
expect(wrapper.html()).toContain('Hello Vue');
65+
});
66+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {
2+
initialState as defaultState,
3+
Controller as DataController,
4+
applyManager,
5+
initManager,
6+
} from '@data-client/core';
7+
import type { State, Manager, GCInterface } from '@data-client/core';
8+
import { defineComponent, h, shallowRef } from 'vue';
9+
10+
import DataStore from './DataStore.js';
11+
import { getDefaultManagers } from './getDefaultManagers';
12+
13+
export interface ProviderProps {
14+
managers?: Manager[];
15+
initialState?: State<unknown>;
16+
Controller?: typeof DataController;
17+
gcPolicy?: GCInterface;
18+
}
19+
20+
export default defineComponent<ProviderProps>({
21+
name: 'DataProvider',
22+
props: ['managers', 'initialState', 'Controller', 'gcPolicy'] as any,
23+
setup(props, { slots }) {
24+
const ControllerCtor = props.Controller ?? DataController;
25+
26+
// refs to keep identity stable
27+
const controllerRef = shallowRef<any>(
28+
new ControllerCtor({ gcPolicy: props.gcPolicy }),
29+
);
30+
const managersRef = shallowRef<Manager[]>(
31+
props.managers ?? getDefaultManagers(),
32+
);
33+
const initial = props.initialState ?? (defaultState as State<unknown>);
34+
35+
// prepare init/cleanup runner (invoked in DataStore onMounted)
36+
const mgrEffect = initManager(
37+
managersRef.value,
38+
controllerRef.value,
39+
initial,
40+
);
41+
42+
// build middleware
43+
const middlewares = applyManager(managersRef.value, controllerRef.value);
44+
45+
return () =>
46+
h(
47+
DataStore as any,
48+
{
49+
mgrEffect,
50+
middlewares,
51+
initialState: initial,
52+
controller: controllerRef.value,
53+
},
54+
slots.default ? { default: () => slots.default!() } : undefined,
55+
);
56+
},
57+
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { createReducer } from '@data-client/core';
2+
import type { State } from '@data-client/core';
3+
import type { Middleware as GenericMiddleware } from '@data-client/use-enhanced-reducer';
4+
import {
5+
defineComponent,
6+
h,
7+
onMounted,
8+
onUnmounted,
9+
provide,
10+
shallowRef,
11+
} from 'vue';
12+
13+
import { ControllerKey, StateKey } from '../context.js';
14+
15+
export interface StoreProps {
16+
mgrEffect: () => void;
17+
middlewares: GenericMiddleware[];
18+
initialState: State<unknown>;
19+
controller: any; // Controller
20+
}
21+
22+
/**
23+
* Vue counterpart to React DataStore: owns reducer state and exposes it via provide/inject.
24+
* Expects props to be referentially stable after mount.
25+
*/
26+
export default defineComponent<StoreProps>({
27+
name: 'DataStore',
28+
props: ['mgrEffect', 'middlewares', 'initialState', 'controller'] as any,
29+
setup(props, { slots }) {
30+
// Create reducer bound to controller
31+
const masterReducer = createReducer(props.controller);
32+
33+
// state ref holds current committed state; updates trigger reactive recompute
34+
const stateRef = shallowRef<State<unknown>>(props.initialState);
35+
36+
// Build a redux-like dispatch chain with middlewares using controller.bindMiddleware
37+
// We emulate the use-enhanced-reducer behavior: dispatch returns a Promise that resolves when committed
38+
let resolveNext: (() => void) | null = null;
39+
const waitForCommit = () =>
40+
new Promise<void>(resolve => {
41+
resolveNext = resolve;
42+
});
43+
44+
const realDispatch = async (action: any) => {
45+
// compute next state synchronously via reducer
46+
const next = masterReducer(stateRef.value, action);
47+
stateRef.value = next;
48+
// resolve pending promise after state commit microtask
49+
if (resolveNext) {
50+
const r = resolveNext;
51+
resolveNext = null;
52+
r();
53+
}
54+
return Promise.resolve();
55+
};
56+
57+
const getState = () => stateRef.value;
58+
59+
// compose middlewares
60+
const chain = props.middlewares.map(mw =>
61+
mw({
62+
getState,
63+
dispatch: (a: any) => outerDispatch(a),
64+
} as any),
65+
);
66+
const compose = (fns: ((arg: any) => any)[]) => (initial: any) =>
67+
fns.reduceRight((v, f) => f(v), initial);
68+
const outerDispatch = compose(chain)(async (action: any) => {
69+
const promise = waitForCommit();
70+
await realDispatch(action);
71+
return promise;
72+
});
73+
74+
// Bind controller with middleware API
75+
props.controller.bindMiddleware({
76+
getState,
77+
dispatch: (a: any) => outerDispatch(a),
78+
} as any);
79+
80+
// provide state and controller
81+
provide(StateKey, stateRef);
82+
provide(ControllerKey, props.controller);
83+
84+
// run managers' init/cleanup after mount via mgrEffect()
85+
let cleanup: void | (() => void);
86+
onMounted(() => {
87+
cleanup = props.mgrEffect();
88+
});
89+
onUnmounted(() => {
90+
if (cleanup) cleanup();
91+
});
92+
93+
return () => (slots.default ? slots.default() : h('div'));
94+
},
95+
});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { DevToolsConfig, Manager } from '@data-client/core';
2+
import {
3+
NetworkManager,
4+
SubscriptionManager,
5+
PollingSubscription,
6+
DevToolsManager,
7+
} from '@data-client/core';
8+
9+
/* istanbul ignore next */
10+
/** Returns the default Managers used by DataProvider. */
11+
let getDefaultManagers: (options?: GetManagersOptions) => Manager[] = (
12+
options = {},
13+
) => {
14+
const { networkManager, subscriptionManager = PollingSubscription } = options;
15+
if (subscriptionManager === null) {
16+
return [constructManager(NetworkManager, networkManager ?? ({} as any))];
17+
}
18+
return [
19+
constructManager(NetworkManager, networkManager ?? ({} as any)),
20+
constructManager(SubscriptionManager, subscriptionManager),
21+
];
22+
};
23+
/* istanbul ignore else */
24+
if (process.env.NODE_ENV !== 'production') {
25+
getDefaultManagers = (options: GetManagersOptions = {}): Manager[] => {
26+
const {
27+
devToolsManager,
28+
networkManager,
29+
subscriptionManager = PollingSubscription,
30+
} = options;
31+
if (networkManager === null) {
32+
console.error('Disabling NetworkManager is not allowed.');
33+
// fall back to default options
34+
}
35+
const nm = constructManager(NetworkManager, networkManager ?? ({} as any));
36+
const managers: Manager[] = [nm];
37+
if (subscriptionManager !== null) {
38+
managers.push(constructManager(SubscriptionManager, subscriptionManager));
39+
}
40+
if (devToolsManager !== null) {
41+
let dtm: DevToolsManager;
42+
if (devToolsManager instanceof DevToolsManager) {
43+
dtm = devToolsManager;
44+
} else {
45+
dtm = new DevToolsManager(
46+
devToolsManager as any,
47+
nm.skipLogging.bind(nm),
48+
);
49+
}
50+
managers.unshift(dtm);
51+
}
52+
return managers;
53+
};
54+
}
55+
export { getDefaultManagers };
56+
57+
function constructManager<M extends { new (...args: any): Manager }>(
58+
Mgr: M,
59+
optionOrInstance: InstanceType<M> | ConstructorArgs<M>,
60+
): InstanceType<M> {
61+
if (optionOrInstance instanceof Mgr) {
62+
return optionOrInstance as InstanceType<M>;
63+
}
64+
return new Mgr(optionOrInstance as any) as InstanceType<M>;
65+
}
66+
67+
export type GetManagersOptions = {
68+
devToolsManager?: DevToolsManager | DevToolsConfig | null;
69+
networkManager?:
70+
| NetworkManager
71+
| ConstructorArgs<typeof NetworkManager>
72+
| null;
73+
subscriptionManager?:
74+
| SubscriptionManager
75+
| ConstructorArgs<typeof SubscriptionManager>
76+
| null;
77+
};
78+
79+
export type ConstructorArgs<T extends { new (...args: any): any }> =
80+
T extends new (options: infer O) => any ? O : never;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default as DataProvider } from './DataProvider.js';
2+
export { getDefaultManagers } from './getDefaultManagers.js';
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export { default as useSuspense } from './useSuspense.js';
2+
export {
3+
injectController as useController,
4+
injectState as useStateRef,
5+
} from '../context.js';
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { State } from '@data-client/core';
2+
import type { ShallowRef } from 'vue';
3+
4+
import { injectController, injectState } from '../context.js';
5+
6+
export function useController() {
7+
return injectController();
8+
}
9+
10+
export function useStateRef(): ShallowRef<State<unknown>> {
11+
return injectState();
12+
}

0 commit comments

Comments
 (0)