Home Reference Source Repository

src/ShadowImpl.js

import clone from "lodash.clone";
import cloneDeep from "lodash.clonedeep";
import isString from "lodash.isstring";
import result from "lodash.result";

import {
	assert,
	isPrimitive,
	isSomething,
} from "akutils";

import Access from "./Access";
import extendProperty from "./extendProperty";
import isShadow from "./isShadow";
import reshadow from "./reshadow";

import appDebug, { ShadowImplKey as DebugKey } from "./debug";
const debug = appDebug(DebugKey);


// instance variable names
const _access = Symbol('access');
// object to store expensive derived values
const _cache = Symbol('cache');
// flag indicating property has pending updates. Not safe to rely on this[_futureState] as it could be
// undefined if that is the next value
const _changed = Symbol('changed');
const _date = Symbol('date');
const _dead = Symbol('dead');
const _didShadowCalled = Symbol('didShadowCalled');
const _futureState = Symbol('futureState');
const _invalid = Symbol('invalid');
const _name = Symbol('name');
const _nextName = Symbol('nextName');
const _path = Symbol('path');

// flag marks this property as obsolete and thus no longer to effect updates on the
// next data model
const _preventUpdates = Symbol('preventUpdates');
const _previousTime = Symbol('previousTime');
const _property = Symbol('property');
const _readonly = Symbol('readonly');
const _replaced = Symbol('replaced');
const _scheduled = Symbol('scheduled');
const _shadow = Symbol('shadow');
const _state = Symbol('state');
const _time = Symbol('time');

// private method symbols
const _createShadow = Symbol('createShadow');
const _defineProperty = Symbol('defineProperty');
const _modelForUpdate = Symbol('modelForUpdate');
const _scheduleUpdate = Symbol('scheduleUpdate');
const _setupShadow = Symbol('setupShadow');


/*
	Todos:
		* Isolated support
			-

		* Reduce memory footprint:
			1) investigate _time and _previousTime really needed
			2) investigate getting access from property (reduce object creation and memory footprint)

		* Investigate replacing impl with Proxy
*/

/**
	The base class for {@link Shadow} backing objects. Each shadow property has an 'impl' that
	performs the f.lux bookkeeping to enable the shadow state to work properly. The 'impl' is
	broken out from the shadow proper to prevent polluting the namespace with a bunch of crazy
	looking variables and methods.

	A shadow property's 'impl' is available through the {@link Shadow.__} method. Direct access
	to the 'impl' is rarely needed by custom properties, shadows, or application logic. And there
	almost certainly no reason to directly subclass this class.

	@see {@link Shadow.__}
*/
export default class ShadowImpl {
	constructor(time, property, name, state, parent, shader, prev) {
		this[_property] = property;
		this[_name] = name;
		this[_state] = state;
		this[_time] = time;

		if (prev) {
			this[_previousTime] = prev[_time];
		}

// TODO: quick hack till have unit tests and thought out life-cycle design
		// didShadow() is being called multiple times which is causing a problem with property
		// initialization that should only occur once.
		this[_didShadowCalled] = false;
	}

	access() {
		const parent = this.parent();

		if (!this[_access]) {
			if (parent && parent.access().create$ForChild) {
				// property does not know about this impl yet. So impl.property() will work but property.__() will not
				this[_access] = parent.access().create$ForChild(this);
			} else {
				this[_access] = this[_property].create$(this);
			}
		}

		return this[_access];
	}

	/**
		Replace the value of this property. This will result in this property tree being recreated.

		Note: This value will be used directly (not copied) so ensure the state is not altered.
	*/
	assign(nextState, name) {
		nextState = isShadow(nextState) ?nextState.__().state() :nextState;

		//create a deep copy so not shared with the passed in value
		//this.deepcopy() will use current model if no value passed or value passed is null or undefined
		//in case of assigned those are legal values so must check explicitly
		return this.update( state => {
				return { name: name || "assign()", nextState, replace: true  };
			});
	}

	/**
		Prevents all children from being able to obtain model in update() callbacks. Update callbacks
		should invoke this method when they perform wholesale
	*/
	blockFurtherChildUpdates() {
		if (!this.hasChildren()) { return }

		const children = this.children();

		for (let i=0, child; child=children[i]; i++) {
			child.blockFurtherUpdates(true);
		}
	}

