diff --git a/incremental/runtime/src/index.ts b/incremental/runtime/src/index.ts index 67697b5ef364e01bb0ba276f3668317508322805..6c584c0f683009d091e4b849f80924a936cce073 100644 --- a/incremental/runtime/src/index.ts +++ b/incremental/runtime/src/index.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2024 Huawei Device Co., Ltd. + * Copyright (c) 2022-2025 Huawei Device Co., Ltd. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -97,6 +97,7 @@ export { memoLifecycle, once, remember, + rememberArrayState, rememberComputableState, rememberComputableValue, rememberControlledScope, @@ -125,12 +126,14 @@ export { } from "./states/Disposable" export { GlobalStateManager, + arrayState, callScheduledCallbacks, mutableState, scheduleCallback, updateStateManager, } from "./states/GlobalStateManager" export { + ArrayState, CONTEXT_ROOT_NODE, CONTEXT_ROOT_SCOPE, ComputableState, diff --git a/incremental/runtime/src/memo/remember.ts b/incremental/runtime/src/memo/remember.ts index bde74667204a064f36cc6b9b4b4d3d3382e24611..090c1a8b98e921b68755b5f7688c5ba3a0b7f437 100644 --- a/incremental/runtime/src/memo/remember.ts +++ b/incremental/runtime/src/memo/remember.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2024 Huawei Device Co., Ltd. + * Copyright (c) 2022-2025 Huawei Device Co., Ltd. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -16,7 +16,7 @@ import { functionOverValue } from "@koalaui/common" import { __context, __id } from "../internals" import { scheduleCallback } from "../states/GlobalStateManager" -import { ControlledScope, MutableState } from "../states/State" +import { ArrayState, ControlledScope, MutableState } from "../states/State" /** * It calculates the value of the given lambda and caches its result. @@ -127,6 +127,18 @@ export function rememberMutableState(initial: (() => Value) | Value): Mut ) } +/** + * Creates remembered array state which can be updated from anywhere, + * and if changed - all dependent memo functions recache automatically. + * @param initial - initial array supplier used on the state creation + * @returns an array state remembered for the current code position + * @memo:intrinsic + */ +export function rememberArrayState(initial?: () => ReadonlyArray): ArrayState { + const scope = __context().scope>(__id(), 0, undefined, undefined, undefined, true) // do not recalculate if used states were updated + return scope.unchanged ? scope.cached : scope.recache(__context().arrayState(initial?.())) +} + /** * @param promise - result of asynchronous function * @param state - state to receive computed value on success or `undefined` value on error diff --git a/incremental/runtime/src/states/GlobalStateManager.ts b/incremental/runtime/src/states/GlobalStateManager.ts index 1aa4542076a20f00a7d4b5047bed13e7730c64ed..0371bac5c805f942c76b91c6acec325323b48eeb 100644 --- a/incremental/runtime/src/states/GlobalStateManager.ts +++ b/incremental/runtime/src/states/GlobalStateManager.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2024 Huawei Device Co., Ltd. + * Copyright (c) 2022-2025 Huawei Device Co., Ltd. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -13,7 +13,7 @@ * limitations under the License. */ -import { Equivalent, MutableState, StateManager, ValueTracker, createStateManager } from "./State" +import { ArrayState, Equivalent, MutableState, StateManager, ValueTracker, createStateManager } from "./State" /** * This class provides an access to the global state manager of the application. @@ -87,3 +87,16 @@ export function scheduleCallback(callback?: () => void, manager: StateManager = export function mutableState(value: T, equivalent?: Equivalent, tracker?: ValueTracker): MutableState { return GlobalStateManager.instance.mutableState(value, undefined, equivalent, tracker) } + +/** + * Creates new mutable array state in the global state manager. + * This state is valid until it is manually detached from the manager. + * It will be detached automatically if it is in the {@link remember}. + * Note that thoughtless state disposing can lead to memory leaks. + * @param array - initial array to initialize the created state + * @param equivalent - optional value comparator for a state + * @returns new mutable array state trackable by memo-functions + */ +export function arrayState(array?: ReadonlyArray, equivalent?: Equivalent): ArrayState { + return GlobalStateManager.instance.arrayState(array, undefined, equivalent) +} diff --git a/incremental/runtime/src/states/Journal.ts b/incremental/runtime/src/states/Journal.ts index 5cb94280bef2f4c93a88ec1a994cbdc51311ea8d..dc11866949fee4d9f0a8f806a415ebe38881bc55 100644 --- a/incremental/runtime/src/states/Journal.ts +++ b/incremental/runtime/src/states/Journal.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2024 Huawei Device Co., Ltd. + * Copyright (c) 2022-2025 Huawei Device Co., Ltd. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -77,6 +77,23 @@ export class Journal implements Changes { clear(): void { this.current.value = new Chunk() } + + /** + * Returns the cached array to store changes. + * The copy of the given array is created on first change. + * @param state - the corresponding array state + * @param array - current snapshot of the array state + * @returns the cached array registered for the given state + * @experimental + */ + getCachedArray(state: Object, array: Array): Array { + const chunk = this.current.value + const change = chunk.get>(state) + if (change) return change.value + const copy = Array.from(find>(state, chunk.previous.value)?.value ?? array) + chunk.map.set(state, new AtomicRef>(copy)) + return copy + } } class ChunkChanges implements Changes { @@ -98,6 +115,12 @@ class ChunkChanges implements Changes { class Chunk { readonly previous = new AtomicRef(undefined) readonly map = new Map() + + get(state: Object): AtomicRef | undefined { + const change = this.map.get(state) + if (change) return change as AtomicRef + return undefined + } } /** @@ -109,8 +132,8 @@ class Chunk { */ function find(state: Object, chunk?: Chunk): AtomicRef | undefined { while (chunk) { - const change = chunk?.map.get(state) - if (change) return change as AtomicRef + const change = chunk?.get(state) + if (change) return change chunk = chunk?.previous.value } return undefined diff --git a/incremental/runtime/src/states/State.ts b/incremental/runtime/src/states/State.ts index 8112d182df81ff6cf6ae99cd4b27f4b898869296..5923d582b912aab4a8e4e19a688ff958167c6513 100644 --- a/incremental/runtime/src/states/State.ts +++ b/incremental/runtime/src/states/State.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2024 Huawei Device Co., Ltd. + * Copyright (c) 2022-2025 Huawei Device Co., Ltd. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -83,6 +83,25 @@ export interface MutableState extends Disposable, State { value: Value } +/** + * Individual mutable state, wrapping an array of elements with the specified type. + */ +export interface ArrayState extends State> { + length: number + at(index: number): Item + get(index: number): Item + set(index: number, item: Item): void + copyWithin(target: number, start: number, end?: number): Array + fill(value: Item, start?: number, end?: number): Array + pop(): Item | undefined + push(...items: Item[]): number + reverse(): Array + shift(): Item | undefined + sort(comparator?: (a: Item, b: Item) => number): Array + splice(start: number, deleteCount: number | undefined, ...items: Item[]): Array + unshift(...items: Item[]): number +} + /** * Individual computable state that provides recomputable value of type `Value`. */ @@ -105,6 +124,7 @@ export interface StateContext { compute(id: KoalaCallsiteKey, compute: () => Value, cleanup?: (value: Value | undefined) => void, once?: boolean): Value computableState(compute: (context: StateContext) => Value, cleanup?: (context: StateContext, value: Value | undefined) => void): ComputableState mutableState(initial: Value, global?: boolean, equivalent?: Equivalent, tracker?: ValueTracker): MutableState + arrayState(initial?: ReadonlyArray, global?: boolean, equivalent?: Equivalent): ArrayState namedState(name: string, create: () => Value, global?: boolean, equivalent?: Equivalent, tracker?: ValueTracker): MutableState stateBy(name: string, global?: boolean): MutableState | undefined valueBy(name: string, global?: boolean): Value @@ -185,13 +205,13 @@ interface ManagedScope extends Disposable, Dependency, ReadonlyTreeNode { } class StateImpl implements Observable, ManagedState, MutableState { - private manager: StateManagerImpl | undefined = undefined + protected manager: StateManagerImpl | undefined = undefined private dependencies: Dependencies | undefined = undefined - private snapshot: Value - private myModified = false - private myUpdated = true + protected snapshot: Value + protected myModified = false + protected myUpdated = true private readonly myGlobal: boolean - private equivalent: Equivalent | undefined = undefined + protected equivalent: Equivalent | undefined = undefined private tracker: ValueTracker | undefined = undefined private name: string | undefined = undefined @@ -231,7 +251,7 @@ class StateImpl implements Observable, ManagedState, MutableState } set value(value: Value) { - if (this.setProhibited) throw new Error("prohibited to modify a state when updating a call tree") + this.checkSetProhibited() const tracker = this.tracker if (tracker) value = tracker.onUpdate(value) const manager = this.manager @@ -258,12 +278,12 @@ class StateImpl implements Observable, ManagedState, MutableState } } - private get setProhibited(): boolean { - if (this.dependencies?.empty != false) return false // no dependencies + checkSetProhibited() { + if (this.dependencies?.empty != false) return // no dependencies const scope = this.manager?.current - if (scope === undefined) return false // outside the incremental update - if (scope?.node === undefined && scope?.parent === undefined) return false // during animation - return true + if (scope === undefined) return // outside the incremental update + if (scope?.node === undefined && scope?.parent === undefined) return // during animation + throw new Error("prohibited to modify a state when updating a call tree") } private current(changes?: Changes): Value { @@ -282,7 +302,7 @@ class StateImpl implements Observable, ManagedState, MutableState this.dependencies?.updateDependencies(this.myModified) } - private applyStateSnapshot(newValue: Value) { + protected applyStateSnapshot(newValue: Value) { const oldValue = this.snapshot const isModified = ObservableHandler.dropModified(oldValue) if (!refEqual(oldValue, newValue)) { @@ -319,6 +339,96 @@ class StateImpl implements Observable, ManagedState, MutableState } } +class ArrayStateImpl extends StateImpl> implements ArrayState { + constructor(manager: StateManagerImpl, initial: Array, global: boolean, equivalent?: Equivalent) { + super(manager, initial, global, (oldArray: Array, newArray: Array): boolean => { + let i = oldArray.length + if (i != newArray.length) return false + while (0 < i--) { + if (isModified(oldArray[i], newArray[i], equivalent)) return false + } + return true + }) + } + + protected override applyStateSnapshot(newValue: Array) { + const modified = isModified>(this.snapshot, newValue, this.equivalent) + if (modified) this.snapshot = newValue + this.myModified = modified + } + + get length(): number { + return this.value.length + } + + set length(value: number) { + this.mutable.length = value + } + + at(index: number): Item { + const array = this.value + return array[index < 0 ? array.length + index : index] + } + + get(index: number): Item { + return this.value[index] + } + + set(index: number, item: Item): void { + this.mutable[index] = item + } + + copyWithin(target: number, start: number, end?: number): Array { + return this.mutable.copyWithin(target, start, end) + } + + fill(value: Item, start?: number, end?: number): Array { + return this.mutable.fill(value, start, end) + } + + pop(): Item | undefined { + return this.mutable.pop() + } + + push(...items: Item[]): number { + return this.mutable.push(...items) + } + + reverse(): Array { + return this.mutable.reverse() + } + + shift(): Item | undefined { + return this.mutable.shift() + } + + sort(comparator?: (a: Item, b: Item) => number): Array { + return this.mutable.sort(comparator) + } + + splice(start: number, deleteCount: number | undefined, ...items: Item[]): Array { + const array = this.mutable + return array.splice(start, deleteCount ?? array.length, ...items) + } + + unshift(...items: Item[]): number { + return this.mutable.unshift(...items) + } + + private get mutable(): Array { + super.checkSetProhibited() + const manager = this.manager + if (manager) { + manager.updateNeeded = true + this.myUpdated = false + return manager.journal.getCachedArray(this, this.snapshot) + } else { + this.myModified = true + return this.snapshot + } + } +} + class ParameterImpl implements MutableState { private manager: StateManagerImpl | undefined = undefined private dependencies: Dependencies | undefined = undefined @@ -501,11 +611,21 @@ class StateManagerImpl implements StateManager { this.callbacks.callCallbacks() } + private isGlobal(global?: boolean): boolean { + if (global == true) return true // allow to create global state everywhere + const remember = this.current?.once // true: remember // false: memo // undefined: global + if (remember == false) throw new Error("unnamed local state created in memo-context without remember") + if (global === undefined) return remember != true // create local state within remember only + if (remember === undefined) throw new Error("unnamed local state created in global context") + return false + } + mutableState(initial: Value, global?: boolean, equivalent?: Equivalent, tracker?: ValueTracker): MutableState { - if (global != true && this.current?.once == false) throw new Error("unnamed local state created in memo-context without remember") - if (global === undefined) global = this.current?.once != true - else if (!global && !this.current) throw new Error("unnamed local state created in global context") - return new StateImpl(this, initial, global, equivalent, tracker) + return new StateImpl(this, initial, this.isGlobal(global), equivalent, tracker) + } + + arrayState(initial?: ReadonlyArray, global?: boolean, equivalent?: Equivalent): ArrayState { + return new ArrayStateImpl(this, initial === undefined ? new Array() : Array.from(initial), this.isGlobal(global), equivalent) } get node(): IncrementalNode | undefined { @@ -588,7 +708,7 @@ class StateManagerImpl implements StateManager { const state = scope!.getNamedState(name) if (state) return state } - return (global ?? true) == false ? undefined : this.getNamedState(name) + return (global == false) ? undefined : this.getNamedState(name) } valueBy(name: string, global?: boolean): Value { @@ -994,3 +1114,7 @@ class ControlledScopeImpl implements Dependency, ControlledScope { this.old = undefined } } + +function isModified(oldV: Value, newV: Value, equivalent?: Equivalent): boolean { + return !refEqual(oldV, newV) && (equivalent?.(oldV, newV) != true) +} diff --git a/incremental/runtime/test/states/State.test.ts b/incremental/runtime/test/states/State.test.ts index bb67757ba2d8064e78a8305587d2aad18746d5c4..65c7d833be3c3ecdb7eedd07f86cf56d0708fcde 100644 --- a/incremental/runtime/test/states/State.test.ts +++ b/incremental/runtime/test/states/State.test.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022-2024 Huawei Device Co., Ltd. + * Copyright (c) 2022-2025 Huawei Device Co., Ltd. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -1373,3 +1373,444 @@ suite("ValueTracker", () => { assertModifiedState(state, 999) }) }) + +suite("ArrayState", () => { + test("managed array state supports #at getter", () => { + const manager = createStateManager() + const array = manager.arrayState(["one", "two", "three"]) + assert.equal(array.length, 3) + assert.equal(array.at(0), "one") + assert.equal(array.at(1), "two") + assert.equal(array.at(2), "three") + assert.equal(array.at(-1), "three") + assert.equal(array.at(-2), "two") + assert.equal(array.at(-3), "one") + }) + test("computable state depends on managed array state", () => { + const manager = createStateManager() + const state = manager.mutableState(1) + const array = manager.arrayState(["item"]) + // create computable state that tracks computing inner scopes + const computing: string[] = [] + const result = manager.computableState(context => { + computing.push("outer") + return context.compute(key("left"), () => { + computing.push("left") + return "<= " + }) + context.compute(key("center"), () => { + computing.push("center") + const prefix = state.value + ": " + return prefix + context.compute(key("inner"), () => { + computing.push("inner") + return array.value.join(" ") + }) + }) + context.compute(key("right"), () => { + computing.push("right") + return " =>" + }) + }) + // initial computation + assertState(result, "<= 1: item =>") + assertStringsAndCleanup(computing, "outer ; left ; center ; inner ; right") + // computable state is not modified + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= 1: item =>") + assert.isEmpty(computing) + // compute state only when snapshot updated + state.value = 2 + array.set(0, "value") + assert.equal(testUpdate(false, manager), 2) + assert.equal(result.value, "<= 2: value =>") + assertStringsAndCleanup(computing, "outer ; center ; inner") + // compute state only when snapshot updated + state.value = 3 + array.set(0, "value") + assert.equal(testUpdate(false, manager), 1) + assert.equal(result.value, "<= 3: value =>") + assertStringsAndCleanup(computing, "outer ; center") + // compute state only when snapshot updated + state.value = 4 + array.length = 0 + assert.equal(testUpdate(false, manager), 2) + assert.equal(result.value, "<= 4: =>") + assertStringsAndCleanup(computing, "outer ; center ; inner") + // computable state is not modified + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= 4: =>") + assert.isEmpty(computing) + }) + test("computable state depends on managed array state #copyWithin", () => { + const manager = createStateManager() + const array = manager.arrayState(["one", "two", "three"]) + // create computable state that tracks computing inner scopes + const computing: string[] = [] + const result = manager.computableState(context => { + computing.push("outer") + return context.compute(key("left"), () => { + computing.push("left") + return "<= " + }) + context.compute(key("center"), () => { + computing.push("center") + return context.compute(key("inner"), () => { + computing.push("inner") + return array.value.join(" ") + }) + }) + context.compute(key("right"), () => { + computing.push("right") + return " =>" + }) + }) + // initial computation + assertState(result, "<= one two three =>") + assertStringsAndCleanup(computing, "outer ; left ; center ; inner ; right") + // computable state is not modified + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= one two three =>") + assert.isEmpty(computing) + // compute state only when snapshot updated + array.copyWithin(0, 1, 2) + assert.equal(testUpdate(false, manager), 1) + assert.equal(result.value, "<= two two three =>") + assertStringsAndCleanup(computing, "outer ; center ; inner") + // compute state only when snapshot updated + array.copyWithin(2, 0, 1) + assert.equal(testUpdate(false, manager), 1) + assert.equal(result.value, "<= two two two =>") + assertStringsAndCleanup(computing, "outer ; center ; inner") + // compute state only when snapshot updated + array.copyWithin(1, 2, 0) + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= two two two =>") + assert.isEmpty(computing) + // computable state is not modified + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= two two two =>") + assert.isEmpty(computing) + }) + test("computable state depends on managed array state #fill", () => { + const manager = createStateManager() + const array = manager.arrayState(["one", "two", "three"]) + // create computable state that tracks computing inner scopes + const computing: string[] = [] + const result = manager.computableState(context => { + computing.push("outer") + return context.compute(key("left"), () => { + computing.push("left") + return "<= " + }) + context.compute(key("center"), () => { + computing.push("center") + return context.compute(key("inner"), () => { + computing.push("inner") + return array.value.join(" ") + }) + }) + context.compute(key("right"), () => { + computing.push("right") + return " =>" + }) + }) + // initial computation + assertState(result, "<= one two three =>") + assertStringsAndCleanup(computing, "outer ; left ; center ; inner ; right") + // computable state is not modified + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= one two three =>") + assert.isEmpty(computing) + // compute state only when snapshot updated + array.fill("X", 1, 2) + assert.equal(testUpdate(false, manager), 1) + assert.equal(result.value, "<= one X three =>") + assertStringsAndCleanup(computing, "outer ; center ; inner") + // compute state only when snapshot updated + array.fill("X") + assert.equal(testUpdate(false, manager), 1) + assert.equal(result.value, "<= X X X =>") + assertStringsAndCleanup(computing, "outer ; center ; inner") + // compute state only when snapshot updated + array.fill("X", 1) + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= X X X =>") + assert.isEmpty(computing) + // computable state is not modified + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= X X X =>") + assert.isEmpty(computing) + }) + test("computable state depends on managed array state #pop", () => { + const manager = createStateManager() + const array = manager.arrayState(["first", "last"]) + // create computable state that tracks computing inner scopes + const computing: string[] = [] + const result = manager.computableState(context => { + computing.push("outer") + return context.compute(key("left"), () => { + computing.push("left") + return "<= " + }) + context.compute(key("center"), () => { + computing.push("center") + return context.compute(key("inner"), () => { + computing.push("inner") + return array.value.join(" ") + }) + }) + context.compute(key("right"), () => { + computing.push("right") + return " =>" + }) + }) + // initial computation + assertState(result, "<= first last =>") + assertStringsAndCleanup(computing, "outer ; left ; center ; inner ; right") + // computable state is not modified + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= first last =>") + assert.isEmpty(computing) + // compute state only when snapshot updated + assert.equal(array.pop(), "last") + assert.equal(testUpdate(false, manager), 1) + assert.equal(result.value, "<= first =>") + assertStringsAndCleanup(computing, "outer ; center ; inner") + // compute state only when snapshot updated + assert.equal(array.pop(), "first") + assert.equal(testUpdate(false, manager), 1) + assert.equal(result.value, "<= =>") + assertStringsAndCleanup(computing, "outer ; center ; inner") + // compute state only when snapshot updated + assert.isUndefined(array.pop()) + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= =>") + assert.isEmpty(computing) + // computable state is not modified + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= =>") + assert.isEmpty(computing) + }) + test("computable state depends on managed array state #push", () => { + const manager = createStateManager() + const array = manager.arrayState() + // create computable state that tracks computing inner scopes + const computing: string[] = [] + const result = manager.computableState(context => { + computing.push("outer") + return context.compute(key("left"), () => { + computing.push("left") + return "<= " + }) + context.compute(key("center"), () => { + computing.push("center") + return context.compute(key("inner"), () => { + computing.push("inner") + return array.value.join(" ") + }) + }) + context.compute(key("right"), () => { + computing.push("right") + return " =>" + }) + }) + // initial computation + assertState(result, "<= =>") + assertStringsAndCleanup(computing, "outer ; left ; center ; inner ; right") + // computable state is not modified + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= =>") + assert.isEmpty(computing) + // compute state only when snapshot updated + assert.equal(array.push(), 0) + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= =>") + assert.isEmpty(computing) + // compute state only when snapshot updated + assert.equal(array.push("one"), 1) + assert.equal(testUpdate(false, manager), 1) + assert.equal(result.value, "<= one =>") + assertStringsAndCleanup(computing, "outer ; center ; inner") + // compute state only when snapshot updated + assert.equal(array.push("two", "three"), 3) + assert.equal(testUpdate(false, manager), 1) + assert.equal(result.value, "<= one two three =>") + assertStringsAndCleanup(computing, "outer ; center ; inner") + // computable state is not modified + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= one two three =>") + assert.isEmpty(computing) + }) + test("computable state depends on managed array state #reverse", () => { + const manager = createStateManager() + const array = manager.arrayState(["one", "two", "three"]) + // create computable state that tracks computing inner scopes + const computing: string[] = [] + const result = manager.computableState(context => { + computing.push("outer") + return context.compute(key("left"), () => { + computing.push("left") + return "<= " + }) + context.compute(key("center"), () => { + computing.push("center") + return context.compute(key("inner"), () => { + computing.push("inner") + return array.value.join(" ") + }) + }) + context.compute(key("right"), () => { + computing.push("right") + return " =>" + }) + }) + // initial computation + assertState(result, "<= one two three =>") + assertStringsAndCleanup(computing, "outer ; left ; center ; inner ; right") + // computable state is not modified + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= one two three =>") + assert.isEmpty(computing) + // compute state only when snapshot updated + array.reverse() + assert.equal(testUpdate(false, manager), 1) + assert.equal(result.value, "<= three two one =>") + assertStringsAndCleanup(computing, "outer ; center ; inner") + // computable state is not modified + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= three two one =>") + assert.isEmpty(computing) + }) + test("computable state depends on managed array state #shift", () => { + const manager = createStateManager() + const array = manager.arrayState(["first", "last"]) + // create computable state that tracks computing inner scopes + const computing: string[] = [] + const result = manager.computableState(context => { + computing.push("outer") + return context.compute(key("left"), () => { + computing.push("left") + return "<= " + }) + context.compute(key("center"), () => { + computing.push("center") + return context.compute(key("inner"), () => { + computing.push("inner") + return array.value.join(" ") + }) + }) + context.compute(key("right"), () => { + computing.push("right") + return " =>" + }) + }) + // initial computation + assertState(result, "<= first last =>") + assertStringsAndCleanup(computing, "outer ; left ; center ; inner ; right") + // computable state is not modified + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= first last =>") + assert.isEmpty(computing) + // compute state only when snapshot updated + assert.equal(array.shift(), "first") + assert.equal(testUpdate(false, manager), 1) + assert.equal(result.value, "<= last =>") + assertStringsAndCleanup(computing, "outer ; center ; inner") + // compute state only when snapshot updated + assert.equal(array.shift(), "last") + assert.equal(testUpdate(false, manager), 1) + assert.equal(result.value, "<= =>") + assertStringsAndCleanup(computing, "outer ; center ; inner") + // compute state only when snapshot updated + assert.isUndefined(array.shift()) + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= =>") + assert.isEmpty(computing) + // computable state is not modified + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= =>") + assert.isEmpty(computing) + }) + test("computable state depends on managed array state #sort", () => { + const manager = createStateManager() + const array = manager.arrayState(["one", "two", "three"]) + // create computable state that tracks computing inner scopes + const computing: string[] = [] + const result = manager.computableState(context => { + computing.push("outer") + return context.compute(key("left"), () => { + computing.push("left") + return "<= " + }) + context.compute(key("center"), () => { + computing.push("center") + return context.compute(key("inner"), () => { + computing.push("inner") + return array.value.join(" ") + }) + }) + context.compute(key("right"), () => { + computing.push("right") + return " =>" + }) + }) + // initial computation + assertState(result, "<= one two three =>") + assertStringsAndCleanup(computing, "outer ; left ; center ; inner ; right") + // computable state is not modified + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= one two three =>") + assert.isEmpty(computing) + // compute state only when snapshot updated + array.sort() + assert.equal(testUpdate(false, manager), 1) + assert.equal(result.value, "<= one three two =>") + assertStringsAndCleanup(computing, "outer ; center ; inner") + // compute state only when snapshot updated + array.sort() + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= one three two =>") + assert.isEmpty(computing) + // compute state only when snapshot updated + array.sort((s1, s2) => s1.length < s2.length ? -1 : s1.length > s2.length ? 1 : s1.localeCompare(s2)) + assert.equal(testUpdate(false, manager), 1) + assert.equal(result.value, "<= one two three =>") + assertStringsAndCleanup(computing, "outer ; center ; inner") + // computable state is not modified + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= one two three =>") + assert.isEmpty(computing) + }) + test("computable state depends on managed array state #unshift", () => { + const manager = createStateManager() + const array = manager.arrayState() + // create computable state that tracks computing inner scopes + const computing: string[] = [] + const result = manager.computableState(context => { + computing.push("outer") + return context.compute(key("left"), () => { + computing.push("left") + return "<= " + }) + context.compute(key("center"), () => { + computing.push("center") + return context.compute(key("inner"), () => { + computing.push("inner") + return array.value.join(" ") + }) + }) + context.compute(key("right"), () => { + computing.push("right") + return " =>" + }) + }) + // initial computation + assertState(result, "<= =>") + assertStringsAndCleanup(computing, "outer ; left ; center ; inner ; right") + // computable state is not modified + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= =>") + assert.isEmpty(computing) + // compute state only when snapshot updated + assert.equal(array.unshift(), 0) + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= =>") + assert.isEmpty(computing) + // compute state only when snapshot updated + assert.equal(array.unshift("one"), 1) + assert.equal(testUpdate(false, manager), 1) + assert.equal(result.value, "<= one =>") + assertStringsAndCleanup(computing, "outer ; center ; inner") + // compute state only when snapshot updated + assert.equal(array.unshift("two", "three"), 3) + assert.equal(testUpdate(false, manager), 1) + assert.equal(result.value, "<= two three one =>") + assertStringsAndCleanup(computing, "outer ; center ; inner") + // computable state is not modified + assert.equal(testUpdate(false, manager), 0) + assert.equal(result.value, "<= two three one =>") + assert.isEmpty(computing) + }) +})