377 lines
15 KiB
JavaScript
377 lines
15 KiB
JavaScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
import { BaseObservable, _setDerivedOpts, } from './base.js';
|
|
import { DebugNameData } from './debugName.js';
|
|
import { BugIndicatingError, DisposableStore, assertFn, onBugIndicatingError, strictEquals } from './commonFacade/deps.js';
|
|
import { getLogger } from './logging.js';
|
|
export function derived(computeFnOrOwner, computeFn) {
|
|
if (computeFn !== undefined) {
|
|
return new Derived(new DebugNameData(computeFnOrOwner, undefined, computeFn), computeFn, undefined, undefined, undefined, strictEquals);
|
|
}
|
|
return new Derived(new DebugNameData(undefined, undefined, computeFnOrOwner), computeFnOrOwner, undefined, undefined, undefined, strictEquals);
|
|
}
|
|
export function derivedWithSetter(owner, computeFn, setter) {
|
|
return new DerivedWithSetter(new DebugNameData(owner, undefined, computeFn), computeFn, undefined, undefined, undefined, strictEquals, setter);
|
|
}
|
|
export function derivedOpts(options, computeFn) {
|
|
return new Derived(new DebugNameData(options.owner, options.debugName, options.debugReferenceFn), computeFn, undefined, undefined, options.onLastObserverRemoved, options.equalsFn ?? strictEquals);
|
|
}
|
|
_setDerivedOpts(derivedOpts);
|
|
/**
|
|
* Represents an observable that is derived from other observables.
|
|
* The value is only recomputed when absolutely needed.
|
|
*
|
|
* {@link computeFn} should start with a JS Doc using `@description` to name the derived.
|
|
*
|
|
* Use `createEmptyChangeSummary` to create a "change summary" that can collect the changes.
|
|
* Use `handleChange` to add a reported change to the change summary.
|
|
* The compute function is given the last change summary.
|
|
* The change summary is discarded after the compute function was called.
|
|
*
|
|
* @see derived
|
|
*/
|
|
export function derivedHandleChanges(options, computeFn) {
|
|
return new Derived(new DebugNameData(options.owner, options.debugName, undefined), computeFn, options.createEmptyChangeSummary, options.handleChange, undefined, options.equalityComparer ?? strictEquals);
|
|
}
|
|
export function derivedWithStore(computeFnOrOwner, computeFnOrUndefined) {
|
|
let computeFn;
|
|
let owner;
|
|
if (computeFnOrUndefined === undefined) {
|
|
computeFn = computeFnOrOwner;
|
|
owner = undefined;
|
|
}
|
|
else {
|
|
owner = computeFnOrOwner;
|
|
computeFn = computeFnOrUndefined;
|
|
}
|
|
const store = new DisposableStore();
|
|
return new Derived(new DebugNameData(owner, undefined, computeFn), r => {
|
|
store.clear();
|
|
return computeFn(r, store);
|
|
}, undefined, undefined, () => store.dispose(), strictEquals);
|
|
}
|
|
export function derivedDisposable(computeFnOrOwner, computeFnOrUndefined) {
|
|
let computeFn;
|
|
let owner;
|
|
if (computeFnOrUndefined === undefined) {
|
|
computeFn = computeFnOrOwner;
|
|
owner = undefined;
|
|
}
|
|
else {
|
|
owner = computeFnOrOwner;
|
|
computeFn = computeFnOrUndefined;
|
|
}
|
|
let store = undefined;
|
|
return new Derived(new DebugNameData(owner, undefined, computeFn), r => {
|
|
if (!store) {
|
|
store = new DisposableStore();
|
|
}
|
|
else {
|
|
store.clear();
|
|
}
|
|
const result = computeFn(r);
|
|
if (result) {
|
|
store.add(result);
|
|
}
|
|
return result;
|
|
}, undefined, undefined, () => {
|
|
if (store) {
|
|
store.dispose();
|
|
store = undefined;
|
|
}
|
|
}, strictEquals);
|
|
}
|
|
var DerivedState;
|
|
(function (DerivedState) {
|
|
/** Initial state, no previous value, recomputation needed */
|
|
DerivedState[DerivedState["initial"] = 0] = "initial";
|
|
/**
|
|
* A dependency could have changed.
|
|
* We need to explicitly ask them if at least one dependency changed.
|
|
*/
|
|
DerivedState[DerivedState["dependenciesMightHaveChanged"] = 1] = "dependenciesMightHaveChanged";
|
|
/**
|
|
* A dependency changed and we need to recompute.
|
|
* After recomputation, we need to check the previous value to see if we changed as well.
|
|
*/
|
|
DerivedState[DerivedState["stale"] = 2] = "stale";
|
|
/**
|
|
* No change reported, our cached value is up to date.
|
|
*/
|
|
DerivedState[DerivedState["upToDate"] = 3] = "upToDate";
|
|
})(DerivedState || (DerivedState = {}));
|
|
export class Derived extends BaseObservable {
|
|
_debugNameData;
|
|
_computeFn;
|
|
createChangeSummary;
|
|
_handleChange;
|
|
_handleLastObserverRemoved;
|
|
_equalityComparator;
|
|
state = 0 /* DerivedState.initial */;
|
|
value = undefined;
|
|
updateCount = 0;
|
|
dependencies = new Set();
|
|
dependenciesToBeRemoved = new Set();
|
|
changeSummary = undefined;
|
|
_isUpdating = false;
|
|
_isComputing = false;
|
|
get debugName() {
|
|
return this._debugNameData.getDebugName(this) ?? '(anonymous)';
|
|
}
|
|
constructor(_debugNameData, _computeFn, createChangeSummary, _handleChange, _handleLastObserverRemoved = undefined, _equalityComparator) {
|
|
super();
|
|
this._debugNameData = _debugNameData;
|
|
this._computeFn = _computeFn;
|
|
this.createChangeSummary = createChangeSummary;
|
|
this._handleChange = _handleChange;
|
|
this._handleLastObserverRemoved = _handleLastObserverRemoved;
|
|
this._equalityComparator = _equalityComparator;
|
|
this.changeSummary = this.createChangeSummary?.();
|
|
getLogger()?.handleDerivedCreated(this);
|
|
}
|
|
onLastObserverRemoved() {
|
|
/**
|
|
* We are not tracking changes anymore, thus we have to assume
|
|
* that our cache is invalid.
|
|
*/
|
|
this.state = 0 /* DerivedState.initial */;
|
|
this.value = undefined;
|
|
getLogger()?.handleDerivedCleared(this);
|
|
for (const d of this.dependencies) {
|
|
d.removeObserver(this);
|
|
}
|
|
this.dependencies.clear();
|
|
this._handleLastObserverRemoved?.();
|
|
}
|
|
get() {
|
|
if (this._isComputing) {
|
|
throw new BugIndicatingError('Cyclic deriveds are not supported yet!');
|
|
}
|
|
if (this.observers.size === 0) {
|
|
let result;
|
|
// Without observers, we don't know when to clean up stuff.
|
|
// Thus, we don't cache anything to prevent memory leaks.
|
|
try {
|
|
this._isReaderValid = true;
|
|
result = this._computeFn(this, this.createChangeSummary?.());
|
|
}
|
|
finally {
|
|
this._isReaderValid = false;
|
|
}
|
|
// Clear new dependencies
|
|
this.onLastObserverRemoved();
|
|
return result;
|
|
}
|
|
else {
|
|
do {
|
|
// We might not get a notification for a dependency that changed while it is updating,
|
|
// thus we also have to ask all our depedencies if they changed in this case.
|
|
if (this.state === 1 /* DerivedState.dependenciesMightHaveChanged */) {
|
|
for (const d of this.dependencies) {
|
|
/** might call {@link handleChange} indirectly, which could make us stale */
|
|
d.reportChanges();
|
|
if (this.state === 2 /* DerivedState.stale */) {
|
|
// The other dependencies will refresh on demand, so early break
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// We called report changes of all dependencies.
|
|
// If we are still not stale, we can assume to be up to date again.
|
|
if (this.state === 1 /* DerivedState.dependenciesMightHaveChanged */) {
|
|
this.state = 3 /* DerivedState.upToDate */;
|
|
}
|
|
this._recomputeIfNeeded();
|
|
// In case recomputation changed one of our dependencies, we need to recompute again.
|
|
} while (this.state !== 3 /* DerivedState.upToDate */);
|
|
return this.value;
|
|
}
|
|
}
|
|
_recomputeIfNeeded() {
|
|
if (this.state === 3 /* DerivedState.upToDate */) {
|
|
return;
|
|
}
|
|
const emptySet = this.dependenciesToBeRemoved;
|
|
this.dependenciesToBeRemoved = this.dependencies;
|
|
this.dependencies = emptySet;
|
|
const hadValue = this.state !== 0 /* DerivedState.initial */;
|
|
const oldValue = this.value;
|
|
this.state = 3 /* DerivedState.upToDate */;
|
|
let didChange = false;
|
|
this._isComputing = false; // TODO@hediet: Set to true and investigate diff editor scrolling issues! (also see test.skip('catches cyclic dependencies')
|
|
try {
|
|
const changeSummary = this.changeSummary;
|
|
this.changeSummary = this.createChangeSummary?.();
|
|
try {
|
|
this._isReaderValid = true;
|
|
/** might call {@link handleChange} indirectly, which could invalidate us */
|
|
this.value = this._computeFn(this, changeSummary);
|
|
}
|
|
finally {
|
|
this._isReaderValid = false;
|
|
// We don't want our observed observables to think that they are (not even temporarily) not being observed.
|
|
// Thus, we only unsubscribe from observables that are definitely not read anymore.
|
|
for (const o of this.dependenciesToBeRemoved) {
|
|
o.removeObserver(this);
|
|
}
|
|
this.dependenciesToBeRemoved.clear();
|
|
}
|
|
didChange = hadValue && !(this._equalityComparator(oldValue, this.value));
|
|
getLogger()?.handleDerivedRecomputed(this, {
|
|
oldValue,
|
|
newValue: this.value,
|
|
change: undefined,
|
|
didChange,
|
|
hadValue,
|
|
});
|
|
}
|
|
catch (e) {
|
|
onBugIndicatingError(e);
|
|
}
|
|
this._isComputing = false;
|
|
if (didChange) {
|
|
for (const r of this.observers) {
|
|
r.handleChange(this, undefined);
|
|
}
|
|
}
|
|
}
|
|
toString() {
|
|
return `LazyDerived<${this.debugName}>`;
|
|
}
|
|
// IObserver Implementation
|
|
beginUpdate(_observable) {
|
|
if (this._isUpdating) {
|
|
throw new BugIndicatingError('Cyclic deriveds are not supported yet!');
|
|
}
|
|
this.updateCount++;
|
|
this._isUpdating = true;
|
|
try {
|
|
const propagateBeginUpdate = this.updateCount === 1;
|
|
if (this.state === 3 /* DerivedState.upToDate */) {
|
|
this.state = 1 /* DerivedState.dependenciesMightHaveChanged */;
|
|
// If we propagate begin update, that will already signal a possible change.
|
|
if (!propagateBeginUpdate) {
|
|
for (const r of this.observers) {
|
|
r.handlePossibleChange(this);
|
|
}
|
|
}
|
|
}
|
|
if (propagateBeginUpdate) {
|
|
for (const r of this.observers) {
|
|
r.beginUpdate(this); // This signals a possible change
|
|
}
|
|
}
|
|
}
|
|
finally {
|
|
this._isUpdating = false;
|
|
}
|
|
}
|
|
_removedObserverToCallEndUpdateOn = null;
|
|
endUpdate(_observable) {
|
|
this.updateCount--;
|
|
if (this.updateCount === 0) {
|
|
// End update could change the observer list.
|
|
const observers = [...this.observers];
|
|
for (const r of observers) {
|
|
r.endUpdate(this);
|
|
}
|
|
if (this._removedObserverToCallEndUpdateOn) {
|
|
const observers = [...this._removedObserverToCallEndUpdateOn];
|
|
this._removedObserverToCallEndUpdateOn = null;
|
|
for (const r of observers) {
|
|
r.endUpdate(this);
|
|
}
|
|
}
|
|
}
|
|
assertFn(() => this.updateCount >= 0);
|
|
}
|
|
handlePossibleChange(observable) {
|
|
// In all other states, observers already know that we might have changed.
|
|
if (this.state === 3 /* DerivedState.upToDate */ && this.dependencies.has(observable) && !this.dependenciesToBeRemoved.has(observable)) {
|
|
this.state = 1 /* DerivedState.dependenciesMightHaveChanged */;
|
|
for (const r of this.observers) {
|
|
r.handlePossibleChange(this);
|
|
}
|
|
}
|
|
}
|
|
handleChange(observable, change) {
|
|
if (this.dependencies.has(observable) && !this.dependenciesToBeRemoved.has(observable)) {
|
|
let shouldReact = false;
|
|
try {
|
|
shouldReact = this._handleChange ? this._handleChange({
|
|
changedObservable: observable,
|
|
change,
|
|
didChange: (o) => o === observable,
|
|
}, this.changeSummary) : true;
|
|
}
|
|
catch (e) {
|
|
onBugIndicatingError(e);
|
|
}
|
|
const wasUpToDate = this.state === 3 /* DerivedState.upToDate */;
|
|
if (shouldReact && (this.state === 1 /* DerivedState.dependenciesMightHaveChanged */ || wasUpToDate)) {
|
|
this.state = 2 /* DerivedState.stale */;
|
|
if (wasUpToDate) {
|
|
for (const r of this.observers) {
|
|
r.handlePossibleChange(this);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// IReader Implementation
|
|
_isReaderValid = false;
|
|
readObservable(observable) {
|
|
if (!this._isReaderValid) {
|
|
throw new BugIndicatingError('The reader object cannot be used outside its compute function!');
|
|
}
|
|
// Subscribe before getting the value to enable caching
|
|
observable.addObserver(this);
|
|
/** This might call {@link handleChange} indirectly, which could invalidate us */
|
|
const value = observable.get();
|
|
// Which is why we only add the observable to the dependencies now.
|
|
this.dependencies.add(observable);
|
|
this.dependenciesToBeRemoved.delete(observable);
|
|
return value;
|
|
}
|
|
addObserver(observer) {
|
|
const shouldCallBeginUpdate = !this.observers.has(observer) && this.updateCount > 0;
|
|
super.addObserver(observer);
|
|
if (shouldCallBeginUpdate) {
|
|
if (this._removedObserverToCallEndUpdateOn && this._removedObserverToCallEndUpdateOn.has(observer)) {
|
|
this._removedObserverToCallEndUpdateOn.delete(observer);
|
|
}
|
|
else {
|
|
observer.beginUpdate(this);
|
|
}
|
|
}
|
|
}
|
|
removeObserver(observer) {
|
|
if (this.observers.has(observer) && this.updateCount > 0) {
|
|
if (!this._removedObserverToCallEndUpdateOn) {
|
|
this._removedObserverToCallEndUpdateOn = new Set();
|
|
}
|
|
this._removedObserverToCallEndUpdateOn.add(observer);
|
|
}
|
|
super.removeObserver(observer);
|
|
}
|
|
log() {
|
|
if (!getLogger()) {
|
|
super.log();
|
|
getLogger()?.handleDerivedCreated(this);
|
|
}
|
|
else {
|
|
super.log();
|
|
}
|
|
return this;
|
|
}
|
|
}
|
|
export class DerivedWithSetter extends Derived {
|
|
set;
|
|
constructor(debugNameData, computeFn, createChangeSummary, handleChange, handleLastObserverRemoved = undefined, equalityComparator, set) {
|
|
super(debugNameData, computeFn, createChangeSummary, handleChange, handleLastObserverRemoved, equalityComparator);
|
|
this.set = set;
|
|
}
|
|
}
|
|
//# sourceMappingURL=derived.js.map
|