	/**
		Prevents this property and descendents from providing a model to update() callbacks.

		The update() method invokes this method when the callback returns a different object than the
		one passed into the callback.
	*/
	blockFurtherUpdates(replaced) {
		this[_preventUpdates] = true;
		this.invalidate(null, this);

		if (replaced) {
			this[_replaced] = true;
		}

		this.blockFurtherChildUpdates();
	}

	changeParent(newParent) {
		assert( a => a.is(this.isValid(), `Property must be valid to change parent: ${ this.dotPath() }`)
			          .not(this.isRoot(), `Root properties do not have parents: ${ this.dotPath() }`) );

		debug( d => d(`changeParent(): ${this.dotPath()}`) );

		// clear cache
		delete this[_cache];

		// setup access through shadows
		this[_defineProperty]();
	}

	cache() {
		if (!this[_cache]) {
			this[_cache] = {};
		}

		return this[_cache];
	}

	/**
		Create a copy of the internals during reshadowing when the property has not changed during the
		update process but some descendant has been modified.
	*/
	createCopy(time, newModel, parentImpl) {
		const property = this[_property];
		const ImplClass = property.implementationClass();
		const name = this.nextName();
		const shader = this.shader(newModel);

		return new ImplClass(time, property, name, newModel, parentImpl, shader, this);
	}

	didShadow(time, newRoot) {
		const storeRootImpl = this.store().rootImpl;

		if (this[_time] == time && !this[_didShadowCalled] && storeRootImpl === this.root()) {
			this[_didShadowCalled] = true;

			if (this.isRoot()) {
				if (this[_previousTime] || !newRoot) {
					this[_property].onPropertyDidUpdate();
				} else {
					this[_property].onPropertyDidShadow();
				}
			} else {
				this[_previousTime] ?this[_property].onPropertyDidUpdate() :this[_property].onPropertyDidShadow();
			}

			if (this.hasChildren()) {
				const children = this.children();
				var childImpl;

				for (let i=0, len=children.length; i<len; i++) {
					let childImpl = children[i];

					if (childImpl) {
						childImpl.didShadow(time);
					}
				}
			}
		}
	}

	/**
		Intended for use by update() and replaying actions.
	*/
	dispatchUpdate(action) {
		if (!this[_preventUpdates] && this.isUpdatable() && this.isActive()) {
			const { name, nextState, replace } = action;

			// Sending to store first ensures:
			// 1) nextState() returns value from before this udpate
			// 2) middleware provided chance to make changes to action
			this.store().onPreStateUpdate(action, this);

			// replacing the current object prevents further next state changes for sub-properties
			if (replace) {
				this[_replaced] = true;
				// block child updates because replacement makes them unreachable
				this.blockFurtherChildUpdates();
				this.onReplaced();
			}

			// set the next model data
			this[_futureState] = nextState;

			// update the parent's future state to reference the state returned by the action
			if (!this.isRoot() && !this.isIsolated()) {
				const parentNextData = this.parent()[_modelForUpdate]();

				// do nothing if parentNextData is not assignable
				if (parentNextData && !isPrimitive(parentNextData)) {
					parentNextData[this[_name]] = nextState;
				}
			}

			this.invalidate(null, this);

			this.store().onPostStateUpdate(action, this);
			this.root()[_scheduleUpdate]();
		}
	}

	/**
		Helpful debugging utility that returns the path joined by '.'. The root node will return the
		word 'root' for the path.
	*/
	dotPath() {
		const cache = this.cache();

		if (!cache.dotPath) {
			const path = this.path();

			cache.dotPath = path.length ?path.join('.') :'root';
		}

		return cache.dotPath;
	}

	ensureMounted() {
		if (this.isRoot() || this.isIsolated() || this.__getCalled__) { return }

		result(this.store().shadow, this.dotPath())
	}

	findByPath(path) {
		if (path.length === 0) { return this; }

		const next = this.getChild(path[0]);

		return next && next.findByPath(path.slice(1));
	}

	/**
		Gets if an update has occurred directly to this property.
	*/
	hasPendingChanges() {
		return !!this[_changed];
	}

