UNPKG

@adpt/core

Version:
826 lines 31.4 kB
"use strict"; /* * Copyright 2018-2019 Unbounded Systems, LLC * * 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 * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = require("tslib"); const debug_1 = tslib_1.__importDefault(require("debug")); const util = tslib_1.__importStar(require("util")); const ld = tslib_1.__importStar(require("lodash")); const css = tslib_1.__importStar(require("./css")); const jsx_1 = require("./jsx"); const state_1 = require("./state"); const observers_1 = require("./observers"); const utils_1 = require("@adpt/utils"); const builtin_components_1 = require("./builtin_components"); const error_1 = require("./error"); const handle_1 = require("./handle"); const hooks_1 = require("./hooks"); const keys_1 = require("./keys"); const debugBuild = debug_1.default("adapt:dom:build"); const debugState = debug_1.default("adapt:state"); class BuildResults { constructor(recorder, mountedOrig, contents, other) { this.recorder = recorder; // These accumulate across build passes this.buildErr = false; this.messages = []; this.buildPassReset(); if (contents !== undefined) { this.contents = contents; } if (mountedOrig !== undefined) { this.mountedOrig = mountedOrig; } if (other !== undefined) { this.combine(other); } } buildPassReset() { this.mountedOrig = null; this.contents = null; this.cleanups = []; this.mountedElements = []; this.builtElements = []; this.stateChanged = false; this.partialBuild = false; } // Terminology is a little confusing here. Anything that allows the // build to keep progressing should be MessageType.warning. // MessageType.error should only be for catastrophic things where // the build cannot keep running (i.e. an exception that can't be // handled within build). // However, either MessageType.warning or MessageType.error indicates // an unsuccessful build, therefore buildErr = true. /** * Record an error in build data recorder and log a message and mark * the build as errored. * This is the primary interface for most build errors. */ error(err, from) { const error = ld.isError(err) ? err : new Error(err); this.recorder({ type: "error", error }); this.message({ type: utils_1.MessageType.warning, from }, error); } message(msg, err) { const content = err ? err.message : msg.content; if (!content) throw new error_1.InternalError(`build message doesn't have content or err`); const copy = Object.assign({}, msg, { content, timestamp: msg.timestamp ? msg.timestamp : Date.now(), from: msg.from ? msg.from : "DOM build" }); this.messages.push(copy); switch (copy.type) { case utils_1.MessageType.warning: case utils_1.MessageType.error: this.buildErr = true; this.partialBuild = true; } } combine(other) { this.messages.push(...other.messages); this.cleanups.push(...other.cleanups); this.mountedElements.push(...other.mountedElements); this.builtElements.push(...other.builtElements); this.buildErr = this.buildErr || other.buildErr; this.partialBuild = this.partialBuild || other.partialBuild; this.stateChanged = this.stateChanged || other.stateChanged; other.messages = []; other.cleanups = []; other.builtElements = []; other.mountedElements = []; return this; } cleanup() { let clean; do { clean = this.cleanups.pop(); if (clean) clean(); } while (clean); } toBuildOutput(stateStore) { if (this.buildErr && this.messages.length === 0) { throw new error_1.InternalError(`buildErr is true, but there are ` + `no messages to describe why`); } if (this.partialBuild) { if (this.contents !== null && !jsx_1.isPartialFinalDomElement(this.contents)) { throw new error_1.InternalError(`contents is not a mounted element: ${this.contents}`); } return { partialBuild: true, buildErr: this.buildErr, messages: this.messages, contents: this.contents, mountedOrig: this.mountedOrig, }; } if (this.contents !== null && !jsx_1.isFinalDomElement(this.contents)) { throw new error_1.InternalError(`contents is not a valid built DOM element: ${this.contents}`); } const builtElements = this.builtElements; return { partialBuild: false, buildErr: false, messages: this.messages, contents: this.contents, mountedOrig: this.mountedOrig, builtElements: this.builtElements, processStateUpdates: () => processStateUpdates(builtElements, stateStore), }; } } function isClassConstructorError(err) { return err instanceof TypeError && typeof err.message === "string" && /Class constructor .* cannot be invoked/.test(err.message); } function recordDomError(cc, element, err) { let message; if (ld.isError(err)) { message = `Component ${element.componentName} cannot be built ` + `with current props` + (err.message ? ": " + err.message : ""); cc.error(message); } else { message = err.content; cc.message(err); } const domError = jsx_1.createElement(builtin_components_1.DomError, {}, message); const kids = jsx_1.childrenToArray(element.props.children); kids.unshift(domError); replaceChildren(element, kids); return { domError, message }; } function makeElementStatus(observerManager) { return async function elementStatus(handle) { const elem = handle.mountedOrig; if (elem == null) return { noStatus: true }; if (!jsx_1.isElementImpl(elem)) throw new error_1.InternalError("Element is not ElementImpl"); try { return await (observerManager ? elem.statusWithMgr(observerManager) : elem.status()); } catch (e) { if (!observers_1.isObserverNeedsData(e)) throw e; return undefined; } }; } exports.makeElementStatus = makeElementStatus; function buildHelpers(options) { const { buildNum, deployID, deployOpID } = options; const elementStatus = makeElementStatus(options.observerManager); return { buildNum, deployID, deployOpID, elementStatus, }; } async function computeContentsFromElement(element, options) { const ret = new BuildResults(options.recorder, element); const helpers = buildHelpers(options); try { hooks_1.startHooks({ element, options, helpers }); ret.contents = element.componentType(element.props); return ret; } catch (e) { if (e instanceof error_1.BuildNotImplemented) return buildDone(e); if (!isClassConstructorError(e)) { if (error_1.isError(e)) { return buildDone(new Error(`SFC build failed: ${e.message}`)); } throw e; } // element.componentType is a class, not a function. Fall through. } finally { hooks_1.finishHooks(); } if (!jsx_1.isComponentElement(element)) { throw new error_1.InternalError(`trying to construct non-component`); } let component; try { component = constructComponent(element, options); } catch (e) { if (e instanceof error_1.BuildNotImplemented) return buildDone(e); if (error_1.isError(e)) { return buildDone(new Error(`Component construction failed: ${e.message}`)); } throw e; } try { if (!ld.isFunction(component.build)) { throw new error_1.BuildNotImplemented(`build is not a function, build = ${util.inspect(component.build)}`); } ret.contents = await component.build(helpers); if (component.cleanup) { ret.cleanups.push(component.cleanup.bind(component)); } return ret; } catch (e) { if (e instanceof error_1.BuildNotImplemented) return buildDone(e); if (error_1.isError(e)) { return buildDone(new Error(`Component build failed: ${e.message}`)); } throw e; } function buildDone(err) { ret.contents = element; if (err) recordDomError(ret, element, err); return ret; } } function findOverride(styles, path, options) { const element = path[path.length - 1]; const reg = options.matchInfoReg; for (const style of styles.reverse()) { if (css.canMatch(reg, element) && !css.ruleHasMatched(reg, element, style) && style.match(path)) { css.ruleMatches(reg, element, style); return { style, override: style.sfc }; } } return null; } async function computeContents(path, options) { const element = ld.last(path); if (element == null) { const ret = new BuildResults(options.recorder); return ret; } if (!jsx_1.isMountedElement(element)) throw new error_1.InternalError(`computeContents for umounted element: ${element}`); const hand = handle_1.getInternalHandle(element); const out = await computeContentsFromElement(element, options); // Default behavior if the component doesn't explicitly call // handle.replaceTarget is to do the replace for them. if (!hand.targetReplaced(options)) hand.replaceTarget(out.contents, options); options.recorder({ type: "step", oldElem: element, newElem: out.contents }); return out; } function ApplyStyle(props) { const origBuild = () => { return props.element; }; const hand = handle_1.getInternalHandle(props.element); const opts = { buildNum: props.buildNum, origBuild, origElement: props.element, [css.$matchInfoReg]: props.matchInfoReg, }; const ret = props.override(props.element.props, opts); // Default behavior if they don't explicitly call // handle.replaceTarget is to do the replace for them. if (ret !== props.element && !hand.targetReplaced(props)) { hand.replaceTarget(ret, props); } return ret; } //Gross, but we need to provide ApplyStyle to jsx.ts like this to avoid a circular require // tslint:disable-next-line:no-var-requires require("./jsx").ApplyStyle = ApplyStyle; function doOverride(path, key, styles, options) { let element = ld.last(path); if (element == null) { throw new Error("Cannot match null element to style rules for empty path"); } const overrideFound = findOverride(styles, path, options); if (overrideFound != null) { const matchInfoReg = options.matchInfoReg; if (jsx_1.isComponentElement(element)) { if (!jsx_1.isMountedElement(element)) throw new error_1.InternalError(`Element should be mounted`); if (!jsx_1.isElementImpl(element)) throw new error_1.InternalError(`Element should be ElementImpl`); if (element.component == null) { element.component = constructComponent(element, options); } } const hand = handle_1.getInternalHandle(element); const oldEl = element; element = jsx_1.cloneElement(element, key, element.props.children); css.copyRuleMatches(matchInfoReg, oldEl, element); hand.replaceTarget(element, options); const { style, override } = overrideFound; const props = Object.assign({}, key, { override, element, matchInfoReg, buildNum: options.buildNum }); const newElem = jsx_1.createElement(ApplyStyle, props); // The ApplyStyle element should never match any CSS rule css.neverMatch(matchInfoReg, newElem); options.recorder({ type: "step", oldElem: element, newElem, style }); return newElem; } else { return element; } } function mountElement(path, parentStateNamespace, options) { let elem = ld.last(path); if (elem === undefined) { throw new error_1.InternalError("Attempt to mount empty path"); } if (elem === null) return new BuildResults(options.recorder, elem, elem); if (jsx_1.isMountedElement(elem)) { throw new Error("Attempt to remount element: " + util.inspect(elem)); } const newKey = keys_1.computeMountKey(elem, parentStateNamespace); const hand = handle_1.getInternalHandle(elem); const oldEl = elem; elem = jsx_1.cloneElement(elem, newKey, elem.props.children); css.copyRuleMatches(options.matchInfoReg, oldEl, elem); if (!hand.targetReplaced(options)) hand.replaceTarget(elem, options); if (!jsx_1.isElementImpl(elem)) { throw new Error("Elements must derive from ElementImpl"); } const finalPath = subLastPathElem(path, elem); elem.mount(parentStateNamespace, domPathToString(finalPath), domPathToKeyPath(finalPath), options.deployID, options.deployOpID); if (!jsx_1.isMountedElement(elem)) throw new error_1.InternalError(`just mounted element is not mounted ${elem}`); const out = new BuildResults(options.recorder, elem, elem); out.mountedElements.push(elem); return out; } function subLastPathElem(path, elem) { const ret = path.slice(0, -1); ret.push(elem); return ret; } async function buildElement(path, parentStateNamespace, styles, options) { const elem = ld.last(path); if (elem === undefined) { throw new error_1.InternalError("buildElement called with empty path"); } if (elem === null) return new BuildResults(options.recorder, null, null); if (!jsx_1.isMountedElement(elem)) throw new error_1.InternalError(`attempt to build unmounted element ${elem}`); if (!jsx_1.isElementImpl(elem)) throw new Error(`Elements must derive from ElementImpl ${elem}`); const override = doOverride(path, keys_1.computeMountKey(elem, parentStateNamespace), styles, options); if (override !== elem) { return new BuildResults(options.recorder, elem, override); } if (jsx_1.isPrimitiveElement(elem)) { const res = new BuildResults(options.recorder, elem, elem); try { constructComponent(elem, options); res.builtElements.push(elem); elem.setBuilt(); } catch (err) { if (!error_1.isError(err)) throw err; recordDomError(res, elem, new Error(`Component construction failed: ${err.message}`)); } return res; } const out = await computeContents(path, options); if (out.contents != null) { if (Array.isArray(out.contents)) { const name = elem.componentName; throw new Error(`Component build for ${name} returned an ` + `array. Components must return a single root element when ` + `built.`); } } out.builtElements.push(elem); elem.setBuilt(); return out; } function constructComponent(elem, options) { const { deployID, deployOpID, observerManager, stateStore } = options; if (!jsx_1.isElementImpl(elem)) { throw new error_1.InternalError(`Element is not an ElementImpl`); } jsx_1.pushComponentConstructorData({ deployInfo: { deployID, deployOpID, }, getState: () => stateStore.elementState(elem.stateNamespace), setInitialState: (init) => stateStore.setElementState(elem.stateNamespace, init), stateUpdates: elem.stateUpdates, observerManager }); try { const component = new elem.componentType(elem.props); elem.component = component; return component; } finally { jsx_1.popComponentConstructorData(); } } function computeOptions(optionsIn) { if (optionsIn != null) optionsIn = utils_1.removeUndef(optionsIn); const defaultBuildOptions = { depth: -1, shallow: false, // Next line shouldn't be needed. VSCode tslint is ok, CLI is not. // tslint:disable-next-line:object-literal-sort-keys recorder: (_op) => { return; }, stateStore: state_1.createStateStore(), observerManager: observers_1.createObserverManagerDeployment(), maxBuildPasses: 200, buildOnce: false, deployID: "<none>", deployOpID: 0, matchInfoReg: css.createMatchInfoReg(), hookInfo: hooks_1.createHookInfo(), }; return Object.assign({}, defaultBuildOptions, optionsIn, { buildPass: 0 }); } // Simultaneous builds let buildCount = 0; function isBuildOutputPartial(v) { return (utils_1.isObject(v) && v.partialBuild === true && (v.contents === null || jsx_1.isPartialFinalDomElement(v.contents))); } exports.isBuildOutputPartial = isBuildOutputPartial; function isBuildOutputError(v) { return isBuildOutputPartial(v) && v.buildErr === true; } exports.isBuildOutputError = isBuildOutputError; exports.noStateUpdates = () => Promise.resolve({ stateChanged: false }); function isBuildOutputSuccess(v) { return (utils_1.isObject(v) && v.partialBuild === false && v.buildErr !== true && (v.contents === null || jsx_1.isFinalDomElement(v.contents))); } exports.isBuildOutputSuccess = isBuildOutputSuccess; async function build(root, styles, options) { const debugBuildBuild = debugBuild.extend("build"); debugBuildBuild(`start`); if (buildCount !== 0) { throw new error_1.InternalError(`Attempt to build multiple DOMs concurrently not supported`); } try { buildCount++; const optionsReq = computeOptions(options); const results = new BuildResults(optionsReq.recorder); const styleList = css.buildStyles(styles); if (optionsReq.depth === 0) throw new Error(`build depth cannot be 0: ${options}`); await pathBuild([root], styleList, optionsReq, results); return results.toBuildOutput(optionsReq.stateStore); } finally { debugBuildBuild(`done`); buildCount--; } } exports.build = build; async function buildOnce(root, styles, options) { return build(root, styles, Object.assign({}, options, { buildOnce: true })); } exports.buildOnce = buildOnce; function atDepth(options, depth) { if (options.shallow) return true; if (options.depth === -1) return false; return depth >= options.depth; } async function nextTick() { await new Promise((res) => { process.nextTick(res); }); } async function pathBuild(path, styles, options, results) { options.matchInfoReg = css.createMatchInfoReg(); await pathBuildOnceGuts(path, styles, options, results); if (results.buildErr || options.buildOnce) return; if (results.stateChanged) { await nextTick(); return pathBuild(path, styles, options, results); } } // Unique identifier for a build pass let nextBuildNum = 1; async function pathBuildOnceGuts(path, styles, options, results) { const root = path[path.length - 1]; const buildNum = nextBuildNum++; const buildPass = ++options.buildPass; if (buildPass > options.maxBuildPasses) { results.error(`DOM build exceeded maximum number of build iterations ` + `(${options.maxBuildPasses})`); return; } const debug = debugBuild.extend(`pathBuildOnceGuts:${buildPass}`); debug(`start (pass ${buildPass})`); options.recorder({ type: "start", root, buildPass }); results.buildPassReset(); try { const once = await realBuildOnce(path, null, styles, Object.assign({}, options, { buildNum }), null); debug(`build finished`); once.cleanup(); results.combine(once); results.mountedOrig = once.mountedOrig; results.contents = once.contents; } catch (error) { options.recorder({ type: "error", error }); debug(`error: ${error} `); throw error; } if (results.buildErr) return; debug(`validating`); results.builtElements.map((elem) => { if (jsx_1.isMountedPrimitiveElement(elem)) { let msgs; try { msgs = elem.validate(); } catch (err) { if (!ld.isError(err)) err = new error_1.ThrewNonError(err); msgs = [err]; } for (const m of msgs) recordDomError(results, elem, m); } }); if (results.buildErr) return; debug(`postBuild`); options.recorder({ type: "done", root: results.contents }); const { stateChanged } = await processStateUpdates(results.builtElements, options.stateStore); if (stateChanged) results.stateChanged = true; debug(`done (stateChanged=${results.stateChanged})`); } async function processStateUpdates(builtElements, stateStore) { let stateChanged = false; debugState(`State updates: start`); const updates = builtElements.map(async (elem) => { if (jsx_1.isElementImpl(elem)) { const ret = await elem.postBuild(stateStore); if (ret.stateChanged) stateChanged = true; } }); await Promise.all(updates); debugState(`State updates: complete (stateChanged=${stateChanged})`); return { stateChanged }; } exports.processStateUpdates = processStateUpdates; function setOrigChildren(predecessor, origChildren) { predecessor.buildData.origChildren = origChildren; } async function buildChildren(newRoot, workingPath, styles, options) { if (!jsx_1.isElementImpl(newRoot)) throw new Error(`Elements must inherit from ElementImpl ${util.inspect(newRoot)}`); const out = new BuildResults(options.recorder); const children = newRoot.props.children; let newChildren = null; if (children == null) { return { newChildren: null, childBldResults: out }; } //FIXME(manishv) Make this use an explicit stack //instead of recursion to avoid blowing the call stack //For deep DOMs let childList = []; if (jsx_1.isElement(children)) { childList = [children]; } else if (ld.isArray(children)) { childList = children; } keys_1.assignKeysAtPlacement(childList); newChildren = []; const mountedOrigChildren = []; for (const child of childList) { if (jsx_1.isElementImpl(child)) { if (jsx_1.isMountedElement(child) && child.built()) { newChildren.push(child); //Must be from a deferred build mountedOrigChildren.push(child); continue; } options.recorder({ type: "descend", descendFrom: newRoot, descendTo: child }); const ret = await realBuildOnce([...workingPath, child], newRoot.stateNamespace, styles, options, null, child); options.recorder({ type: "ascend", ascendTo: newRoot, ascendFrom: child }); ret.cleanup(); // Do lower level cleanups before combining msgs out.combine(ret); newChildren.push(ret.contents); mountedOrigChildren.push(ret.mountedOrig); continue; } else { newChildren.push(child); mountedOrigChildren.push(child); continue; } } setOrigChildren(newRoot, mountedOrigChildren); newChildren = newChildren.filter(utils_1.notNull); return { newChildren, childBldResults: out }; } function setSuccessor(predecessor, succ) { if (predecessor === null) return; if (!jsx_1.isElementImpl(predecessor)) throw new error_1.InternalError(`Element is not ElementImpl: ${predecessor}`); predecessor.buildData.successor = succ; } let realBuildId = 0; async function realBuildOnce(pathIn, parentStateNamespace, styles, options, predecessor, workingElem) { const buildId = ++realBuildId; const debug = debugBuild.extend(`realBuildOnce:${buildId}`); debug(`start (id: ${buildId})`); try { let deferring = false; const atDepthFlag = atDepth(options, pathIn.length); if (options.depth === 0) throw new error_1.InternalError("build depth 0 not supported"); if (parentStateNamespace == null) { parentStateNamespace = state_1.stateNamespaceForPath(pathIn.slice(0, -1)); } const oldElem = ld.last(pathIn); if (oldElem === undefined) throw new error_1.InternalError("realBuild called with empty path"); if (oldElem === null) return new BuildResults(options.recorder, null); if (workingElem === undefined) { workingElem = oldElem; } const out = new BuildResults(options.recorder); let mountedElem = oldElem; if (!jsx_1.isMountedElement(oldElem)) { const mountOut = mountElement(pathIn, parentStateNamespace, options); if (mountOut.buildErr) return mountOut; out.contents = mountedElem = mountOut.contents; out.combine(mountOut); } if (!jsx_1.isMountedElement(mountedElem)) throw new error_1.InternalError("element not mounted after mount"); out.mountedOrig = mountedElem; setSuccessor(predecessor, mountedElem); if (mountedElem === null) { options.recorder({ type: "elementBuilt", oldElem: workingElem, newElem: out.contents }); return out; } //Element is mounted const mountedPath = subLastPathElem(pathIn, mountedElem); let newRoot; let newPath = mountedPath; if (!jsx_1.isElementImpl(mountedElem)) { throw new Error("Elements must inherit from ElementImpl:" + util.inspect(newRoot)); } if (!jsx_1.isDeferredElementImpl(mountedElem) || mountedElem.shouldBuild()) { const computeOut = await buildElement(mountedPath, parentStateNamespace, styles, options); out.combine(computeOut); out.contents = newRoot = computeOut.contents; if (computeOut.buildErr) return out; if (newRoot !== null) { if (newRoot !== mountedElem) { newPath = subLastPathElem(mountedPath, newRoot); const ret = (await realBuildOnce(newPath, mountedElem.stateNamespace, styles, options, mountedElem, workingElem)).combine(out); ret.mountedOrig = out.mountedOrig; return ret; } else { options.recorder({ type: "elementBuilt", oldElem: workingElem, newElem: newRoot }); return out; } } } else { options.recorder({ type: "defer", elem: mountedElem }); deferring = true; mountedElem.setDeferred(); newRoot = mountedElem; out.contents = newRoot; } if (newRoot === undefined) { out.error(`Root element undefined after build`); out.contents = null; return out; } if (newRoot === null) { setSuccessor(mountedElem, newRoot); options.recorder({ type: "elementBuilt", oldElem: workingElem, newElem: null }); return out; } //Do not process children of DomError nodes in case they result in more DomError children if (!builtin_components_1.isDomErrorElement(newRoot)) { if (!atDepthFlag) { const { newChildren, childBldResults } = await buildChildren(newRoot, mountedPath, styles, options); out.combine(childBldResults); replaceChildren(newRoot, newChildren); } } else { if (!out.buildErr) { // This could happen if a user instantiates a DomError element. // Treat that as a build error too. out.error("User-created DomError component present in the DOM tree"); } } if (atDepthFlag) out.partialBuild = true; //We are here either because mountedElem was deferred, or because mountedElem === newRoot if (!deferring || atDepthFlag) { options.recorder({ type: "elementBuilt", oldElem: workingElem, newElem: newRoot }); return out; } //FIXME(manishv)? Should this check be if there were no element children instead of just no children? //No built event in this case since we've exited early if (atDepthFlag && newRoot.props.children === undefined) return out; //We must have deferred to get here options.recorder({ type: "buildDeferred", elem: mountedElem }); const deferredRet = (await realBuildOnce(newPath, mountedElem.stateNamespace, styles, options, predecessor, workingElem)).combine(out); deferredRet.mountedOrig = out.mountedOrig; return deferredRet; } catch (err) { debug(`error (id: ${buildId}): ${err}`); throw err; } finally { debug(`done (id: ${buildId})`); } } function replaceChildren(elem, children) { children = jsx_1.simplifyChildren(children); if (Object.isFrozen(elem.props)) { const newProps = Object.assign({}, elem.props); if (children == null) { delete newProps.children; } else { newProps.children = children; } elem.props = newProps; Object.freeze(elem.props); } else { if (children == null) { delete elem.props.children; } else { elem.props.children = children; } } } function domPathToString(domPath) { return "/" + domPath.map((el) => el.componentType.name).join("/"); } exports.domPathToString = domPathToString; function domPathToKeyPath(domPath) { return domPath.map((el) => { const key = el.props.key; if (typeof key !== "string") { throw new error_1.InternalError(`element has no key`); } return key; }); } //# sourceMappingURL=dom.js.map