Skip to content

Commit 26397b4

Browse files
committed
feat: #536 make $metadata and instanceof reactive to when an abstract proxy changes type
1 parent e1bc774 commit 26397b4

File tree

2 files changed

+172
-95
lines changed

2 files changed

+172
-95
lines changed

src/coalesce-vue/src/viewmodel.ts

Lines changed: 120 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,11 +1279,13 @@ export abstract class ViewModel<
12791279
}
12801280
}
12811281

1282-
constructor(
1283-
// The following MUST be declared in the constructor so its value will be available to property initializers.
1282+
/** The metadata representing the type of data that this ViewModel handles. */
1283+
public declare readonly $metadata: TModel["$metadata"];
12841284

1285-
/** The metadata representing the type of data that this ViewModel handles. */
1286-
public readonly $metadata: TModel["$metadata"],
1285+
constructor(
1286+
// Metadata is set on the prototype by `defineProps`.
1287+
// This parameter only exists for backwards-compat.
1288+
$metadata: TModel["$metadata"],
12871289

12881290
/** Instance of an API client for the model through which direct, stateless API requests may be made. */
12891291
public readonly $apiClient: TApi,
@@ -1346,6 +1348,11 @@ export abstract class ViewModel<
13461348
}
13471349

13481350
const $loadProxy = Symbol("$loadProxy");
1351+
const $protoVersion = Symbol("$protoVersion");
1352+
/** Create a class that represents an abstract model type
1353+
* and will turn itself into the proper concrete ViewModel type
1354+
* when loaded.
1355+
*/
13491356
export function createAbstractProxyViewModelType<
13501357
TModel extends Model<ModelType>,
13511358
TViewModel extends ViewModel
@@ -1355,95 +1362,115 @@ export function createAbstractProxyViewModelType<
13551362
): {
13561363
new (initialData?: DeepPartial<TModel> | null): TViewModel;
13571364
} {
1358-
return function abstractProxy(initialData?: DeepPartial<any> | null) {
1359-
const apiClient = new apiClientCtor();
1360-
1361-
if (initialData && "$metadata" in initialData) {
1362-
return ViewModelFactory.get(
1363-
initialData.$metadata!.name!,
1364-
initialData,
1365-
true
1366-
);
1367-
}
1368-
1369-
function unsupportedError() {
1370-
return new Error(`"Operation not supported: This ViewModel instance is a proxy for an abstract type, with its concrete implementation not yet decided.
1365+
function unsupportedError() {
1366+
return new Error(`"Operation not supported: This ViewModel instance is a proxy for an abstract type, with its concrete implementation not yet decided.
13711367
13721368
Try one of the following to obtain a concrete implementation:
13731369
- $load(...) or $loadFromModel(...) data for a concrete implementation
13741370
- Instantiate a concrete implementation of this abstract type instead of this abstract proxy
13751371
`);
1372+
}
1373+
1374+
class AbstractVmProxy extends ViewModel<TModel, ModelApiClient<TModel>> {
1375+
[$loadProxy]?: ItemApiState<any, any, any>;
1376+
[$protoVersion]? = ref(0);
1377+
1378+
override get $load() {
1379+
return this[$loadProxy]!;
1380+
}
1381+
override get $save() {
1382+
return this.$apiClient.$makeCaller("item", (c) => {
1383+
throw unsupportedError();
1384+
}) as any;
13761385
}
1377-
class AbstractVmProxy extends ViewModel {
1378-
constructor(initialDirtyData?: any) {
1379-
super(metadata, apiClient, initialDirtyData);
1386+
override get $bulkSave() {
1387+
return this.$apiClient.$makeCaller("item", (c) => {
1388+
throw unsupportedError();
1389+
}) as any;
1390+
}
1391+
override get $delete() {
1392+
return this.$apiClient.$makeCaller("item", (c) => {
1393+
throw unsupportedError();
1394+
}) as any;
1395+
}
1396+
1397+
constructor(initialDirtyData?: any) {
1398+
if (
1399+
initialDirtyData &&
1400+
"$metadata" in initialDirtyData &&
1401+
ViewModel.typeLookup?.[initialDirtyData.$metadata.name]
1402+
) {
1403+
// We know the real concrete type of this ViewModel from the metadata.
1404+
// Return the proper instance directly, bypassing the conversion process.
1405+
return ViewModelFactory.get(
1406+
initialDirtyData.$metadata.name,
1407+
initialDirtyData,
1408+
false
1409+
) as any;
13801410
}
13811411

1382-
[$loadProxy]!: ItemApiState<any, any, any>;
1412+
super(metadata, new apiClientCtor(), initialDirtyData);
13831413

1384-
override get $load() {
1385-
return this[$loadProxy];
1386-
}
1387-
override get $save() {
1388-
return apiClient.$makeCaller("item", (c) => {
1389-
throw unsupportedError();
1390-
});
1391-
}
1392-
override get $bulkSave() {
1393-
return apiClient.$makeCaller("item", (c) => {
1394-
throw unsupportedError();
1395-
});
1396-
}
1397-
override get $delete() {
1398-
return apiClient.$makeCaller("item", (c) => {
1399-
throw unsupportedError();
1400-
});
1401-
}
1402-
}
1403-
Object.defineProperty(AbstractVmProxy, "name", {
1404-
value: metadata.name + "ViewModelProxy",
1405-
});
1406-
defineProps(AbstractVmProxy, metadata);
1414+
const vm = this;
14071415

1408-
let vm = new AbstractVmProxy(initialData);
1416+
let $load = (vm[$loadProxy] = vm.$apiClient
1417+
.$makeCaller("item", (c, id?: any) => {
1418+
return c.get(id != null ? id : vm.$primaryKey, vm.$params);
1419+
})
1420+
.onFulfilled((state) => {
1421+
const result = state.result!;
14091422

1410-
let $load = (vm[$loadProxy] = apiClient
1411-
.$makeCaller("item", (c, id?: any) => {
1412-
return c.get(id != null ? id : vm.$primaryKey, vm.$params);
1413-
})
1414-
.onFulfilled((state) => {
1415-
const result = state.result;
1423+
var realCtor = ViewModel.typeLookup![result.$metadata.name];
14161424

1417-
var realCtor = ViewModel.typeLookup![result!.$metadata.name];
1425+
// Grab real metadata and API client instances of the concrete type.
1426+
const { $apiClient, $metadata } = new realCtor();
14181427

1419-
// Grab real metadata and API client instances of the concrete type.
1420-
const { $apiClient, $metadata } = new realCtor();
1428+
// Update the tracking ref for prototype version so that instanceof is reactive.
1429+
vm[$protoVersion]!.value++;
1430+
delete vm[$protoVersion];
14211431

1422-
// Convert the ViewModel instance to the target type:
1423-
Object.setPrototypeOf(vm, realCtor.prototype);
1424-
//@ts-expect-error normally readonly.
1425-
vm.$metadata = $metadata;
1432+
// Convert the ViewModel instance to the target type:
1433+
Object.setPrototypeOf(vm, realCtor.prototype);
14261434

1427-
// Convert the ApiClient instance to the target type:
1428-
Object.setPrototypeOf(vm.$apiClient, Object.getPrototypeOf($apiClient));
1429-
vm.$apiClient.$metadata = $metadata;
1435+
// Convert the ApiClient instance to the target type:
1436+
Object.setPrototypeOf(
1437+
vm.$apiClient,
1438+
Object.getPrototypeOf($apiClient)
1439+
);
1440+
vm.$apiClient.$metadata = $metadata;
14301441

1431-
// Populate the properties of the $load caller on the real $load caller instance.
1432-
//@ts-expect-error protected prop or fn
1433-
vm.$load.setResponseProps($load.rawResponse.data);
1434-
//@ts-expect-error protected prop or fn
1435-
vm.$load.__rawResponse.value = $load.rawResponse;
1436-
vm.$load.isLoading = false;
1437-
vm.$load.concurrencyMode = $load.concurrencyMode;
1442+
// Populate the properties of the $load caller on the real $load caller instance.
1443+
//@ts-expect-error protected prop or fn
1444+
vm.$load.setResponseProps($load.rawResponse.data);
1445+
//@ts-expect-error protected prop or fn
1446+
vm.$load.__rawResponse.value = $load.rawResponse;
1447+
vm.$load.isLoading = false;
1448+
vm.$load.concurrencyMode = $load.concurrencyMode;
14381449

1439-
//@ts-expect-error cleaning up for GC
1440-
vm[$loadProxy] = $load = undefined;
1450+
//@ts-expect-error cleaning up for GC
1451+
$load = undefined;
1452+
delete vm[$loadProxy];
14411453

1442-
vm.$loadCleanData(result);
1443-
}));
1454+
vm.$loadCleanData(result);
1455+
}));
1456+
}
1457+
}
14441458

1445-
return vm;
1446-
} as any;
1459+
Object.defineProperty(AbstractVmProxy, "name", {
1460+
value: metadata.name + "ViewModelProxy",
1461+
});
1462+
1463+
defineProps(AbstractVmProxy, metadata);
1464+
1465+
// Make `$metadata` reactive so components depending on it update when it changes.
1466+
Object.defineProperty(AbstractVmProxy.prototype, "$metadata", {
1467+
get() {
1468+
this[$protoVersion]?.value.toString();
1469+
return metadata;
1470+
},
1471+
});
1472+
1473+
return AbstractVmProxy as any;
14471474
}
14481475

14491476
export interface BulkSaveRequestRawItem {
@@ -2230,6 +2257,25 @@ export function defineProps<T extends new () => ViewModel>(
22302257
) {
22312258
const props = Object.values(metadata.props);
22322259
const descriptors = {} as PropertyDescriptorMap;
2260+
2261+
if (metadata.baseTypes?.some((base) => base.abstract)) {
2262+
// Make the `instanceof` operator against this type reactive if the type
2263+
// has a known abstract base class such that an instance could change types via AbstractVmProxy
2264+
Object.defineProperty(ctor, Symbol.hasInstance, {
2265+
value(x: any) {
2266+
// Take a dependency, tracked per instance, that will update when the instance changes type.
2267+
x[$protoVersion]?.value.toString();
2268+
return Object[Symbol.hasInstance].call(this, x);
2269+
},
2270+
});
2271+
}
2272+
2273+
descriptors["$metadata"] = {
2274+
enumerable: true,
2275+
configurable: true,
2276+
value: metadata,
2277+
};
2278+
22332279
for (let i = 0; i < props.length; i++) {
22342280
const prop = props[i];
22352281
const propName = prop.name;

src/coalesce-vue/test/viewmodel.inheritance.spec.ts

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mockEndpoint } from "./test-utils";
1+
import { delay, mockEndpoint } from "./test-utils";
22

33
import {
44
PersonViewModel,
@@ -9,6 +9,7 @@ import {
99
import { AbstractImpl1 } from "@test-targets/models.g";
1010
import { ViewModelFactory } from "../src";
1111
import { AbstractImpl1ApiClient } from "@test-targets/api-clients.g";
12+
import { computed, ref } from "vue";
1213

1314
describe("ViewModel", () => {
1415
test("$bulkSave creation of TPH entity with many-to-many that references base type", async () => {
@@ -83,6 +84,19 @@ describe("abstract proxy", () => {
8384
impl1OnlyField: "foo",
8485
};
8586

87+
function mockGet() {
88+
mockEndpoint(
89+
"/AbstractModel/get/1",
90+
vitest.fn((req) => ({
91+
wasSuccessful: true,
92+
object: {
93+
$type: "AbstractImpl1",
94+
...expectedData,
95+
},
96+
}))
97+
);
98+
}
99+
86100
test("class name", async () => {
87101
const vm = new AbstractModelViewModel();
88102
expect(vm.constructor.name).toBe("AbstractModelViewModelProxy");
@@ -91,20 +105,12 @@ describe("abstract proxy", () => {
91105
test("becomes concrete type when loaded with initial data", async () => {
92106
const vm = new AbstractModelViewModel(new AbstractImpl1(expectedData));
93107

108+
expect(vm.$getPropDirty("id")).toBeTruthy();
94109
assertIsImpl1(vm);
95110
});
96111

97112
test("can load when ID is provided by initial data", async () => {
98-
const loadMock = mockEndpoint(
99-
"/AbstractModel/get/1",
100-
vitest.fn((req) => ({
101-
wasSuccessful: true,
102-
object: {
103-
$type: "AbstractImpl1",
104-
...expectedData,
105-
},
106-
}))
107-
);
113+
mockGet();
108114

109115
const vm = new AbstractModelViewModel({ id: 1 });
110116
expect(vm.id).toBe(1);
@@ -115,16 +121,7 @@ describe("abstract proxy", () => {
115121
});
116122

117123
test("becomes concrete type after $load", async () => {
118-
const loadMock = mockEndpoint(
119-
"/AbstractModel/get",
120-
vitest.fn((req) => ({
121-
wasSuccessful: true,
122-
object: {
123-
$type: "AbstractImpl1",
124-
...expectedData,
125-
},
126-
}))
127-
);
124+
mockGet();
128125

129126
const vm = new AbstractModelViewModel();
130127
await vm.$load(1);
@@ -133,6 +130,40 @@ describe("abstract proxy", () => {
133130
assertLoaded(vm);
134131
});
135132

133+
test("instanceof is reactive", async () => {
134+
mockGet();
135+
136+
const vm = new AbstractModelViewModel();
137+
138+
// Dummy ref because Vue will always recompute a computed on every access if the computed has no deps.
139+
const dummyRef = ref(1);
140+
141+
const isImpl1 = computed(
142+
() => dummyRef.value && vm instanceof AbstractImpl1ViewModel
143+
);
144+
expect(isImpl1.value).toBeFalsy();
145+
146+
await vm.$load(1);
147+
expect(isImpl1.value).toBeTruthy();
148+
});
149+
150+
test("$metadata is reactive", async () => {
151+
mockGet();
152+
153+
const vm = new AbstractModelViewModel();
154+
155+
// Dummy ref because Vue will always recompute a computed on every access if the computed has no deps.
156+
const dummyRef = ref(1);
157+
158+
const isImpl1 = computed(
159+
() => dummyRef.value && vm.$metadata.name == "AbstractImpl1"
160+
);
161+
expect(isImpl1.value).toBeFalsy();
162+
163+
await vm.$load(1);
164+
expect(isImpl1.value).toBeTruthy();
165+
});
166+
136167
function assertLoaded(vm: AbstractModelViewModel) {
137168
expect(vm.$load.wasSuccessful).toBe(true);
138169
expect(vm.$load.isLoading).toBe(false);

0 commit comments

Comments
 (0)