	/**
		Marks property and ancestors as invalid. This means this property or one of its children
		has been updated. The invalid flag is set to the earliest timestamp when this property
		or one of its children was changed.

		Parameters:
			childImpl - the child implementation triggering this call or undefined if this implementation
				started the invalidation process
			source - the shadow implementation that triggered the invalidation
	*/
	invalidate(childImpl, source=this) {
		const owner = this.owner();

		if (childImpl) {
			this[_property].onChildInvalidated(childImpl.property(), source.property());
		}

		if (this.isValid() && this.isActive()) {
			this[_invalid] = true;

			if (owner) {
				owner.invalidate(this, source);
			}
		}
	}

	/**
		Gets if the property represents live data.
	*/
	isActive() {
		return !this[_dead];
	}

	isIsolated() {
		return this.property().isIsolated();
	}

	isLeaf() {
		return !this.hasChildren();
	}

	isRoot() {
		return this.property().isRoot();
	}

	/**
		Gets if this property or one of its child properties has pending updates. Returns true if there are no
		pending updates.
	*/
	isValid() {
		return !this[_invalid];
	}

	latest() {
		return this.store().findByPath(this.path());
	}

	name() {
		return this[_name];
	}

	/**
		Gets the name after all model updates are performed.
	*/
	nextName() {
		return this[_nextName] !== undefined ?this[_nextName] :this[_name];
	}

	/**
		Gets the model as it will be once all pending changes are recorded with the store. This must
		not be altered.
	*/
	nextState() {
		return this.hasPendingChanges() || !this.isValid() ?this[_futureState] :this.state();
	}

	/**
		Marks this property as obsolete. Once marked obsolete a property may not interact with the store.
		A property becomes obsolete after it's value or ancestor's value has changed and the update process
		has completed.

		This method does not affect subproperties.
	*/
	obsolete(callback) {
		if (callback) {
			callback(this);
		}

		this[_dead] = true;
	}

	obsoleteChildren() {
		if (this.hasChildren()) {
			const children = this.children();

			for (let i=0, len=children.length; i<len; i++) {
				let child = children[i];

				if (child) {
					child.obsoleteTree();
				}
			}
		}
	}

	/**
		Marks the entire subtree as inactive, aka dead.
	*/
	obsoleteTree(callback) {
		if (!this[_dead]) {
			this.obsolete(callback);
			this.obsoleteChildren();
		}
	}

	owner() {
		const ownerProperty = this[_property].owner();

		return ownerProperty && ownerProperty.__();
	}

	parent() {
		const parentProperty = this.property().parent();

		return parentProperty && parentProperty.__();
	}

	/**
		Gets an array with the property names/indices from the root to this property.
	*/
	path() {
		const cache = this.cache();

		if (this.isRoot()) {
			return [];
		} else if (!cache.path) {
			cache.path = this.parent().path().concat(this[_name]);
		}

		return cache.path;
	}

	property() {
		return this[_property];
	}

	readonly() {
		return this[_readonly] === undefined ?this[_property].isReadonly() :this[_readonly];
	}

	replaced() {
		return !!this[_replaced];
	}

	/**
		Invoked by reshadow() function for invalid parent property implementations when the directly
		managed state did not change.

		Calls the onReshadow(prev) method to provide subclasses an oppotunity to setup for futher
		action after a parent change.
	*/
	reshadowed(prev) {
		debug( d => d(`reshadowed(): ${this.dotPath()}, mapped=${prev.isMapped()}, time=${this[_time]}, prevTime=${prev[_time]}`) );

		if (prev.__getCalled__) {
			this[_setupShadow](prev, true);
		}

		this.onReshadow(prev);
	}

	root() {
		if (this[_property].isRoot()) { return this }

		const cache = this.cache();

		if (!cache.root) {
			cache.root = this.owner().root();
		}

		return cache.root;
	}

	/**
		Sets the readonly flag which will prevent a 'set' function being set in defineProeprty().

		Note: this method must be called before defineProperty() is invoked or it will have no affect.
	*/
	setReadonly(readonly) {
		this[_readonly] = readonly;
	}

	/**
		Creates shadow properties for root properties and sets this property on the parent property for
		non-root properties.

		Note: This method is called by shadowProperty() and reshadow() functions.
	*/
	setupPropertyAccess(prev) {
		const property = this[_property];

		if (this.isRoot() || this.isIsolated()) {
			this[_setupShadow](prev);
		} else {
			this[_defineProperty](prev);
		}
	}

