Skip to content

Commit adbd7af

Browse files
author
Oleksandr Dzhychko
authored
Merge pull request #1178 from modelix/fix/MODELIX-1041-with-test
fix(vue-model-api): do not evict ReactiveINodeJS from cache when objects manged by it are still used
2 parents 938310d + 6493504 commit adbd7af

File tree

9 files changed

+193
-108
lines changed

9 files changed

+193
-108
lines changed

vue-model-api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
],
1515
"scripts": {
1616
"build": "tsc --build",
17-
"test": "jest",
17+
"test": "node --expose-gc node_modules/.bin/jest",
1818
"prettier": "prettier . --check",
1919
"prettier:fix": "npm run prettier -- --write"
2020
},

vue-model-api/src/internal/Cache.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { INodeJS } from "@modelix/ts-model-api";
22
import { Cache } from "./Cache";
3+
import { runGarbageCollection } from "./runGarbageCollection";
34

45
test("wrapper is added to cache", () => {
56
const cache = new Cache<{ id: string }>();
@@ -17,3 +18,16 @@ test("wrapper is added to cache", () => {
1718
const wrapped2 = cache.memoize(node, wrap2);
1819
expect(wrapped2.id).toBe("aWrapper1");
1920
});
21+
22+
test("wrapper is removed from cache after garbage collection", async () => {
23+
const iNode = {
24+
getReference() {
25+
return "myKey";
26+
},
27+
} as INodeJS;
28+
const cache = new Cache();
29+
cache.memoize(iNode, () => ({}));
30+
expect(cache.get(iNode)).not.toBeUndefined();
31+
await runGarbageCollection();
32+
expect(cache.get(iNode)).toBeUndefined();
33+
});

vue-model-api/src/internal/ReactiveINodeJS.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useModelsFromJson } from "../useModelsFromJson";
22
import { computed, isReactive, reactive } from "vue";
3+
import { runGarbageCollection } from "./runGarbageCollection";
34

45
const root = {
56
root: {
@@ -159,3 +160,22 @@ test("removing a node is reactive", () => {
159160
node.remove();
160161
expect(computedProperty.value).toHaveLength(childCount - 1);
161162
});
163+
164+
test("garbage collection does not break reactivity", async () => {
165+
const rootNode = useRootNode();
166+
// Do not assign the child object to a variable because this would prevent GC from collecting.
167+
// MODELIX-1041 was caused by child object being garbage collected even when Vue components were subscribed to their properties.
168+
function getChild() {
169+
return rootNode.getAllChildren()[0];
170+
}
171+
getChild().setPropertyValue("name", "firstName");
172+
const computedChildNames = computed(() =>
173+
getChild().getPropertyValue("name"),
174+
);
175+
expect(computedChildNames.value).toEqual("firstName");
176+
177+
await runGarbageCollection();
178+
getChild().setPropertyValue("name", "secondName");
179+
180+
expect(computedChildNames.value).toEqual("secondName");
181+
});
Lines changed: 123 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { IConceptJS, INodeJS } from "@modelix/ts-model-api";
2-
import { customRef, markRaw } from "vue";
2+
import type { Ref } from "vue";
3+
import { markRaw, shallowRef } from "vue";
34
import type { Cache } from "./Cache";
5+
import type { Nullable } from "@modelix/model-client";
46

57
export function toReactiveINodeJS(
68
node: INodeJS,
@@ -9,12 +11,12 @@ export function toReactiveINodeJS(
911
return cache.memoize(node, () => new ReactiveINodeJS(node, cache));
1012
}
1113

12-
// This declaration specifies the types of the implemenation in `unwrapReactiveINodeJS` further.
14+
// This declaration specifies the types of the implementation in `unwrapReactiveINodeJS` further.
1315
// It declares, that when
1416
// * an `INodeJS` is given an `INodeJS` is returned
1517
// * an `undefined` is given an `undefined` is returned
1618
// * an `null` is given an `null` is returned
17-
// This so called conditional types help avoid unneed assertsion about types on the usage site.
19+
// This so called conditional types help avoid unneeded assertions about types on the usage site.
1820
// See. https://www.typescriptlang.org/docs/handbook/2/conditional-types.html
1921
function unwrapReactiveINodeJS<T extends INodeJS | null | undefined>(
2022
maybeReactive: T,
@@ -32,20 +34,24 @@ function unwrapReactiveINodeJS(
3234
}
3335
}
3436

35-
// `any` is currectly provided as the type for references from `@modelix/ts-model-api`,
37+
// `any` is correctly provided as the type for references from `@modelix/ts-model-api`,
3638
// so we have to work with it for now.
3739
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3840
type INodeReferenceJS = any;
3941

40-
interface TrackAndTrigger {
41-
track: () => void;
42-
trigger: () => void;
43-
}
44-
4542
export class ReactiveINodeJS implements INodeJS {
46-
private byRoleTrackAndTrigger: Map<string | undefined, TrackAndTrigger> =
43+
private byRoleRefToProperty: Map<string, Ref<string | undefined>> = new Map();
44+
private byRoleRefToReferenceTargetNode: Map<
45+
string,
46+
Ref<INodeJS | undefined>
47+
> = new Map();
48+
private byRoleRefToReferenceTargetRef: Map<
49+
string,
50+
Ref<INodeReferenceJS | undefined>
51+
> = new Map();
52+
private byRoleRefToChildren: Map<string | undefined, Ref<INodeJS[]>> =
4753
new Map();
48-
private trackAndTriggerForAllChildren: TrackAndTrigger | undefined;
54+
private refForAllChildren: Ref<INodeJS[]> | undefined = undefined;
4955

5056
constructor(
5157
public readonly unreactiveNode: INodeJS,
@@ -56,6 +62,30 @@ export class ReactiveINodeJS implements INodeJS {
5662
markRaw(this);
5763
}
5864

65+
private propertyGetter = (role: string) =>
66+
this.unreactiveNode.getPropertyValue(role);
67+
68+
private referenceTargetRefGetter = (role: string) =>
69+
this.unreactiveNode.getReferenceTargetRef(role);
70+
71+
private referenceTargetNodeGetter = (role: string) => {
72+
const unreactiveTargetNode =
73+
this.unreactiveNode.getReferenceTargetNode(role);
74+
return unreactiveTargetNode
75+
? toReactiveINodeJS(unreactiveTargetNode, this.cache)
76+
: unreactiveTargetNode;
77+
};
78+
79+
private childrenGetter = (role: string | undefined) =>
80+
this.unreactiveNode
81+
.getChildren(role)
82+
.map((node) => toReactiveINodeJS(node, this.cache));
83+
84+
private allChildrenGetter = () =>
85+
this.unreactiveNode
86+
.getAllChildren()
87+
.map((node) => toReactiveINodeJS(node, this.cache));
88+
5989
getConcept(): IConceptJS | undefined {
6090
return this.unreactiveNode.getConcept();
6191
}
@@ -78,30 +108,30 @@ export class ReactiveINodeJS implements INodeJS {
78108

79109
getParent(): INodeJS | undefined {
80110
// This does not need to be made reacitve for the same reason as getRoleInParent
81-
const unreacitveNode = this.unreactiveNode.getParent();
82-
return unreacitveNode
83-
? toReactiveINodeJS(unreacitveNode, this.cache)
84-
: unreacitveNode;
111+
const unreactiveNode = this.unreactiveNode.getParent();
112+
return unreactiveNode
113+
? toReactiveINodeJS(unreactiveNode, this.cache)
114+
: unreactiveNode;
85115
}
86116

87117
remove(): void {
88118
this.unreactiveNode.remove();
89119
}
90120

91121
getChildren(role: string | undefined): INodeJS[] {
92-
const { track } = this.getOrCreateTrackAndTriggerForRole(role);
93-
track();
94-
return this.unreactiveNode
95-
.getChildren(role)
96-
.map((node) => toReactiveINodeJS(node, this.cache));
122+
const ref = this.getOrCreateRefForRole(
123+
this.byRoleRefToChildren,
124+
role,
125+
this.childrenGetter,
126+
);
127+
return ref.value;
97128
}
98129

99130
getAllChildren(): INodeJS[] {
100-
const { track } = this.getOrCreateTrackAndTriggerForAllChildren();
101-
track();
102-
return this.unreactiveNode
103-
.getAllChildren()
104-
.map((node) => toReactiveINodeJS(node, this.cache));
131+
if (this.refForAllChildren == undefined) {
132+
this.refForAllChildren = shallowRef(this.allChildrenGetter());
133+
}
134+
return this.refForAllChildren!.value;
105135
}
106136

107137
moveChild(role: string | undefined, index: number, child: INodeJS): void {
@@ -113,14 +143,14 @@ export class ReactiveINodeJS implements INodeJS {
113143
index: number,
114144
concept: IConceptJS | undefined,
115145
): INodeJS {
116-
const unreacitveNode = this.unreactiveNode.addNewChild(
146+
// The related Vue-`Ref` does not need to be triggered.
147+
// It will be updated through the changed listener of the branch.
148+
const unreactiveNode = this.unreactiveNode.addNewChild(
117149
role,
118150
index,
119151
concept,
120152
);
121-
return unreacitveNode
122-
? toReactiveINodeJS(unreacitveNode, this.cache)
123-
: unreacitveNode;
153+
return toReactiveINodeJS(unreactiveNode, this.cache);
124154
}
125155

126156
removeChild(child: INodeJS): void {
@@ -134,25 +164,26 @@ export class ReactiveINodeJS implements INodeJS {
134164
}
135165

136166
getReferenceTargetNode(role: string): INodeJS | undefined {
137-
const { track } = this.getOrCreateTrackAndTriggerForRole(role);
138-
track();
139-
const unreacitveNode = this.unreactiveNode.getReferenceTargetNode(role);
140-
return unreacitveNode
141-
? toReactiveINodeJS(unreacitveNode, this.cache)
142-
: unreacitveNode;
167+
const ref = this.getOrCreateRefForRole(
168+
this.byRoleRefToReferenceTargetNode,
169+
role,
170+
this.referenceTargetNodeGetter,
171+
);
172+
return ref.value;
143173
}
144174

145175
getReferenceTargetRef(role: string): INodeReferenceJS | undefined {
146-
const { track } = this.getOrCreateTrackAndTriggerForRole(role);
147-
track();
148-
return this.unreactiveNode.getReferenceTargetRef(role);
176+
const ref = this.getOrCreateRefForRole(
177+
this.byRoleRefToReferenceTargetRef,
178+
role,
179+
this.referenceTargetRefGetter,
180+
);
181+
return ref.value;
149182
}
150183

151184
setReferenceTargetNode(role: string, target: INodeJS | undefined): void {
152-
// Do not call `this.unreactiveNode.setReferenceTargetNode` directly,
153-
// because the target is declared as `INodeJS`, but actuall an `NodeAdapterJS` is expected.
154-
// Just using the reference is cleaner then unwrapping
155-
// then checking for ReactiveINodeJS and eventuall unwrapping it
185+
// The related Vue-`Ref` does not need to be triggered.
186+
// It will be updated through the changed listener of the branch.
156187
return this.unreactiveNode.setReferenceTargetNode(
157188
role,
158189
unwrapReactiveINodeJS(target),
@@ -163,6 +194,8 @@ export class ReactiveINodeJS implements INodeJS {
163194
role: string,
164195
target: INodeReferenceJS | undefined,
165196
): void {
197+
// The related Vue-`Ref` does not need to be triggered.
198+
// It will be updated through the changed listener of the branch.
166199
this.unreactiveNode.setReferenceTargetRef(role, target);
167200
}
168201

@@ -173,69 +206,63 @@ export class ReactiveINodeJS implements INodeJS {
173206
}
174207

175208
getPropertyValue(role: string): string | undefined {
176-
const { track } = this.getOrCreateTrackAndTriggerForRole(role);
177-
track();
178-
return this.unreactiveNode.getPropertyValue(role);
209+
const ref = this.getOrCreateRefForRole(
210+
this.byRoleRefToProperty,
211+
role,
212+
this.propertyGetter,
213+
);
214+
return ref.value;
179215
}
180216

181217
setPropertyValue(role: string, value: string | undefined): void {
218+
// The related Vue-`Ref` does not need to be triggered.
219+
// It will be updated through the changed listener of the branch.
182220
this.unreactiveNode.setPropertyValue(role, value);
183221
}
184222

185-
private getOrCreateTrackAndTriggerForAllChildren(): TrackAndTrigger {
186-
if (this.trackAndTriggerForAllChildren === undefined) {
187-
customRef((track, trigger) => {
188-
this.trackAndTriggerForAllChildren = {
189-
track,
190-
trigger,
191-
};
192-
return {
193-
// Getter and setter ar empty for the same reason as in `getOrCreateTrackAndTriggerForRole`
194-
get() {},
195-
set() {},
196-
};
197-
});
223+
getOrCreateRefForRole<RoleT, ValueT>(
224+
byRoleRefs: Map<RoleT, Ref<ValueT>>,
225+
role: RoleT,
226+
getValue: (role: RoleT) => ValueT,
227+
): Ref<ValueT> {
228+
const maybeCreatedShallowRef = byRoleRefs.get(role);
229+
230+
if (maybeCreatedShallowRef != undefined) {
231+
return maybeCreatedShallowRef;
232+
} else {
233+
const newRef = shallowRef(getValue(role));
234+
byRoleRefs.set(role, newRef);
235+
return newRef;
198236
}
199-
// `this.trackAndTriggerForAllChildren` will always be set, because the `factory`
200-
// argument of `customRef` will be evaluated immedialty.
201-
return this.trackAndTriggerForAllChildren!;
202237
}
203238

204-
private getOrCreateTrackAndTriggerForRole(
205-
role: string | undefined,
206-
): TrackAndTrigger {
207-
const existing = this.byRoleTrackAndTrigger.get(role);
208-
if (existing !== undefined) {
209-
return existing;
239+
triggerChangeInChild(role: Nullable<string>) {
240+
if (this.refForAllChildren) {
241+
this.refForAllChildren.value = this.allChildrenGetter();
242+
}
243+
244+
const normalizedRole = role ?? undefined;
245+
const maybeRef = this.byRoleRefToChildren.get(normalizedRole);
246+
if (maybeRef) {
247+
maybeRef.value = this.childrenGetter(normalizedRole);
248+
}
249+
}
250+
251+
triggerChangeInReference(role: string) {
252+
const maybeTargetNodeRef = this.byRoleRefToReferenceTargetNode.get(role);
253+
if (maybeTargetNodeRef) {
254+
maybeTargetNodeRef.value = this.referenceTargetNodeGetter(role);
255+
}
256+
const maybeTargetRefRef = this.byRoleRefToReferenceTargetRef.get(role);
257+
if (maybeTargetRefRef) {
258+
maybeTargetRefRef.value = this.referenceTargetRefGetter(role);
259+
}
260+
}
261+
262+
triggerChangeInProperty(role: string) {
263+
const maybeRef = this.byRoleRefToProperty.get(role);
264+
if (maybeRef) {
265+
maybeRef.value = this.propertyGetter(role);
210266
}
211-
let created;
212-
customRef((track, trigger) => {
213-
created = {
214-
track,
215-
trigger,
216-
};
217-
this.byRoleTrackAndTrigger.set(role, created);
218-
return {
219-
// The getters and setters will never be called directly
220-
// and therefore the are empty.
221-
// We use `customRef` to get access to a pair of `trigger` and `track`
222-
// to call them directly from outside.
223-
get() {},
224-
set() {},
225-
};
226-
});
227-
// `created` will always be set, because the `factory`
228-
// argument of `customRef` will be evaluated immedialty.
229-
return created!;
230-
}
231-
232-
triggerChangeInRole(role: string | undefined | null) {
233-
const normalizedRole =
234-
role !== undefined && role !== null ? role : undefined;
235-
this.byRoleTrackAndTrigger.get(normalizedRole)?.trigger();
236-
}
237-
238-
triggerChangeInAllChildren() {
239-
this.trackAndTriggerForAllChildren?.trigger();
240267
}
241268
}

0 commit comments

Comments
 (0)