Skip to content

Commit

Permalink
fix: #494 ViewModel.$load and ListViewModel.$load not properly wo…
Browse files Browse the repository at this point in the history
…rking with `.useResponseCaching()`.
  • Loading branch information
ascott18 committed Jan 28, 2025
1 parent 1a7a76a commit 6734909
Show file tree
Hide file tree
Showing 3 changed files with 192 additions and 94 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
217 changes: 124 additions & 93 deletions src/coalesce-vue/src/viewmodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1516,21 +1527,32 @@ 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,
TItem
>(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.
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -1871,11 +1904,6 @@ function viewModelCollectionMapItems<T extends ViewModel, TModel extends Model>(
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,
Expand Down Expand Up @@ -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<any, TItem>();
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<any, TItem>();
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.
Expand Down
68 changes: 67 additions & 1 deletion src/coalesce-vue/test/viewmodel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, <Student>{
studentId: 3,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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", () => {
Expand Down

0 comments on commit 6734909

Please sign in to comment.