	/**
		Gets the shader needed to recreate the shadow property for the state.
	*/
	shader(state) {
		return this[_property].shader(state);
	}

	/**
		Gets the user facing property represented by this implementation object.
	*/
	shadow() {
		if (!this.isMapped()) { throw new Error(`Property implementation not mapped: ${this.dotPath()}`) }

		return this[_setupShadow]()
	}

	/**
		Helpful debugging utility that returns the path joined by '.'. The root node will return the
		word 'root' for the path.
	*/
	slashPath() {
		const cache = this.cache();

		if (!cache.slashPath) {
			const path = this.path();

			cache.slashPath = path.length ?`/${path.join('/')}` :'/';
		}

		return cache.slashPath;
	}

	state() {
		return this[_state];
	}

	store() {
		return this[_property].store();
	}

	/**
		Transfers the nextName to the name attribute.
	*/
	switchName() {
		if (this[_nextName] !== undefined) {
			this[_name] = this[_nextName];
			delete this[_nextName];
		}
	}

	time() {
		return this[_time];
	}

	/**
		Gets a compact version of this internal's state. It does NOT provide a JSON representation of the
		model state. The actual Property.toJSON() method returns the model JSON representation.
	*/
	toJSON() {
		return {
			name: this[_name],
			path: this.dotPath(),
			active: !this[_dead],
			valid: this.isValid(),
			state: this.state(),
		}
	}

	//Gets a stringified version of the toJSON() method.
	toString() {
		return JSON.stringify(this);
	}

	/**
		Makes changes to the next property state. The callback should be pure (no side affects) but that
		is not a requirement. The callback must be of the form:

			(state) => return { nextState, replace }

		where:
			state - the next property state
			nextState - the state following the callback
			replace - boolean for whether nextState replaces the current value. The implication of true
				is that this property and all of it's children will not be able to make future changes
				to the model.

		To understand the reasoning behind the replace flag consider the following example:

			const model = { a: { b: { c: 1 } } }
			const oldB = model.a.b

			model.a.b = "foo"
			oldB.c = 5

			model.a.b.c === undefined

		Thus, oldB.c may change oldB'c property 'c' to 5 but model.a.b is still "foo".
	*/
	update(callback) {
		assert( a => a.is(this.isActive(), `Property is not active: ${ this.dotPath() }`) );

		if (!this[_preventUpdates] && this.isUpdatable() && this.isActive()) {
			const next = this[_modelForUpdate]();

			// invoke callback without bind context to reduce overhead
			const action = callback(next);
			const { nextState, replace } = action;

			// mark property as having pending updates if the action callback returns a different
			// object/value or requests a replacement be created. An example where neither would be
			// true is a property touch() call because its shadow function signature changed.
			if (nextState !== next || replace) {
				this[_changed] = true;
			}

			this.dispatchUpdate(action);

			return true;
		}

		return false;
	}

	/**
		Marks this property as dead. Once marked obsolete a property may not accept further updates.
		A property is updated when the state changes but not a wholesale replacement or a descendents's
		value has changed and the update process has completed.

		This method does not affect subproperties.
	*/
	updated() {
		this[_dead] = true;

		this.onUpdate();
	}

	/**
		Changes the name this property will have after updates. This is used when moving properties
		around in the model, such as when splice is used on an array. The nextName() method
		will return the property name for after updates are applied.

		Note: this method does not have any side effects beyond setting the _nextName instance
			variable. Subclasss will need to perform any book keeping associated with sub-properties.
	*/
	updateName(name) {
		this[_nextName] = name;
	}

	/**
		Gets if shadow property is allowing state updates.

		@return {boolean} `false` if the property or its parent has been replaced, `true` otherwise.
	*/
	updatesAllowed() {
		return !this[_preventUpdates] && !this[_replaced];
	}

	/**
		Invokes a callback once all pending changes have occurred. The callback should have the form:

			callback(property, implementation)

		where the property and implementation arguments are the latest version if they still exist.

		This method is safe to call on a dead property.
	*/
	waitFor(callback) {
		if (this.isValid() && this.isActive()) {
			// short circuit if no changes pending
			callback(this.shadow());
		} else {
			this.store().waitFor( () => {
					const latest = this.latest();

					callback(latest && latest.shadow(), latest);
				});
		}
	}

