diff --git a/CHANGELOG.md b/CHANGELOG.md index da3720dbb..15f2605cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Fixes - Fix error in .NET 9 thrown by vite development middleware if the HTTPS cert directory doesn't exist. +- Fix `ViewModel.$load` and `ListViewModel.$load` not properly working with `.useResponseCaching()`. # 5.3.1 diff --git a/src/coalesce-vue/src/viewmodel.ts b/src/coalesce-vue/src/viewmodel.ts index d5c616a3e..bc9fe10a9 100644 --- a/src/coalesce-vue/src/viewmodel.ts +++ b/src/coalesce-vue/src/viewmodel.ts @@ -418,21 +418,32 @@ export abstract class ViewModel< * A function for invoking the `/get` endpoint, and a set of properties about the state of the last call. */ get $load() { - const $load = this.$apiClient.$makeCaller("item", (c, id?: TPrimaryKey) => { - const startTime = performance.now(); - - return c - .get(id != null ? id : this.$primaryKey, this.$params) - .then((r) => { - const result = r.data.object; - if (result) { - // We do `purgeUnsaved` (arg2) here, since $load() is always an explicit user action - // that may be serving as a "reset" of local state. - this.$loadFromModel(result, startTime, true); - } - return r; - }); - }); + const $load = this.$apiClient + .$makeCaller("item", (c, id?: TPrimaryKey) => { + const startTime = performance.now(); + + return c + .get(id != null ? id : this.$primaryKey, this.$params) + .then((r) => { + // @ts-expect-error passing data through to `onFulfilled` + r.__startTime = startTime; + return r; + }); + }) + .onFulfilled((state) => { + const result = state.result; + + // If there's no captured start time, assume the data is from response caching + // and use the oldest possible time (0). + // @ts-expect-error passed through from invoker + const startTime = state.rawResponse?.__startTime ?? 0; + + if (result) { + // We do `purgeUnsaved` (arg2) here, since $load() is always an explicit user action + // that may be serving as a "reset" of local state. + this.$loadFromModel(result, startTime, true); + } + }); // Lazy getter technique - don't create the caller until/unless it is needed, // since creation of api callers is a little expensive. @@ -1516,11 +1527,24 @@ export abstract class ListViewModel< * A function for invoking the `/load` endpoint, and a set of properties about the state of the last call. */ get $load() { - const $load = this.$apiClient.$makeCaller("list", (c) => { - const startTime = performance.now(); - return c.list(this.$params).then((r) => { + const $load = this.$apiClient + .$makeCaller("list", (c) => { + const startTime = performance.now(); + return c.list(this.$params).then((r) => { + // @ts-expect-error passing data through to `onFulfilled` + r.__startTime = startTime; + return r; + }); + }) + .onFulfilled((state) => { if (!this._lightweight) { - const result = r.data.list; + const result = state.result; + + // If there's no captured start time, assume the data is from response caching + // and use the oldest possible time (0). + // @ts-expect-error passed through from invoker + const startTime = state.rawResponse?.__startTime ?? 0; + if (result) { this.$items = rebuildModelCollectionForViewModelCollection< TModel, @@ -1528,9 +1552,7 @@ export abstract class ListViewModel< >(this.$metadata, this.$items, result, startTime, true); } } - return r; }); - }); // Lazy getter technique - don't create the caller until/unless it is needed, // since creation of api callers is a little expensive. @@ -1766,7 +1788,18 @@ export class ViewModelFactory { if (map.has(initialData)) { return map.get(initialData)!; } - const vmCtor = ViewModel.typeLookup![typeName]; + + if (initialData instanceof ViewModel) { + return initialData; + } + + if (ViewModel.typeLookup === null) { + throw Error( + "Static `ViewModel.typeLookup` is not defined. It should get defined in viewmodels.g.ts." + ); + } + + const vmCtor = ViewModel.typeLookup[typeName]; const vm = new vmCtor() as unknown as ViewModel; map.set(initialData, vm); @@ -1871,11 +1904,6 @@ function viewModelCollectionMapItems( viewModel = val; } else { // Incoming is a Model. Make a ViewModel from it. - if (ViewModel.typeLookup === null) { - throw Error( - "Static `ViewModel.typeLookup` is not defined. It should get defined in viewmodels.g.ts." - ); - } viewModel = val = ViewModelFactory.get( collectedTypeMeta.name, val, @@ -2322,89 +2350,92 @@ function rebuildModelCollectionForViewModelCollection< isCleanData: DataFreshness, purgeUnsaved: boolean ) { - if (!Array.isArray(currentValue)) { - currentValue = []; - } + ViewModelFactory.scope((factory) => { + if (!Array.isArray(currentValue)) { + currentValue = []; + } - let incomingLength = incomingValue.length; - let currentLength = currentValue.length; + let incomingLength = incomingValue.length; + let currentLength = currentValue.length; - // There are existing items. We need to surgically merge in the incoming items, - // keeping existing ViewModels the same based on keys. - const pkName = type.keyProp.name; - const existingItemsMap = new Map(); - const existingItemsWithoutPk = []; - for (let i = 0; i < currentLength; i++) { - const item = currentValue[i]; - const itemPk = item.$primaryKey; + // There are existing items. We need to surgically merge in the incoming items, + // keeping existing ViewModels the same based on keys. + const pkName = type.keyProp.name; + const existingItemsMap = new Map(); + const existingItemsWithoutPk = []; + for (let i = 0; i < currentLength; i++) { + const item = currentValue[i]; + const itemPk = item.$primaryKey; - if (itemPk) { - existingItemsMap.set(itemPk, item); - } else { - existingItemsWithoutPk.push(item); + if (itemPk) { + existingItemsMap.set(itemPk, item); + } else { + existingItemsWithoutPk.push(item); + } } - } - // Rebuild the currentValue array, using existing items when they exist, - // otherwise using the incoming items. + // Rebuild the currentValue array, using existing items when they exist, + // otherwise using the incoming items. - for (let i = 0; i < incomingLength; i++) { - const incomingItem = incomingValue[i]; - const incomingItemPk = incomingItem[pkName]; - const existingItem = existingItemsMap.get(incomingItemPk); + for (let i = 0; i < incomingLength; i++) { + const incomingItem = incomingValue[i]; + const incomingItemPk = incomingItem[pkName]; + const existingItem = existingItemsMap.get(incomingItemPk); - if (existingItem) { - existingItem.$loadFromModel(incomingItem, isCleanData); + if (existingItem) { + factory.set(incomingItem, existingItem); - if (currentValue[i] === existingItem) { - // The existing item is not moving position. Do nothing. - } else { - // Replace the item currently at this position with the existing item. - currentValue.splice(i, 1, existingItem); - } - } else { - // No need to $loadFromModel on the incoming item. - // The setter for the collection will transform its contents into ViewModels for us. + existingItem.$loadFromModel(incomingItem, isCleanData); - if (currentValue[i]) { - // There is something else already in the array at this position. Replace it. - currentValue.splice(i, 1, incomingItem); + if (currentValue[i] === existingItem) { + // The existing item is not moving position. Do nothing. + } else { + // Replace the item currently at this position with the existing item. + currentValue.splice(i, 1, existingItem); + } } else { - // Nothing in the current array at this position. Just stick it in. - currentValue.push(incomingItem); + const incomingVm = factory.get(type.name, incomingItem) as TItem; + + if (currentValue[i]) { + // There is something else already in the array at this position. Replace it. + currentValue.splice(i, 1, incomingVm); + } else { + // Nothing in the current array at this position. Just stick it in. + currentValue.push(incomingVm); + } } } - } - if (existingItemsWithoutPk.length && !purgeUnsaved) { - // Add to the end of the collection any existing items that do not have primary keys. - // This behavior exists to prevent losing items on the client - // that may not yet be saved in the event that the parent of the collection - // get reloaded from a save. - // If this behavior is undesirable in a specific circumstance, - // it is trivial to manually remove unsaved items after a .$save() is peformed. - - const existingItemsLength = existingItemsWithoutPk.length; - for (let i = 0; i < existingItemsLength; i++) { - const existingItem = existingItemsWithoutPk[i]; - const currentItem = currentValue[incomingLength]; + if (existingItemsWithoutPk.length && !purgeUnsaved) { + // Add to the end of the collection any existing items that do not have primary keys. + // This behavior exists to prevent losing items on the client + // that may not yet be saved in the event that the parent of the collection + // get reloaded from a save. + // If this behavior is undesirable in a specific circumstance, + // it is trivial to manually remove unsaved items after a .$save() is peformed. + + const existingItemsLength = existingItemsWithoutPk.length; + for (let i = 0; i < existingItemsLength; i++) { + const existingItem = existingItemsWithoutPk[i]; + const currentItem = currentValue[incomingLength]; + + if (existingItem === currentItem) { + // The existing item is not moving position. Do nothing. + } else { + // Replace the item currently at this position with the existing item. + currentValue.splice(incomingLength, 1, existingItem); + } - if (existingItem === currentItem) { - // The existing item is not moving position. Do nothing. - } else { - // Replace the item currently at this position with the existing item. - currentValue.splice(incomingLength, 1, existingItem); + incomingLength += 1; } - - incomingLength += 1; } - } - // If the new collection is shorter than the existing length, - // remove the extra items. - if (currentLength > incomingLength) { - currentValue.splice(incomingLength, currentLength - incomingLength); - } + // If the new collection is shorter than the existing length, + // remove the extra items. + if (currentLength > incomingLength) { + currentValue.splice(incomingLength, currentLength - incomingLength); + } + }, isCleanData); // Let the receiving ViewModelCollection handle the conversion of the contents // into ViewModel instances. diff --git a/src/coalesce-vue/test/viewmodel.spec.ts b/src/coalesce-vue/test/viewmodel.spec.ts index 47eb71bae..23f324889 100644 --- a/src/coalesce-vue/test/viewmodel.spec.ts +++ b/src/coalesce-vue/test/viewmodel.spec.ts @@ -174,6 +174,39 @@ describe("ViewModel", () => { }); }); + describe("$load", () => { + test("$load respects response caching", async () => { + let i = 1; + mockEndpoint("/ComplexModel/get/1", async (req) => { + await delay(10); + return { + wasSuccessful: true, + object: { complexModelId: 1, name: "Response " + i++ }, + }; + }); + + const makeVm = () => { + const vm = new ComplexModelViewModel(); + vm.$load.useResponseCaching({ maxAgeSeconds: 20 }); + return vm; + }; + + // Make the first caller and invoke it, which will populate the cache. + await makeVm().$load(1); + + // Make another caller. + const vm2 = makeVm(); + + // Upon first invocation, the cache will be used. + const loadPromise = vm2.$load(1); + expect(vm2.name).toBe("Response 1"); + + // After the real API call has a chance to finish, the new results should be loaded. + await loadPromise; + expect(vm2.name).toBe("Response 2"); + }); + }); + describe("$save", () => { const saveMock = mockItemResult(true, { studentId: 3, @@ -809,7 +842,9 @@ describe("ViewModel", () => { }, }, ]; - expect(JSON.parse(endpoint.mock.calls[0][0].data)).toMatchObject({ + + const parsed = JSON.parse(endpoint.mock.calls[0][0].data); + expect(parsed).toMatchObject({ items: expected, }); expect(preview.isDirty).toBeTruthy(); @@ -3317,6 +3352,37 @@ describe("ListViewModel", () => { expect(item1).toBe(list.$items[1]); expect(item0.name).toBe("Steve"); }); + + test("$load respects response caching", async () => { + let i = 1; + mockEndpoint("/ComplexModel/list", async (req) => { + await delay(10); + return { + wasSuccessful: true, + list: [{ complexModelId: 1, name: "Response " + i++ }], + }; + }); + + const makeList = () => { + const list = new ComplexModelListViewModel(); + list.$load.useResponseCaching({ maxAgeSeconds: 20 }); + return list; + }; + + // Make the first caller and invoke it, which will populate the cache. + await makeList().$load(); + + // Make another caller. + const list2 = makeList(); + + // Upon first invocation, the cache will be used. + const loadPromise = list2.$load(); + expect(list2.$items[0].name).toBe("Response 1"); + + // After the real API call has a chance to finish, the new results should be loaded. + await loadPromise; + expect(list2.$items[0].name).toBe("Response 2"); + }); }); describe("autoload", () => {