Skip to content

Commit

Permalink
Maybe fix #1903: markup control property values are wrapped recursive…
Browse files Browse the repository at this point in the history
…ly with observables

These observables need to have .state and friends in order to work correctly
in staticCommands. In theory, the mapper should only process objects created
inline in the binding expression (such as in LINQ operators), meaning it's
unlikely to be a large object. Therefore, we just re-create the hierarchy on
each update, trying to re-use any subobject which is already wrapped
with `DotvvmObservable`s
  • Loading branch information
exyi committed Jan 21, 2025
1 parent d4f5de5 commit 1171231
Show file tree
Hide file tree
Showing 4 changed files with 60 additions and 22 deletions.
Original file line number Diff line number Diff line change
@@ -1,50 +1,52 @@
import { deserialize } from '../serialization/deserialize'
import { currentStateSymbol, unmapKnockoutObservables } from '../state-manager';
import { isDotvvmObservable, isFakeObservableObject, unmapKnockoutObservables } from '../state-manager';
import { logWarning } from '../utils/logging';
import { defineConstantProperty, keys } from '../utils/objects';
import { defineConstantProperty, isPrimitive, keys } from '../utils/objects';
import * as manager from '../viewModules/viewModuleManager';

function isCommand(value: any, prop: string) {
return !ko.isObservable(value[prop]) && typeof value[prop] === 'function';
}