	/**
		Invoked by the shadowing process to invoke appropriate {@link Property} life-cycle methods.
		The method name is a reflection that shadow state tree invocation chain for `willShadow()`
		occurs when the {@link Store} is going to shadow that state.

		@param {boolean} parentWillUnshadow - `true` when parent property is unshadowing.
	*/
	willShadow(parentWillUnshadow) {
		var willUnshadow = parentWillUnshadow || false;

		if (parentWillUnshadow) {
			// all properties under an unshadowed proeprty also get unshadowed
			this[_property].onPropertyWillUnshadow();
			willUnshadow = true;
		} else if (this.isValid()) {
			// nothing else to do since this property and all subproperties must be fine
			return;
		} else if (this[_replaced] || this[_preventUpdates]) {
			this[_property].onPropertyWillUnshadow();
			willUnshadow = true;
		}

		if (this.hasChildren()) {
			const children = this.children();
			var childImpl;

			for (let i=0, len=children.length; i<len; i++) {
				let childImpl = children[i];

				if (childImpl) {
					childImpl.willShadow(willUnshadow);
				}
			}
		}
	}


	//------------------------------------------------------------------------------------------------------
	//	Methods with base implementations that subclasses may need to override - no need to call super
	//------------------------------------------------------------------------------------------------------

	/**
		Creates a deep clone of the current property state.
	*/
	copyState() {
		return cloneDeep(this.state());
	}

	/**
		Invoked on shadow getter access to obtain the get value.

		The default implementation returns the shadow.

		@return the shadow or other f.lux representative value for the shadow proeprty.
	*/
	definePropertyGetValue(state) {
		return this[_createShadow]();
	}

	/**
		Invoked on shadow property assignment to perform the replacement f.lux action.

		The default implementation is to assign the new value with no checking.

		@param newValue - the new state value
	*/
	definePropertySetValue(newValue) {
		this.assign(newValue);
	}

	/**
		Gets if the property has an child properties (not whether child properties are supported).
	*/
	hasChildren() {
		return this.childCount() != 0;
	}

	/**
		Gets if this property type reprsents a primitive javascript type.
	*/
	isPrimitive() {
		return false;
	}

	/**
		Gets whether the property value supports calls to update().
	*/
	isUpdatable() {
		return true;
	}

	/**
		Property has just been reshadowed.
	*/
	onReshadow(prev) { }

	/**
		Hook for when this property is no longer represented in the system state.
	*/
	onReplaced() { }

	/**
		Hook for when this property is no longer represented in the system state due to a state
		update - not a replacement.
	*/
	onUpdate() { }


	//------------------------------------------------------------------------------------------------------
	//	Methods that ShadowImpl subclasses must be implemented by subclasses
	//------------------------------------------------------------------------------------------------------

	/**
		Merges a new state into this property by using the 'state' parameter to set default values, ie it
		will not overwrite any existing values. Useful when model objects arrive from external sources,
		such as an asyncrhonous save or a websocket based update.
	*/
	defaults(state) {
		throw new Error("ShadowImpl subclasses must implement defaults()");
	}

	/**
		Merges a new state into this property. Useful when model objects arrive from external
		sources, such as an asyncrhonous save or a websocket based update.
	*/
	merge(state) {
		throw new Error("ShadowImpl subclasses must implement merge()");
	}


	//------------------------------------------------------------------------------------------------------
	//	Methods that ShadowImpl subclasses with children must implement
	//------------------------------------------------------------------------------------------------------

	/**
		Invoked during defineProperty() to define children properties marked for automount
	*/
	automountChildren() {
//		throw new Error("ShadowImpl subclasses with children must implement children()");
	}

	/**
		Subclasses should implement this method in such a way as not to trigger a mapping.
	*/
	childCount() {
		return 0;
	}

	/**
		Gets the implementation objects managed by this property.
	*/
	children() {
		throw new Error("ShadowImpl subclasses with children must implement children()");
	}

	/**
		Gets a child implementation matching a property name or undefined if no such property exists.
	*/
	getChild(name) {
		return undefined;
	}

