Skip to content

Commit 98465f4

Browse files
committed
feat: improve useCreateViewModel hook (HMR improvenment)
1 parent df67dd4 commit 98465f4

File tree

7 files changed

+206
-45
lines changed

7 files changed

+206
-45
lines changed

.changeset/polite-garlics-act.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"mobx-view-model": patch
3+
---
4+
5+
try to improve `HMR` with using `useMemo` hook inside `useCreateViewModel`

.changeset/ready-bats-hide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"mobx-view-model": patch
3+
---
4+
5+
added unit tests for `ViewModelSimple` and its connection with `ViewModelStore`

src/hoc/with-view-model.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -811,7 +811,7 @@ describe('withViewModel', () => {
811811
);
812812

813813
expect(viewModels).toBeDefined();
814-
expect(vmStore.spies.get).toHaveBeenCalledTimes(3);
814+
expect(vmStore.spies.get).toHaveBeenCalledTimes(1);
815815
expect(vmStore._instanceAttachedCount.size).toBe(1);
816816
expect(vmStore._unmountingViews.size).toBe(0);
817817
expect(vmStore.mountedViewsCount).toBe(1);
@@ -885,7 +885,7 @@ describe('withViewModel', () => {
885885
await expect(container.firstChild).toMatchFileSnapshot(
886886
`../../tests/snapshots/hoc/with-view-model/view-model-store/${task.name}.html`,
887887
);
888-
expect(vmStore.spies.get).toHaveBeenCalledTimes(15);
888+
expect(vmStore.spies.get).toHaveBeenCalledTimes(3);
889889
expect(vmStore._instanceAttachedCount.size).toBe(3);
890890
expect(vmStore._unmountingViews.size).toBe(0);
891891
expect(vmStore.mountedViewsCount).toBe(3);

src/hooks/use-create-view-model.ts

Lines changed: 13 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable react-hooks/rules-of-hooks */
2-
import { useContext, useLayoutEffect, useRef, useState } from 'react';
2+
import { useContext, useLayoutEffect } from 'react';
33
import { Class, AllPropertiesOptional, Maybe } from 'yummies/utils/types';
44

55
import { viewModelsConfig } from '../config/global-config.js';
@@ -10,6 +10,7 @@ import {
1010
import { ActiveViewModelContext } from '../contexts/active-view-context.js';
1111
import { ViewModelsContext } from '../contexts/view-models-context.js';
1212
import { useIsomorphicLayoutEffect } from '../lib/hooks/use-isomorphic-layout-effect.js';
13+
import { useValue } from '../lib/hooks/use-value.js';
1314
import { generateVMId } from '../utils/create-vm-id-generator.js';
1415
import { ViewModelSimple } from '../view-model/view-model-simple.js';
1516
import { ViewModelCreateConfig } from '../view-model/view-model.store.types.js';
@@ -41,19 +42,11 @@ const useCreateViewModelSimple = (
4142
payload?: any,
4243
) => {
4344
const viewModels = useContext(ViewModelsContext);
44-
const lastInstance = useRef<ViewModelSimple | null>(null);
45-
46-
const [instance] = useState(() => {
47-
if (lastInstance.current) {
48-
return lastInstance.current;
49-
}
50-
45+
const instance = useValue(() => {
5146
const instance = new VM();
47+
5248
viewModels?.markToBeAttached(instance);
5349

54-
if (viewModels && instance.linkStore) {
55-
instance.linkStore(viewModels);
56-
}
5750
return instance;
5851
});
5952

@@ -68,16 +61,14 @@ const useCreateViewModelSimple = (
6861
viewModels.attach(instance);
6962
return () => {
7063
viewModels.detach(instance.id);
71-
lastInstance.current = null;
7264
};
7365
} else {
7466
instance.mount?.();
7567
return () => {
7668
instance.unmount?.();
77-
lastInstance.current = null;
7869
};
7970
}
80-
}, []);
71+
}, [instance]);
8172

8273
return instance;
8374
};
@@ -134,14 +125,13 @@ export function useCreateViewModel(VM: Class<any>, ...args: any[]) {
134125
return useCreateViewModelSimple(VM, payload);
135126
}
136127

137-
const idRef = useRef<string>('');
138128
const viewModels = useContext(ViewModelsContext);
139129
const parentViewModel = useContext(ActiveViewModelContext) || null;
140130

141131
const ctx = config?.ctx ?? {};
142132

143-
if (!idRef.current) {
144-
idRef.current =
133+
const instance = useValue(() => {
134+
const id =
145135
viewModels?.generateViewModelId({
146136
...config,
147137
ctx,
@@ -150,16 +140,11 @@ export function useCreateViewModel(VM: Class<any>, ...args: any[]) {
150140
}) ??
151141
config?.id ??
152142
generateVMId(ctx);
153-
}
154143

155-
const id = idRef.current;
144+
const instanceFromStore = viewModels ? viewModels.get(id) : null;
156145

157-
const instanceFromStore = viewModels ? viewModels.get(id) : null;
158-
const instanceRef = useRef<AnyViewModel | null>(null);
159-
160-
if (!instanceRef.current) {
161146
if (instanceFromStore) {
162-
instanceRef.current = instanceFromStore as AnyViewModel;
147+
return instanceFromStore as AnyViewModel;
163148
} else {
164149
const configCreate: ViewModelCreateConfig<any> = {
165150
...config,
@@ -180,32 +165,28 @@ export function useCreateViewModel(VM: Class<any>, ...args: any[]) {
180165
viewModels?.createViewModel<any>(configCreate) ??
181166
viewModelsConfig.factory(configCreate);
182167

183-
instanceRef.current = instance;
184-
185168
instance.willMount();
186169

187170
viewModels?.markToBeAttached(instance);
188-
}
189-
}
190171

191-
const instance = instanceRef.current;
172+
return instance;
173+
}
174+
});
192175

193176
useIsomorphicLayoutEffect(() => {
194177
if (viewModels) {
195178
viewModels.attach(instance);
196179
return () => {
197180
viewModels.detach(instance.id);
198-
instanceRef.current = null;
199181
};
200182
} else {
201183
instance.mount();
202184
return () => {
203185
instance.willUnmount();
204186
instance.unmount();
205-
instanceRef.current = null;
206187
};
207188
}
208-
}, []);
189+
}, [instance]);
209190

210191
instance.setPayload(payload ?? {});
211192

src/lib/hooks/use-value.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { useMemo, useRef } from 'react';
2+
import { AnyObject } from 'yummies/utils/types';
3+
4+
type UseValueHook = <TValue extends AnyObject>(
5+
getValue: () => TValue,
6+
) => TValue;
7+
8+
let useValueImpl = null as unknown as UseValueHook;
9+
10+
if (process.env.NODE_ENV === 'production') {
11+
/**
12+
* This implementation is not working with HMR
13+
*/
14+
useValueImpl = (getValue) => {
15+
// eslint-disable-next-line sonarjs/no-redundant-type-constituents
16+
const valueRef = useRef<any | null>(null);
17+
18+
if (!valueRef.current) {
19+
valueRef.current = getValue();
20+
}
21+
22+
return valueRef.current;
23+
};
24+
} else {
25+
/**
26+
* This is might be helpful for better HMR Vite
27+
*/
28+
useValueImpl = (getValue) => {
29+
return useMemo(getValue, []);
30+
};
31+
}
32+
33+
/**
34+
* This hook accept `getValue` function and returns it result.
35+
*
36+
* `getValue` _should_ executes **ONLY ONCE**.
37+
* But in HMR it can executes more than 1 time
38+
*
39+
* @example
40+
* ```
41+
* const num = useValue(() => 1); // 1
42+
* ```
43+
*/
44+
export const useValue = useValueImpl;
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import { ViewModelSimple } from './view-model-simple.js';
4+
import { ViewModelStoreBaseMock } from './view-model.store.base.test.js';
5+
6+
import { ViewModelStore } from './index.js';
7+
8+
export class ViewModelSimpleImpl implements ViewModelSimple<{ test: number }> {
9+
spies = {
10+
mount: vi.fn(),
11+
linkStore: vi.fn(),
12+
setPayload: vi.fn(),
13+
unmount: vi.fn(),
14+
};
15+
16+
id: string;
17+
18+
constructor(id: string = '1') {
19+
this.id = id;
20+
}
21+
22+
mount(): void {
23+
this.spies.mount();
24+
}
25+
26+
linkStore(viewModels: ViewModelStore): void {
27+
this.spies.linkStore(viewModels);
28+
}
29+
30+
setPayload(payload: { test: number }): void {
31+
this.spies.setPayload(payload);
32+
}
33+
34+
unmount(): void {
35+
this.spies.unmount();
36+
}
37+
}
38+
39+
describe('ViewModelSimple', () => {
40+
it('create instance', () => {
41+
const vm = new ViewModelSimpleImpl();
42+
expect(vm).toBeDefined();
43+
});
44+
45+
it('has id', () => {
46+
const vm = new ViewModelSimpleImpl();
47+
expect(vm.id).toBe('1');
48+
});
49+
50+
describe('work with vm store', () => {
51+
it('should call "linkStore"', () => {
52+
const vmStore = new ViewModelStoreBaseMock();
53+
const vm = new ViewModelSimpleImpl();
54+
vmStore.markToBeAttached(vm);
55+
expect(vm.spies.linkStore).toBeCalledTimes(1);
56+
expect(vm.spies.linkStore).toBeCalledWith(vmStore);
57+
});
58+
59+
it('should ok "attach" simple vm to store', async () => {
60+
const vmStore = new ViewModelStoreBaseMock();
61+
const vm1 = new ViewModelSimpleImpl('1');
62+
const vm2 = new ViewModelSimpleImpl('2');
63+
64+
await vmStore.attach(vm1);
65+
await vmStore.attach(vm2);
66+
67+
expect(vm1.spies.mount).toBeCalledTimes(1);
68+
expect(vm2.spies.mount).toBeCalledTimes(1);
69+
70+
expect([...vmStore._viewModels.values()]).toHaveLength(2);
71+
expect([...vmStore._mountingViews.values()]).toHaveLength(0);
72+
expect([...vmStore._unmountingViews.values()]).toHaveLength(0);
73+
expect([...vmStore._linkedComponentVMClasses.values()]).toHaveLength(0);
74+
expect([...vmStore._instanceAttachedCount.values()]).toHaveLength(2);
75+
expect([...vmStore._viewModelIdsByClasses.values()]).toHaveLength(1);
76+
});
77+
78+
it('should ok "detach" simple vm to store', async () => {
79+
const vmStore = new ViewModelStoreBaseMock();
80+
const vm1 = new ViewModelSimpleImpl('1');
81+
const vm2 = new ViewModelSimpleImpl('2');
82+
83+
await vmStore.attach(vm1);
84+
await vmStore.attach(vm2);
85+
86+
await vmStore.detach('1');
87+
await vmStore.detach('2');
88+
89+
expect([...vmStore._viewModels.values()]).toHaveLength(0);
90+
expect([...vmStore._mountingViews.values()]).toHaveLength(0);
91+
expect([...vmStore._unmountingViews.values()]).toHaveLength(0);
92+
expect([...vmStore._linkedComponentVMClasses.values()]).toHaveLength(0);
93+
expect([...vmStore._instanceAttachedCount.values()]).toHaveLength(0);
94+
expect([...vmStore._viewModelIdsByClasses.values()]).toHaveLength(0);
95+
});
96+
97+
it('should be found by .get() and id', async () => {
98+
const vmStore = new ViewModelStoreBaseMock();
99+
const vm = new ViewModelSimpleImpl('1000');
100+
101+
await vmStore.attach(vm);
102+
103+
expect(vmStore.get('1000')).toBe(vm);
104+
});
105+
106+
it('should be found by .get() and class ref', async () => {
107+
const vmStore = new ViewModelStoreBaseMock();
108+
const vm = new ViewModelSimpleImpl('1000');
109+
110+
await vmStore.attach(vm);
111+
112+
expect(vmStore.get(ViewModelSimpleImpl)).toBe(vm);
113+
});
114+
});
115+
});

src/view-model/view-model.store.base.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -257,11 +257,31 @@ export class ViewModelStoreBase<VMBase extends AnyViewModel = AnyViewModel>
257257
}
258258
}
259259

