From 88f3909f73c45ad0d9e1185534bf08c852534e94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Sat, 10 Jan 2026 11:35:48 +0100 Subject: [PATCH 1/3] Experiment - collections with property keys --- .../Resources/Scripts/global-declarations.ts | 3 ++- .../Resources/Scripts/state-manager.ts | 25 ++++++++++++++++--- .../Scripts/tests/stateManagement.data.ts | 23 ++++++++++++++++- .../Scripts/tests/stateManagement.test.ts | 19 ++++++++++++++ 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/Framework/Framework/Resources/Scripts/global-declarations.ts b/src/Framework/Framework/Resources/Scripts/global-declarations.ts index cf7ffc5b73..3097b90783 100644 --- a/src/Framework/Framework/Resources/Scripts/global-declarations.ts +++ b/src/Framework/Framework/Resources/Scripts/global-declarations.ts @@ -272,7 +272,8 @@ type PropertyMetadata = { post?: "always" | "pathOnly" | "no"; update?: "always" | "no"; validationRules?: PropertyValidationRuleInfo[]; - clientExtenders?: ClientExtenderInfo[] + clientExtenders?: ClientExtenderInfo[]; + isKey?: boolean; } type TypeDefinition = string | diff --git a/src/Framework/Framework/Resources/Scripts/state-manager.ts b/src/Framework/Framework/Resources/Scripts/state-manager.ts index 6b596f58ab..ae47ca5fd2 100644 --- a/src/Framework/Framework/Resources/Scripts/state-manager.ts +++ b/src/Framework/Framework/Resources/Scripts/state-manager.ts @@ -10,6 +10,8 @@ import { hackInvokeNotifySubscribers } from "./utils/knockout"; import { logWarning } from "./utils/logging"; import {ValidationError} from "./validation/error"; import { errorsSymbol } from "./validation/common"; +import { getTypeProperties } from "./metadata/typeMap"; +import { primitiveTypes } from "./metadata/primitiveTypes"; export const currentStateSymbol = Symbol("currentState") @@ -385,12 +387,21 @@ function createWrappedObservable(initialValue: DeepReadonly, typeHint: Typ // notifiable observables // otherwise, we want to skip the big update whenever possible - Knockout tends to update everything in the DOM when // we update the observableArray - const skipUpdate = !observableWasSetFromOutside && oldContents instanceof Array && oldContents.length == newVal.length + const elementKeyProperties = getCollectionElementKeyProperties(); + const skipUpdate = !observableWasSetFromOutside && oldContents instanceof Array && oldContents.length == newVal.length && !elementKeyProperties; if (!skipUpdate) { const t: KnockoutObservableArray = obs as any - // take at most newVal.length from the old value - newContents = oldContents instanceof Array ? oldContents.slice(0, newVal.length) : [] + if (elementKeyProperties && oldContents instanceof Array) { + // synchronize based on key properties + const calculateKey = (item: any) => JSON.stringify(elementKeyProperties.map(p => ko.unwrap(ko.unwrap(item)?.[p]))); + const map = Object.fromEntries(oldContents.filter(i => ko.unwrap(i) !== null).map((item: any) => [calculateKey(item), item])); + newContents = newVal.map(i => map[calculateKey(i)]); + } + else { + // take at most newVal.length from the old value + newContents = oldContents instanceof Array ? oldContents.slice(0, newVal.length) : [] + } // then append (potential) new values into the array for (let index = 0; index < newVal.length; index++) { if (isDotvvmObservable(newContents[index])) { @@ -442,6 +453,14 @@ function createWrappedObservable(initialValue: DeepReadonly, typeHint: Typ return { newContents }; } + function getCollectionElementKeyProperties(): string[] | undefined { + if (typeHint instanceof Array && typeof typeHint[0] === "string" && !(typeHint[0] in primitiveTypes)) { + const props = getTypeProperties(typeHint[0]); + // TODO: validate that property type is primitive or nullable + return Object.entries(props).filter(e => e[1].isKey).map(e => e[0]); + } + } + obs[notifySymbol] = notify notify(initialValue) diff --git a/src/Framework/Framework/Resources/Scripts/tests/stateManagement.data.ts b/src/Framework/Framework/Resources/Scripts/tests/stateManagement.data.ts index b20693bb91..66760f25c3 100644 --- a/src/Framework/Framework/Resources/Scripts/tests/stateManagement.data.ts +++ b/src/Framework/Framework/Resources/Scripts/tests/stateManagement.data.ts @@ -17,7 +17,12 @@ initDotvvm({ P2: 2, P3: 3 }, - Inner2: null + Inner2: null, + ArrayWithKeys: [ + { $type: "t6", Id: 1, SubId: "a" }, + { $type: "t6", Id: 1, SubId: "b" }, + { $type: "t6", Id: 2, SubId: null } + ] }, typeMetadata: { t1: { @@ -51,6 +56,9 @@ initDotvvm({ DateTime: { type: { type: "nullable", inner: "DateTime" } }, Dynamic: { type: { type: "dynamic" } + }, + ArrayWithKeys: { + type: ["t6"] } } }, @@ -106,6 +114,19 @@ initDotvvm({ } } }, + t6: { + type: "object", + properties: { + "Id": { + type: "Int32", + "isKey": true + }, + "SubId": { + type: "String", + "isKey": true + } + } + }, e1: { type: "enum", isFlags: true, diff --git a/src/Framework/Framework/Resources/Scripts/tests/stateManagement.test.ts b/src/Framework/Framework/Resources/Scripts/tests/stateManagement.test.ts index b877c45561..3a5bf74097 100644 --- a/src/Framework/Framework/Resources/Scripts/tests/stateManagement.test.ts +++ b/src/Framework/Framework/Resources/Scripts/tests/stateManagement.test.ts @@ -851,3 +851,22 @@ test("ko.observableArray - clones pushed dotvvm object taken from elsewhere", () // expect(arr.state).toEqual(ko.toJS(arr)) // }) // }) + + +test("ko.observableArray - synchronizing objects by key", () => { + const ref0 = vm.ArrayWithKeys()[0]; + const ref1 = vm.ArrayWithKeys()[1]; + const ref2 = vm.ArrayWithKeys()[2]; + + s.patchState({ + ArrayWithKeys: [ + { Id: 2, SubId: null }, + { Id: 3, SubId: null } + ] + }); + s.doUpdateNow(); + + expect(vm.ArrayWithKeys()[0]).toBe(ref2); + expect(vm.ArrayWithKeys()[1]).not.toBe(ref0); + expect(vm.ArrayWithKeys()[1]).not.toBe(ref1); +}) From 43f0f0b367b01ad2e49648c482137e88e1d7b8c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Sat, 10 Jan 2026 12:57:30 +0100 Subject: [PATCH 2/3] Sample test with animation added --- .../Resources/Scripts/state-manager.ts | 7 +- .../ViewModelTypeMetadataSerializer.cs | 10 ++ src/Framework/Framework/tsconfig.jest.json | 6 +- .../CollectionKeys/CollectionKeysViewModel.cs | 58 +++++++++++ .../CollectionKeys/CollectionKeys.dothtml | 95 +++++++++++++++++++ 5 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 src/Samples/Common/ViewModels/FeatureSamples/CollectionKeys/CollectionKeysViewModel.cs create mode 100644 src/Samples/Common/Views/FeatureSamples/CollectionKeys/CollectionKeys.dothtml diff --git a/src/Framework/Framework/Resources/Scripts/state-manager.ts b/src/Framework/Framework/Resources/Scripts/state-manager.ts index ae47ca5fd2..94f68b2464 100644 --- a/src/Framework/Framework/Resources/Scripts/state-manager.ts +++ b/src/Framework/Framework/Resources/Scripts/state-manager.ts @@ -388,11 +388,11 @@ function createWrappedObservable(initialValue: DeepReadonly, typeHint: Typ // otherwise, we want to skip the big update whenever possible - Knockout tends to update everything in the DOM when // we update the observableArray const elementKeyProperties = getCollectionElementKeyProperties(); - const skipUpdate = !observableWasSetFromOutside && oldContents instanceof Array && oldContents.length == newVal.length && !elementKeyProperties; + const skipUpdate = !observableWasSetFromOutside && oldContents instanceof Array && oldContents.length == newVal.length && elementKeyProperties.length; if (!skipUpdate) { const t: KnockoutObservableArray = obs as any - if (elementKeyProperties && oldContents instanceof Array) { + if (elementKeyProperties.length && oldContents instanceof Array) { // synchronize based on key properties const calculateKey = (item: any) => JSON.stringify(elementKeyProperties.map(p => ko.unwrap(ko.unwrap(item)?.[p]))); const map = Object.fromEntries(oldContents.filter(i => ko.unwrap(i) !== null).map((item: any) => [calculateKey(item), item])); @@ -453,12 +453,13 @@ function createWrappedObservable(initialValue: DeepReadonly, typeHint: Typ return { newContents }; } - function getCollectionElementKeyProperties(): string[] | undefined { + function getCollectionElementKeyProperties(): string[] { if (typeHint instanceof Array && typeof typeHint[0] === "string" && !(typeHint[0] in primitiveTypes)) { const props = getTypeProperties(typeHint[0]); // TODO: validate that property type is primitive or nullable return Object.entries(props).filter(e => e[1].isKey).map(e => e[0]); } + return []; } obs[notifySymbol] = notify diff --git a/src/Framework/Framework/ViewModel/Serialization/ViewModelTypeMetadataSerializer.cs b/src/Framework/Framework/ViewModel/Serialization/ViewModelTypeMetadataSerializer.cs index a29931a037..bdd78d59c9 100644 --- a/src/Framework/Framework/ViewModel/Serialization/ViewModelTypeMetadataSerializer.cs +++ b/src/Framework/Framework/ViewModel/Serialization/ViewModelTypeMetadataSerializer.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Globalization; using System.IO; using System.Linq; @@ -150,6 +151,15 @@ private ObjectMetadataWithDependencies BuildObjectTypeMetadata(ViewModelSerializ JsonSerializer.Serialize(json, property.ClientExtenders, DefaultSerializerSettingsProvider.Instance.SettingsHtmlUnsafe); } + if (property.PropertyInfo.GetCustomAttribute() is { }) + { + if (!ReflectionUtils.IsPrimitiveType(property.Type)) + { + throw new NotSupportedException($"Property {property.Name} on type {map.Type} defines the [Key] attribute, but is not of a primitive type."); + } + json.WriteBoolean("isKey"u8, true); + } + json.WriteEndObject(); } diff --git a/src/Framework/Framework/tsconfig.jest.json b/src/Framework/Framework/tsconfig.jest.json index 5cc76d0cb9..dcf7a69b80 100644 --- a/src/Framework/Framework/tsconfig.jest.json +++ b/src/Framework/Framework/tsconfig.jest.json @@ -7,14 +7,14 @@ "noEmitOnError": false, "removeComments": false, "sourceMap": true, - "target": "ES2018", + "target": "ES2020", "declaration": true, "strictNullChecks": true, - "lib": [ "dom", "es2015.promise", "es5", "es6" ], + "lib": [ "dom", "ES2020" ], "module": "ES2015", "esModuleInterop": true, "skipLibCheck": true }, "exclude": [ "obj/**", "node_modules/**" ], "compileOnSave": false -} \ No newline at end of file +} diff --git a/src/Samples/Common/ViewModels/FeatureSamples/CollectionKeys/CollectionKeysViewModel.cs b/src/Samples/Common/ViewModels/FeatureSamples/CollectionKeys/CollectionKeysViewModel.cs new file mode 100644 index 0000000000..72cce100f4 --- /dev/null +++ b/src/Samples/Common/ViewModels/FeatureSamples/CollectionKeys/CollectionKeysViewModel.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DotVVM.Framework.ViewModel; +using DotVVM.Framework.Hosting; + +namespace DotVVM.Samples.Common.ViewModels.FeatureSamples.CollectionKeys +{ + public class CollectionKeysViewModel : DotvvmViewModelBase + { + public string NewTaskTitle { get; set; } + + public List Tasks { get; set; } + + public CollectionKeysViewModel() + { + Tasks = new List(); + } + + public override Task Init() + { + if (!Context.IsPostBack) + { + Tasks.Add(new TaskViewModel() { IsCompleted = false, TaskId = Guid.NewGuid(), Title = "Do the laundry" }); + Tasks.Add(new TaskViewModel() { IsCompleted = false, TaskId = Guid.NewGuid(), Title = "Wash the car" }); + Tasks.Add(new TaskViewModel() { IsCompleted = false, TaskId = Guid.NewGuid(), Title = "Go shopping" }); + } + + return base.Init(); + } + + public void AddTask() + { + Tasks.Add(new TaskViewModel() { Title = NewTaskTitle, TaskId = Guid.NewGuid() }); + NewTaskTitle = string.Empty; + } + + public void CompleteTask(Guid id) + { + Tasks.RemoveAll(t => t.TaskId == id); + } + + } + + public class TaskViewModel + { + [Key] + public Guid TaskId { get; set; } + + public string Title { get; set; } + + public bool IsCompleted { get; set; } + } + +} diff --git a/src/Samples/Common/Views/FeatureSamples/CollectionKeys/CollectionKeys.dothtml b/src/Samples/Common/Views/FeatureSamples/CollectionKeys/CollectionKeys.dothtml new file mode 100644 index 0000000000..a5d1353b24 --- /dev/null +++ b/src/Samples/Common/Views/FeatureSamples/CollectionKeys/CollectionKeys.dothtml @@ -0,0 +1,95 @@ +@viewModel DotVVM.Samples.Common.ViewModels.FeatureSamples.CollectionKeys.CollectionKeysViewModel, DotVVM.Samples.Common + + + + + + Hello from DotVVM! + + + + + + function afterRender(nodes) { + for (let child of nodes) { + if (child.nodeType === Node.ELEMENT_NODE) { + child.classList.add("show"); + } + } + } + function afterAdd(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.classList.remove("show"); + window.requestAnimationFrame(() => { + node.classList.add("show"); + }, 1); + } + } + function beforeRemove(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.addEventListener("transitionend", () => { + node.remove(); + }, { once: true }); + node.classList.remove("show"); + } else { + node.remove(); + } + } + + function makeAnimatedValueAccessor(valueAccessor) { + return () => ({ + ...valueAccessor(), + afterRender, + afterAdd, + beforeRemove + }); + } + + const originalTemplateHandler = ko.bindingHandlers["template"]; + ko.bindingHandlers["template"] = { + init: function (element, valueAccessor, allBindings, viewModel, bindingContext) { + return originalTemplateHandler['init'](element, makeAnimatedValueAccessor(valueAccessor), allBindings, viewModel, bindingContext); + }, + 'update': function (element, valueAccessor, allBindings, viewModel, bindingContext) { + return originalTemplateHandler['update'](element, makeAnimatedValueAccessor(valueAccessor), allBindings, viewModel, bindingContext); + } + }; + + +
+

Task List

+ +
+
+ Add Task + +

Title:

+

+
+
+ +

 

+ + + + + + + + +
{{value: Title}} + +
+
+ + From bb78d4dcd6cb76e8bb52703f75cdb5f571599027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Herceg?= Date: Mon, 12 Jan 2026 21:21:40 +0100 Subject: [PATCH 3/3] Object key functions moved to typeMap, duplicate key handling implemented (no tests yet) --- .../Resources/Scripts/metadata/typeMap.ts | 23 ++++++++++ .../Resources/Scripts/state-manager.ts | 46 +++++++++++-------- 2 files changed, 51 insertions(+), 18 deletions(-) diff --git a/src/Framework/Framework/Resources/Scripts/metadata/typeMap.ts b/src/Framework/Framework/Resources/Scripts/metadata/typeMap.ts index 7cff6ed289..f2f702c01a 100644 --- a/src/Framework/Framework/Resources/Scripts/metadata/typeMap.ts +++ b/src/Framework/Framework/Resources/Scripts/metadata/typeMap.ts @@ -1,4 +1,5 @@ import { keys } from "../utils/objects"; +import { primitiveTypes } from "./primitiveTypes"; let types: TypeMap = {}; @@ -92,3 +93,25 @@ export function formatTypeName(type: TypeDefinition, prefix = "", suffix = ""): const typeCheck: never = type return undefined as any } + +type KeyFunction = (item: any) => string; +const keyFunctions: { [name: string]: KeyFunction | undefined } = {}; +export function tryGetKeyFunction(type: string): KeyFunction | undefined { + if (type in keyFunctions) { + return keyFunctions[type]; + } + return keyFunctions[type] = buildKeyFunction(type); +} +function buildKeyFunction(type: string): KeyFunction | undefined { + if (!(type in primitiveTypes)) { + const typeInfo = getTypeInfo(type); + if (typeInfo.type === "object") { + const props = getTypeProperties(type); + // NB: validation that property type is primitive or nullable is done on the server + const keyProperties = Object.entries(props).filter(e => e[1].isKey).map(e => e[0]); + if (keyProperties.length) { + return (item: any) => JSON.stringify(keyProperties.map(p => ko.unwrap(ko.unwrap(item)?.[p]))); + } + } + } +} \ No newline at end of file diff --git a/src/Framework/Framework/Resources/Scripts/state-manager.ts b/src/Framework/Framework/Resources/Scripts/state-manager.ts index 94f68b2464..6574a6d9bc 100644 --- a/src/Framework/Framework/Resources/Scripts/state-manager.ts +++ b/src/Framework/Framework/Resources/Scripts/state-manager.ts @@ -3,7 +3,7 @@ import { createArray, defineConstantProperty, isPrimitive, keys } from "./utils/objects"; import { DotvvmEvent } from "./events"; import { extendToObservableArrayIfRequired } from "./serialization/deserialize" -import { areObjectTypesEqual, formatTypeName, getObjectTypeInfo } from "./metadata/typeMap"; +import { areObjectTypesEqual, formatTypeName, getObjectTypeInfo, tryGetKeyFunction } from "./metadata/typeMap"; import { coerce } from "./metadata/coercer"; import { patchViewModel } from "./postback/updater"; import { hackInvokeNotifySubscribers } from "./utils/knockout"; @@ -315,6 +315,7 @@ function logObservableCloneWarning(value: any) { function createWrappedObservable(initialValue: DeepReadonly, typeHint: TypeDefinition | undefined, getter: () => DeepReadonly | undefined, updater: UpdateDispatcher): DeepKnockoutObservable { let isUpdating = false + const arrayElementKeyFunction = (typeHint instanceof Array && typeof typeHint[0] === "string") ? tryGetKeyFunction(typeHint[0]) : void 0; function observableValidator(this: KnockoutObservable, newValue: any): any { if (isUpdating) return { newValue, notifySubscribers: false } @@ -387,16 +388,34 @@ function createWrappedObservable(initialValue: DeepReadonly, typeHint: Typ // notifiable observables // otherwise, we want to skip the big update whenever possible - Knockout tends to update everything in the DOM when // we update the observableArray - const elementKeyProperties = getCollectionElementKeyProperties(); - const skipUpdate = !observableWasSetFromOutside && oldContents instanceof Array && oldContents.length == newVal.length && elementKeyProperties.length; + const skipUpdate = !observableWasSetFromOutside && oldContents instanceof Array && oldContents.length == newVal.length && !arrayElementKeyFunction; if (!skipUpdate) { const t: KnockoutObservableArray = obs as any - if (elementKeyProperties.length && oldContents instanceof Array) { - // synchronize based on key properties - const calculateKey = (item: any) => JSON.stringify(elementKeyProperties.map(p => ko.unwrap(ko.unwrap(item)?.[p]))); - const map = Object.fromEntries(oldContents.filter(i => ko.unwrap(i) !== null).map((item: any) => [calculateKey(item), item])); - newContents = newVal.map(i => map[calculateKey(i)]); + if (arrayElementKeyFunction && oldContents instanceof Array) { + // build a map of keys from non-null items in the old array while handling items with the same key + const oldElementsMap: { [name: string]: any[] } = {}; + for (let [key, value] of oldContents.filter(i => ko.unwrap(i) !== null).map((item: any) => [arrayElementKeyFunction(item), item])) { + if (!(key in oldElementsMap)) { + oldElementsMap[key] = [value] + } else { + oldElementsMap[key].push(value) + } + } + + // try to reuse existing references in the new array while handling items with the same key + newContents = Array(newVal.length); + for (let index = 0; index < newVal.length; index++) { + const key = arrayElementKeyFunction(newVal[index]); + const oldElement = oldElementsMap[key]; + if (oldElement) { + newContents[index] = oldElement.shift(); + if (!oldElement.length) { + delete oldElementsMap[key]; + } + } + } + } else { // take at most newVal.length from the old value @@ -452,16 +471,7 @@ function createWrappedObservable(initialValue: DeepReadonly, typeHint: Typ // return a result indicating that the observable needs to be set return { newContents }; } - - function getCollectionElementKeyProperties(): string[] { - if (typeHint instanceof Array && typeof typeHint[0] === "string" && !(typeHint[0] in primitiveTypes)) { - const props = getTypeProperties(typeHint[0]); - // TODO: validate that property type is primitive or nullable - return Object.entries(props).filter(e => e[1].isKey).map(e => e[0]); - } - return []; - } - + obs[notifySymbol] = notify notify(initialValue)