	/**
		Maps all child properties onto this property using Object.defineProperty().

		@param {ShadowImpl} prev - the previous property shadow implementation instance.
		@param {boolean} inCtor - `true` if call occuring during shadowing process.
	*/
	defineChildProperties(prev, inCtor) { }

	/**
		Gets if defineChildProperties() has been invoked.
	*/
	isMapped() {
		return true;
	}

	/**
		Gets the keys/indices for this property.

		Implementation note: Subclasses should implement this method in such a way as not to trigger a mapping.
	*/
	keys() {
		throw new Error("ShadowImpl subclasses with children must implement keys()");
	}


	//------------------------------------------------------------------------------------------------------
	//	Private functions - should not be called by code outside this file.
	//------------------------------------------------------------------------------------------------------

	[_createShadow]() {
		if (!this[_shadow]) {
			let ShadowClass = this[_property].shadowClass();

			this[_shadow] = new ShadowClass(this);
			extendProperty(this[_property], this, this[_shadow]);
		}

		return this[_shadow];
	}

	[_setupShadow](prev, inCtor) {
		if (!this.__getCalled__) {
			let state = this.state();

			debug( d => d(`_setupShadow(): ${this.dotPath()}, time=${this[_time]}`) );

			this.__getCalled__ = true;
			var shadow = this.__getResonse__ = this.definePropertyGetValue(state);

			this.defineChildProperties(prev, inCtor);

			// freeze shadows in dev mode to provide check not assigning to non-shadowed property
			// this can have performance penalties so skip in production mode
			if (process.env.NODE_ENV !== 'production') {
				!Object.isFrozen(shadow) && Object.freeze(shadow);
			}
		}

		return this.__getResonse__;
	}

	/**
		Maps the getter and setter (if appropriate) onto the parent property.
	*/
	[_defineProperty](prev) {
		if (this.isRoot() || this.isIsolated()) { return }

		// names with a leading '_' are not enumerable (way of hiding them)
		const enumerable = !(isString(this[_name]) && this[_name].startsWith('_'));
		const parentShadow = this.parent().shadow();
		const state = this.state();
		const set = this.readonly()
			?undefined
			:newValue => {
					if (!this.isActive())  {
						if (process.env.NODE_ENV !== 'production') {
							console.error(`Attempting to set value on inactive property: ${this.dotPath()}`, newValue);
						}

						return
					}

					return this.definePropertySetValue(newValue);
				}

		try {
			Object.defineProperty(parentShadow, this[_name], {
					enumerable: enumerable,
					get: () => {
							if (isSomething(state)) {
								return this[_setupShadow]();
							} else {
								return state;
							}
						},
					set: set
				});
		} catch(error) {
			console.warn(`_defineProperty() Error: name=${this[_name]}, parent=${this.parent().dotPath()}`, error.stack);
			debugger
		}

		this.automountChildren(prev);
	}

	/**
		Gets the next model state for the property. This value is used for performing property updates through
		the update() function.

		Calls to update() trigger an update through the dispatcher upon which the new object will be mapped
		and the store informed of the change.
	*/
	[_modelForUpdate]() {
		if (!this[_futureState]) {
			if (this.isRoot() || this.isIsolated()) {
				// next data will be a shallow copy of current model
				this[_futureState] = clone(this.state());
			} else {
				const parentNextState = this.parent()[_modelForUpdate]();

				// Primitive parent models do not support adding properties
				if (isPrimitive(parentNextState)) {
					return undefined;
				}

				// next data is a shallow copy of the parent's value of this property
				this[_futureState] = clone(parentNextState[this[_name]]);

				// place a shallow clone in place of current value
				parentNextState[this.nextName()] = this[_futureState]
			}
		}

		return this[_futureState];
	}

	/**
		Schedules an UPDATE action with the store. On action execution, the new property will be generated
		and returned to the store.
	*/
	[_scheduleUpdate]() {
		if (!this.isRoot()) {
			return this.root()[_scheduleUpdate]();
		}

		if (!this[_scheduled] && !this.isValid() && !this[_dead]) {
			// flag never gets cleared
			this[_scheduled] = true;

			this.store().dispatchUpdate( time => reshadow(time, this[_futureState], this) );
		}
	}
}