260+
protected dettachVMConstructor(model: VMBase | AnyViewModelSimple) {
261+
const constructor = (model as any).constructor as Class<any, any>;
262+
263+
if (this.viewModelIdsByClasses.has(constructor)) {
264+
const vmIds = this.viewModelIdsByClasses
265+
.get(constructor)!
266+
.filter((it) => it !== model.id);
267+
268+
if (vmIds.length > 0) {
269+
this.viewModelIdsByClasses.set(constructor, vmIds);
270+
} else {
271+
this.viewModelIdsByClasses.delete(constructor);
272+
}
273+
}
274+
}
275+
260276
markToBeAttached(model: VMBase | AnyViewModelSimple) {
261277
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
262278
// @ts-expect-error
263279
this.viewModelsTempHeap.set(model.id, model);
264280

281+
if ('linkStore' in model) {
282+
model.linkStore!(this as any);
283+
}
284+
265285
this.attachVMConstructor(model);
266286
}
267287

@@ -304,18 +324,9 @@ export class ViewModelStoreBase<VMBase extends AnyViewModel = AnyViewModel>
304324
}
305325

306326
this.instanceAttachedCount.delete(model.id);
307-
308-
const constructor = model.constructor as Class<VMBase, any>;
309-
310327
this.viewModels.delete(id);
311-
if (this.viewModelIdsByClasses.has(constructor)) {
312-
this.viewModelIdsByClasses.set(
313-
constructor,
314-
this.viewModelIdsByClasses
315-
.get(constructor)!
316-
.filter((id) => id !== model.id),
317-
);
318-
}
328+
this.dettachVMConstructor(model);
329+
319330
await this.unmount(model);
320331
}
321332
}

0 commit comments

Comments
 (0)