diff --git a/src/Framework/Framework/Resources/Scripts/dotvvm-root.ts b/src/Framework/Framework/Resources/Scripts/dotvvm-root.ts index 27e9db671..640cdac1f 100644 --- a/src/Framework/Framework/Resources/Scripts/dotvvm-root.ts +++ b/src/Framework/Framework/Resources/Scripts/dotvvm-root.ts @@ -91,7 +91,8 @@ const dotvvmExports = { getStateManager().patchState(a) }, setState(a: any) { getStateManager().setState(a) }, - updateState(updateFunction: StateUpdate) { getStateManager().update(updateFunction) }, + updateState(updateFunction: StateUpdate) { getStateManager().updateState(updateFunction) }, + get rootStateManager() { return getStateManager() }, viewModelObservables: { get root() { return getViewModelObservable(); } }, diff --git a/src/Framework/Framework/Resources/Scripts/postback/http.ts b/src/Framework/Framework/Resources/Scripts/postback/http.ts index 9a09d0619..2eb28fec5 100644 --- a/src/Framework/Framework/Resources/Scripts/postback/http.ts +++ b/src/Framework/Framework/Resources/Scripts/postback/http.ts @@ -86,7 +86,7 @@ export async function retryOnInvalidCsrfToken(postbackFunction: () => P if (err.reason.type === "serverError") { if (err.reason.responseObject?.action === "invalidCsrfToken") { logInfoVerbose("postback", "Resending postback due to invalid CSRF token."); - getStateManager().update(u => ({ ...u, $csrfToken: undefined })) + getStateManager().updateState(u => ({ ...u, $csrfToken: undefined })) if (iteration < 3) { return await retryOnInvalidCsrfToken(postbackFunction, iteration + 1); diff --git a/src/Framework/Framework/Resources/Scripts/state-manager.ts b/src/Framework/Framework/Resources/Scripts/state-manager.ts index db436af24..2142577f6 100644 --- a/src/Framework/Framework/Resources/Scripts/state-manager.ts +++ b/src/Framework/Framework/Resources/Scripts/state-manager.ts @@ -32,13 +32,21 @@ export type UpdatableObjectExtensions = { [updateSymbol]?: UpdateDispatcher } -export class StateManager { +/** Manages the consistency of DotVVM immutable ViewModel state object with the knockout observables. + * Knockout observables are by-default updated asynchronously after a state change, but the synchronization can be forced by calling `doUpdateNow`. + * The newState event is also called asynchronously right before the knockout observables are updated. + * Changes from observables to state are immediate. + */ +export class StateManager implements DotvvmStateContainer { + /** The knockout observable containing the root objects, equivalent to `dotvvm.viewModels.root.viewModel` */ public readonly stateObservable: DeepKnockoutObservable; private _state: DeepReadonly + /** Returns the current */ public get state() { return this._state } private _isDirty: boolean = false; + /** Indicates whether there is a pending update of the knockout observables. */ public get isDirty() { return this._isDirty } @@ -49,17 +57,20 @@ export class StateManager { public stateUpdateEvent?: DotvvmEvent> ) { this._state = coerce(initialState, initialState.$type || { type: "dynamic" }) - this.stateObservable = createWrappedObservable(initialState, (initialState as any)["$type"], () => this._state, u => this.update(u as any)) + this.stateObservable = createWrappedObservable(initialState, (initialState as any)["$type"], () => this._state, u => this.updateState(u as any)) this.dispatchUpdate() } - public dispatchUpdate() { + private dispatchUpdate() { if (!this._isDirty) { this._isDirty = true; this._currentFrameNumber = window.requestAnimationFrame(this.rerender.bind(this)) } } + /** Performs a synchronous update of knockout observables with the data currently stored in `state`. + * Consequently, if ko.options.deferUpdates is false (default), the UI will be updated immediately. + * If ko.options.deferUpdates is true, the UI can be manually updated by also calling the `ko.tasks.runEarly()` function. */ public doUpdateNow() { if (this._currentFrameNumber !== null) window.cancelAnimationFrame(this._currentFrameNumber); @@ -86,6 +97,13 @@ export class StateManager { //logInfoVerbose("New state dispatched, t = ", performance.now() - time, "; t_cpu = ", performance.now() - realStart); } + /** Sets a new view model state, after checking its type compatibility and possibly performing implicit conversions. + * Only the changed objects are re-checked and updated in the knockout observables. + * It is therefore recommended to clone only the changed parts of the view model using the `{... x, ChangedProp: 1 }` syntax. + * In the rarely occuring complex cases where this is difficult, you can use `structuredClone` to obtain a writable clone of some part of the viewmodel. + * + * @throws CoerceError if the new state has incompatible type. + * @returns The type-coerced version of the new state. */ public setState(newState: DeepReadonly): DeepReadonly { if (compileConstants.debug && newState == null) throw new Error("State can't be null or undefined.") if (newState === this._state) return newState @@ -98,13 +116,21 @@ export class StateManager { return this._state = coercionResult } - public patchState(patch: Partial): DeepReadonly { + /** Applies a patch to the current view model state. + * @throws CoerceError if the new state has incompatible type. + * @returns The type-coerced version of the new state. */ + public patchState(patch: DeepReadonly>): DeepReadonly { return this.setState(patchViewModel(this._state, patch)) } - public update(updater: StateUpdate) { + /** Updates the view model state using the provided `State => State` function. + * @throws CoerceError if the new state has incompatible type. + * @returns The type-coerced version of the new state. */ + public updateState(updater: StateUpdate) { return this.setState(updater(this._state)) } + /** @deprecated Use updateState method instead */ + public update: UpdateDispatcher = this.updateState; } class FakeObservableObject implements UpdatableObjectExtensions {