function createWrapperComputed<T>(accessor: () => KnockoutObservable<T> | T, propertyDebugInfo: string | null = null) {
const computed = ko.pureComputed({
/** Wraps a function returning observable to make sure we have a single observable which we will not need to replace even if accessor returns a different instance. */
function createWrapperComputed<T>(valueAccessor: () => T,
observableAccessor: () => KnockoutObservable<T> | T = valueAccessor,
propertyDebugInfo: string | null = null) {
const computed = ko.pureComputed<T>({
read() {
return ko.unwrap(accessor());
return valueAccessor();
},
write(value: T) {
const val = accessor();
const val = observableAccessor();
if (ko.isWriteableObservable(val)) {
val(value);
} else {
logWarning("binding-handler", `Attempted to write to readonly property` + (!propertyDebugInfo ? `` : ` ` + propertyDebugInfo) + `.`);
}
}
});
(computed as any)["wrappedProperty"] = accessor;
(computed as any)["wrappedProperty"] = observableAccessor;
Object.defineProperty(computed, "state", {
get: () => {
const x = accessor() as any
const x = observableAccessor() as any
return (x && x.state) ?? unmapKnockoutObservables(x, true)
}
})
defineConstantProperty(computed, "setState", (state: any) => {
const x = accessor() as any
const x = observableAccessor() as any
if (compileConstants.debug && typeof x.setState != "function") {
throw new Error(`Cannot set state of property ${propertyDebugInfo}.`)
}
x.setState(state)
})
defineConstantProperty(computed, "patchState", (state: any) => {
const x = accessor() as any
const x = observableAccessor() as any
if (compileConstants.debug && typeof x.patchState != "function") {
throw new Error(`Cannot patch state of property ${propertyDebugInfo}.`)
}
x.patchState(state)
})
defineConstantProperty(computed, "updateState", (updateFunction: (state: any) => any) => {
const x = accessor() as any
const x = observableAccessor() as any
if (compileConstants.debug && typeof x.updateState != "function") {
throw new Error(`Cannot patch state of property ${propertyDebugInfo}.`)
}
Expand All @@ -53,6 +55,45 @@ function createWrapperComputed<T>(accessor: () => KnockoutObservable<T> | T, pro
return computed;
}

/** Similar to createWrapperComputed, but makes sure the entire object tree pretends to be observables from DotVVM viewmodel
* -- i.e. with state, setState, patchState and updateState methods.
* The function assumes that the object hierarchy which needs wrapping is relatively small or updates are rare and simply replaces everything
* when the accessor value changes. */
function createWrapperComputedRecursive<T>(accessor: () => KnockoutObservable<T> | T,
propertyDebugInfo: string | null = null) {
return createWrapperComputed<T>(/*valueAccessor:*/ () => processValue(accessor, accessor()),
/*observableAccessor:*/ accessor,
propertyDebugInfo)

function processValue(accessor: () => KnockoutObservable<unknown> | unknown, value: unknown): any {
const unwrapped = ko.unwrap(value)
// skip if:
// * primitive: don't need any nested wrapping
// * DotVVM VM observable: assume already wrapped recursively
// * DotVVM FakeObservableObject: again, it's already wrapped recursively
if (isPrimitive(unwrapped) || isDotvvmObservable(value) || isFakeObservableObject(unwrapped)) {
return unwrapped
}

if (Array.isArray(unwrapped)) {
return unwrapped.map((item, index) => makeConstantObservable(() => ko.unwrap(accessor() as any)?.[index], item))
} else {
return Object.freeze(Object.fromEntries(
Object.entries(unwrapped as object).map(([prop, value]) =>
[prop, makeConstantObservable(() => ko.unwrap(accessor() as any)?.[prop], value)]
)
))
}
}

function makeConstantObservable(accessor: () => KnockoutObservable<unknown> | unknown, value: unknown) {
// the value in observable is constant, we'll create new one if accessor returns new value
// however, this process is asynchronnous, so for writes and `state`, `setState`, ... calls we call it again to be sure
const processed = processValue(accessor, value)
return createWrapperComputed(() => processed, accessor, propertyDebugInfo)
}
}

function prepareViewModuleContexts(element: HTMLElement, value: any, properties: object) {
if (compileConstants.debug && value.modules.length == 0) {
throw new Error(`dotvvm-with-view-modules binding was used without any modules.`)
Expand Down Expand Up @@ -80,14 +121,9 @@ export function wrapControlProperties(valueAccessor: () => any) {
const commandFunction = value[prop];
value[prop] = createWrapperComputed(() => commandFunction);
} else {
value[prop] = createWrapperComputed(
() => {
const value = valueAccessor()[prop];
// if it's observable or FakeObservableObject, we assume that we don't need to wrap it in observables.
const isWrapped = ko.isObservable(value) || (value && typeof value == 'object' && currentStateSymbol in value)
return isWrapped ? value : deserialize(value)
},
`'${prop}' at '${valueAccessor.toString()}'`);
value[prop] = createWrapperComputedRecursive(
() => valueAccessor()[prop],
compileConstants.debug ? `'${prop}' at '${valueAccessor}'` : prop);
}
}
return value
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { wrapObservable } from '../utils/knockout';
import { deserialize } from '../serialization/deserialize';
import { updateTypeInfo } from '../metadata/typeMap';

export function showUploadDialog(sender: HTMLElement) {
Expand Down
4 changes: 4 additions & 0 deletions src/Framework/Framework/Resources/Scripts/state-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,10 @@ export function isDotvvmObservable(obj: any): obj is DotvvmObservable<any> {
return obj?.[notifySymbol] && ko.isObservable(obj)
}

export function isFakeObservableObject(obj: any): obj is FakeObservableObject<any> {
return obj instanceof FakeObservableObject
}

/**
* Recursively unwraps knockout observables from the object / array hierarchy. When nothing needs to be unwrapped, the original object is returned.
* @param allowStateUnwrap Allows accessing [currentStateSymbol], which makes it faster, but doesn't register in the knockout dependency tracker
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { getViewModelObservable } from "../dotvvm-base";
import { deserialize } from "../serialization/deserialize";
import { serialize } from "../serialization/serialize";
import { unmapKnockoutObservables } from "../state-manager";
import { debugQuoteString } from "../utils/logging";
Expand Down

0 comments on commit 1171231

Please sign in to comment.