UNPKG

closure-builder

Version:

Simple Closure, Soy and JavaScript Build system

312 lines (261 loc) 9.57 kB
/** * @fileoverview * * Functions necessary to interact with the Soy-Idom runtime. */ import 'goog:soy.velog'; // from //javascript/template/soy:soyutils_velog import UnsanitizedText from 'goog:goog.soy.data.UnsanitizedText'; // from //javascript/closure/soy:data import {$$VisualElementData, ElementMetadata, Logger} from 'goog:soy.velog'; // from //javascript/template/soy:soyutils_velog import * as incrementaldom from 'incrementaldom'; // from //third_party/javascript/incremental_dom:incrementaldom /** A key as stored in the stack. This is JSON-serialzed for idom APIs. */ type ElementKey = string|number|null; /** A key as passed from compiled Soy. Soy includes undefined in null. */ type ElementKeyParam = ElementKey|UnsanitizedText|undefined; /** Unwraps UnsanitizedText objects passed from Soy so they match strings. */ function unwrapKey(key: ElementKeyParam): ElementKey { if (key instanceof UnsanitizedText) return key.toString(); if (key === undefined) return null; return key; } /** * Class that mostly delegates to global Incremental DOM runtime. This will * eventually take in a logger and conditionally mute. These methods may * return void when idom commands are muted for velogging. */ export class IncrementalDomRenderer { // Stack (holder) of key stacks for the current template being rendered, which // has context on where the template was called from and is used to // key each template call (see go/soy-idom-diffing-semantics). // Works as follows: // - A new key is pushed onto the topmost key stack before a template call, // - and popped after the call. // - A new stack is pushed onto the holder before a manually keyed element // is opened, and popped before the element is closed. This is because // manual keys "reset" the key context. private keyStackHolder: ElementKey[][] = [[]]; private logger: Logger|null = null; /** * Wrapper over `elementOpen/elementOpenStart` calls. * Pushes/pops the given key from `keyStack` (versus `Array#concat`) * to avoid allocating a new array for every element open. */ private elementOpenWrapper( elementOpenFn: (name: string, key?: string|null, statics?: string[]) => void, nameOrCtor: string, key?: ElementKeyParam, statics?: string[]) { if (key !== undefined) { this.pushKey(unwrapKey(key)); } const keyStack = this.getCurrentKeyStack(); const el = elementOpenFn(nameOrCtor, getKeyForCurrentPointer(keyStack), statics); if (key !== undefined) { this.popKey(); } return el; } alignWithDOM(tagName: string, key: string) { incrementaldom.alignWithDOM(tagName, key); } /** * Called (from generated template render function) before OPENING * keyed elements. */ pushManualKey(key: ElementKeyParam) { this.keyStackHolder.push([unwrapKey(key)]); } /** * Called (from generated template render function) before CLOSING * keyed elements. */ popManualKey() { this.keyStackHolder.pop(); } /** * Called (from generated template render function) BEFORE template * calls. */ pushKey(key: ElementKeyParam) { const keyStack = this.keyStackHolder[this.keyStackHolder.length - 1]; keyStack.push(unwrapKey(key)); } /** * Called (from generated template render function) AFTER template * calls. */ popKey() { const keyStack = this.keyStackHolder[this.keyStackHolder.length - 1]; keyStack.pop(); } /** * Returns the stack on top of the holder. This represents the current * chain of keys. */ getCurrentKeyStack(): ElementKey[] { return this.keyStackHolder[this.keyStackHolder.length - 1]; } elementOpen(nameOrCtor: string, key?: ElementKeyParam, statics?: string[]): HTMLElement|void { return this.elementOpenWrapper( incrementaldom.elementOpen, nameOrCtor, key, statics); } elementClose(name: string): Element|void { return incrementaldom.elementClose(name); } elementOpenStart(name: string, key?: ElementKeyParam, statics?: string[]) { return this.elementOpenWrapper( incrementaldom.elementOpenStart, name, key, statics); } elementOpenEnd(): HTMLElement|void { return incrementaldom.elementOpenEnd(); } text(value: string): Text|void { return incrementaldom.text(value); } attr(name: string, value: string) { return incrementaldom.attr(name, value); } currentPointer(): Node|null { return incrementaldom.currentPointer(); } skip() { return incrementaldom.skip(); } currentElement(): HTMLElement|void { return incrementaldom.currentElement(); } skipNode() { return incrementaldom.skipNode(); } /** * Called when a `{velog}` statement is entered. */ enter(veData: $$VisualElementData, logOnly: boolean) { if (this.logger) { this.logger.enter(new ElementMetadata( veData.getVe().getId(), veData.getData(), logOnly)); } } /** * Called when a `{velog}` statement is exited. */ exit() { if (this.logger) { this.logger.exit(); } } /** * Switches runtime to produce incremental dom calls that do not traverse * the DOM. This happens when logOnly in a velogging node is set to true. * For more info, see http://go/soy/reference/velog#the-logonly-attribute */ toNullRenderer() { const nullRenderer = new NullRenderer(this); return nullRenderer; } toDefaultRenderer(): IncrementalDomRenderer { throw new Error( 'Cannot transition a default renderer to a default renderer'); } /** Called by user code to configure logging */ setLogger(logger: Logger|null) { this.logger = logger; } getLogger() { return this.logger; } /** * Used to trigger the requirement that logOnly can only be true when a * logger is configured. Otherwise, it is a passthrough function. */ verifyLogOnly(logOnly: boolean) { if (!this.logger && logOnly) { throw new Error( 'Cannot set logonly="true" unless there is a logger configured'); } return logOnly; } /* * Called when a logging function is evaluated. */ evalLoggingFunction(name: string, args: Array<{}>, placeHolder: string): string { if (this.logger) { return this.logger.evalLoggingFunction(name, args); } return placeHolder; } } /** * Renderer that mutes all IDOM commands and returns void. * For more info, see http://go/soy/reference/velog#the-logonly-attribute */ export class NullRenderer extends IncrementalDomRenderer { constructor(private readonly renderer: IncrementalDomRenderer) { super(); this.setLogger(renderer.getLogger()); } elementOpen( nameOrCtor: string, key?: ElementKey, statics?: string[], ...varArgs: string[]) {} alignWithDOM(name: string, key: string) {} elementClose(name: string) {} elementOpenStart(name: string, key?: ElementKey, statics?: string[]) {} elementOpenEnd() {} text(value: string) {} attr(name: string, value: string) {} currentPointer() { return null; } skip() {} key(val: string) {} currentElement() {} skipNode() {} /** Returns to the default renderer which will traverse the DOM. */ toDefaultRenderer() { this.renderer!.setLogger(this.getLogger()); return this.renderer; } } /** * For the current pointer, returns the correct key - either the original * key if the proposed key is a suffix (this means that we're patching * a subtree of the originally rendered template), or the proposed key * otherwise (go/soy-idom-suffix-matching-strategy). */ function getKeyForCurrentPointer(proposedKeyArr?: ElementKey[]): string|null { if (!proposedKeyArr) return null; const currentPointer = incrementaldom.currentPointer(); if (!currentPointer || !incrementaldom.isDataInitialized(currentPointer)) { // If there is no current pointer or its data has not been initialized, // this means the element is being rendered or hydrated for the first // time, so just use the proposed key. return JSON.stringify(proposedKeyArr); } const currentPointerKey = incrementaldom.getKey(currentPointer) as string; // If the current pointer has no key, just use the proposed key. // This is expected to happen when doing the initial client-side hydration // of server-side rendered DOM. if (!currentPointerKey) return JSON.stringify(proposedKeyArr); const currentPointerKeyArr = JSON.parse(currentPointerKey); return isProposedKeySuffixOfCurrentKey(proposedKeyArr, currentPointerKeyArr) ? // Just use the current (original) key. currentPointerKey : JSON.stringify(proposedKeyArr); } /** * Returns whether the proposed key is a suffix of the current key. * For example: * - proposedKeyArr: ['b', 'c'], currentKeyArr: ['a', 'b', 'c'] => true * - proposedKeyArr: ['b', 'c'], currentKeyArr: ['a', 'b', 'c', 'd'] => false */ export function isProposedKeySuffixOfCurrentKey( proposedKeyArr: ElementKey[], currentPointerKeyArr: ElementKey[]) { let i = proposedKeyArr.length - 1; let j = currentPointerKeyArr.length - 1; while (i >= 0 && j >= 0 && proposedKeyArr[i] === currentPointerKeyArr[j]) { i--; j--; } return i < 0; }