marko/src/compiler/ast/HtmlElement/vdom/HtmlElementVDOM.js
Michael Rawlings df79fcc5f0
Diffing/Keying fixes (#1094)
* Updates the diffing algorithm to use an HTMLFragment node as an abstraction rather than keeping track of startNode and endNode all throughout the diffing algorithm.

* Uses the HTMLFragment for the <${dynamic}> tag and <include> tags to preserve server-rendered content for which the renderBody is not available in the browser.

* Component ids are based on the resulting parent tree (not the owner tree). This means we cannot rely on the ids in the global lookup, so component key/refs are now also stored on the component instance.

* Static node trees are now only auto assigned a key for the top-level node (instead of all nodes).

* Server comment starting markers now have the component's key serialized so the component can be attached to its owner

* Server comment markers no longer have the id on the closing marker, it is stack based.

* Normalize differences between hydration and client-rendering, so post mount the DOM looks the same regardless of whether a component was server or client rendered.

* fix matching up fragments when hydrating by taking components and normalized text nodes into account

* remove wrapping divs in test, should no longer be needed.  address hydration issue with alternate fragment matching approach.

* add fragment to dom if there's a parentNode even if no startNode

* add test for mismatched hydration

* don't detach components before moving

* use fragments to preserve renderBody content

* use ___finishFragment as the incomplete fragment marker

* ensure fragments get destroyed properly and dom node key sequences don't continue from previous renders

* use parent/owner terminology in more places, component ids are now parent scoped, key/ref components are attached to their owner for both client + server render, server comment boundaries include the owner and the key in addition to the fully scoped component id, autokeyed dom nodes are attached to their parent, hydration now uses a stack: ids in ending comment nodes not needed, hydration checks to see if a component has been initialized and will attach keys directly to the owner if so

* add mutation guards for text/comment nodes, add mutation guard for input value

* make component-pages test better represent streaming hydration, fix html/head/body hydration in a better/more generic way

* add test for async rendered keyrefs

* add test for repeated mult-level transclusion

* Autokeyed elements are now stored on the parent rather than the owner. User assigned key/refs are still stored on the owner component. Because of this, user assigned keys are now prefixed (with @) to differentiate them from autokeys. This also has the benefit that assigning numeric keys can no longer conflict with the autokeys.

* add re-rendering the intermediate container to one of the new tests
2018-08-27 11:46:47 -07:00

405 lines
11 KiB
JavaScript

"use strict";
const Node = require("../../Node");
const vdomUtil = require("../../../util/vdom");
const FLAG_IS_SVG = 1;
const FLAG_IS_TEXTAREA = 2;
const FLAG_SIMPLE_ATTRS = 4;
// const FLAG_PRESERVE = 8;
// const FLAG_CUSTOM_ELEMENT = 16;
let CREATE_ARGS_COUNT = 0;
const INDEX_TAG_NAME = CREATE_ARGS_COUNT++;
const INDEX_ATTRS = CREATE_ARGS_COUNT++;
const INDEX_KEY = CREATE_ARGS_COUNT++;
const INDEX_COMPONENT = CREATE_ARGS_COUNT++;
const INDEX_CHILD_COUNT = CREATE_ARGS_COUNT++;
const INDEX_FLAGS = CREATE_ARGS_COUNT++;
const INDEX_PROPS = CREATE_ARGS_COUNT++;
function finalizeCreateArgs(createArgs, builder) {
var length = createArgs.length;
var lastArg;
for (var i = length - 1; i >= 0; i--) {
var arg = createArgs[i];
if (arg) {
lastArg = arg;
} else {
if (lastArg != null) {
if (i === INDEX_FLAGS) {
// Use a literal 0 for the flags
createArgs[i] = builder.literal(0);
} else {
createArgs[i] = builder.literalNull();
}
} else {
length--;
}
}
}
createArgs.length = length;
return createArgs;
}
const MAYBE_SVG = {
a: true,
script: true,
style: true
};
const SIMPLE_ATTRS = {
class: true,
style: true,
id: true
};
function isStaticProperties(properties) {
for (var k in properties) {
var v = properties[k];
if (v.type !== "Literal") {
return false;
}
if (typeof v.value === "object") {
return false;
}
}
return true;
}
class HtmlElementVDOM extends Node {
constructor(def) {
super("HtmlElementVDOM");
this.tagName = def.tagName;
this.isStatic = def.isStatic;
this.isAttrsStatic = def.isAttrsStatic;
this.isHtmlOnly = def.isHtmlOnly;
this.attributes = def.attributes;
this.properties = def.properties;
this.body = def.body;
this.dynamicAttributes = def.dynamicAttributes;
this.key = def.key;
this.runtimeFlags = def.runtimeFlags;
this.isAutoKeyed = def.isAutoKeyed;
this.isSVG = false;
this.isTextArea = false;
this.hasAttributes = false;
this.hasSimpleAttrs = false; // This will be set to true if the HTML element
// only attributes in the following set:
// ['id', 'style', 'class']
this.isChild = false;
this.createElementId = undefined;
this.attributesArg = undefined;
this.propertiesArg = undefined;
}
generateCode(codegen) {
let context = codegen.context;
let builder = codegen.builder;
vdomUtil.registerOptimizer(context);
let tagName = this.tagName;
if (tagName.type === "Literal" && typeof tagName.value === "string") {
let tagDef = context.getTagDef(tagName.value);
if (tagDef) {
if (tagDef.htmlType === "svg") {
this.isSVG = true;
} else {
if (MAYBE_SVG[tagName.value] && context.isFlagSet("SVG")) {
this.isSVG = true;
} else {
this.tagName = tagName = builder.literal(
tagName.value.toUpperCase()
);
if (tagName.value === "TEXTAREA") {
this.isTextArea = true;
}
}
}
}
this.isLiteralTag = true;
} else if (context.isFlagSet("SVG")) {
this.isSVG = true;
}
let attributes = this.attributes;
let properties = this.properties;
let dynamicAttributes = this.dynamicAttributes;
let attributesArg = null;
var hasNamedAttributes = false;
var hasDynamicAttributes =
dynamicAttributes != null && dynamicAttributes.length !== 0;
var hasSpreadAttributes = false;
var hasSimpleAttrs = true;
if (properties && properties.noupdate) {
// Preserving attributes requires extra logic that we cannot
// shortcircuit
hasSimpleAttrs = false;
}
if (attributes != null && attributes.length !== 0) {
let explicitAttrs = null;
let attrs = [];
let addAttr = function(name, value) {
hasNamedAttributes = true;
if (!SIMPLE_ATTRS[name]) {
hasSimpleAttrs = false;
}
if (!explicitAttrs) {
explicitAttrs = {};
}
if (value.type === "Literal") {
let literalValue = value.value;
if (literalValue == null || literalValue === false) {
return;
} else if (typeof literalValue === "number") {
value.value = literalValue.toString();
}
} else if (value.type === "AttributePlaceholder") {
value = codegen.builder.functionCall(
context.helper("str"),
[value]
);
}
explicitAttrs[name] = value;
};
attributes.forEach((attr, i) => {
// deprecated
if (!attr.name && !attr.spread) {
return;
}
if (attr.spread) {
let isFirstOfMany = i === 0 && attributes.length > 1;
if (explicitAttrs || isFirstOfMany) {
attrs.push(builder.literal(explicitAttrs || {}));
}
attrs.push(
codegen.builder.functionCall(context.helper("attrs"), [
attr.value
])
);
explicitAttrs = null;
hasSpreadAttributes = true;
} else {
let value = attr.value;
if (value == null) {
value = builder.literal(true);
}
addAttr(attr.name, value);
}
});
if (explicitAttrs) {
attrs.push(builder.literal(explicitAttrs));
}
attributesArg =
attrs.length > 1
? builder.functionCall(context.helper("assign"), attrs)
: attrs[0];
}
// deprecated
if (hasDynamicAttributes) {
dynamicAttributes.forEach(attrs => {
if (attributesArg) {
let mergeVar = context.helper("merge");
attributesArg = builder.functionCall(mergeVar, [
attributesArg, // Input props from the attributes take precedence
attrs
]);
} else {
attributesArg = attrs;
}
});
}
if (
!this.isAttrsStatic &&
hasNamedAttributes &&
hasSimpleAttrs &&
!hasDynamicAttributes &&
!hasSpreadAttributes
) {
this.hasSimpleAttrs = true;
}
this.hasAttributes = hasNamedAttributes || hasDynamicAttributes;
this.attributesArg = attributesArg;
this.properties = builder.literal(this.properties || {});
return this;
}
walk(walker) {
this.tagName = walker.walk(this.tagName);
this.attributes = walker.walk(this.attributes);
this.body = walker.walk(this.body);
}
writeCode(writer) {
let builder = writer.builder;
let body = this.body;
let attributesArg = this.attributesArg;
let tagName = this.tagName;
let key = this.key;
let childCount = body && body.length;
let createArgs = new Array(CREATE_ARGS_COUNT);
createArgs[INDEX_TAG_NAME] = tagName;
if (attributesArg) {
createArgs[INDEX_ATTRS] = attributesArg;
}
if (
key &&
(!this.isAutoKeyed || !this.isStatic || this.createElementId)
) {
createArgs[INDEX_KEY] = key;
if (!this.isStatic) {
createArgs[INDEX_COMPONENT] = builder.identifier("component");
}
}
if (childCount != null) {
createArgs[INDEX_CHILD_COUNT] = builder.literal(childCount);
}
var flags = 0;
if (this.isSVG) {
flags |= FLAG_IS_SVG;
}
if (this.isTextArea) {
flags |= FLAG_IS_TEXTAREA;
}
if (this.hasSimpleAttrs) {
flags |= FLAG_SIMPLE_ATTRS;
}
if (this.runtimeFlags) {
flags |= this.runtimeFlags;
}
if (flags) {
createArgs[INDEX_FLAGS] = builder.literal(flags);
}
let properties = this.properties;
if (
this.properties &&
properties.type === "Literal" &&
Object.keys(properties.value).length === 0
) {
properties = null;
}
if (properties) {
createArgs[INDEX_PROPS] = properties;
}
// Remove trailing undefined arguments and convert non-trailing
// undefined elements to a literal null node
createArgs = finalizeCreateArgs(createArgs, builder);
let funcCall;
if (this.isChild) {
writer.write(".");
funcCall = builder.functionCall(
builder.identifier(
this.isLiteralTag || this.isSVG ? "e" : "ed"
),
createArgs
);
} else if (this.isStatic && this.createElementId) {
funcCall = builder.functionCall(this.createElementId, createArgs);
} else if (this.isHtmlOnly) {
writer.write("out.");
funcCall = builder.functionCall(
builder.identifier(
this.isLiteralTag || this.isSVG ? "e" : "ed"
),
createArgs
);
} else {
writer.write("out.");
funcCall = builder.functionCall(
builder.identifier(
this.isLiteralTag || this.isSVG ? "be" : "bed"
),
createArgs
);
}
writer.write(funcCall);
if (body && body.length) {
writer.incIndent();
for (let i = 0; i < body.length; i++) {
let child = body[i];
child.isChild = true;
writer.write("\n");
writer.writeLineIndent();
writer.write(child);
}
writer.decIndent();
}
}
setConstId(value) {
this.properties.value.i = value;
}
finalizeProperties(context) {
if (
this.properties.type === "Literal" &&
isStaticProperties(this.properties.value)
) {
if (Object.keys(this.properties.value).length === 0) {
this.properties = null;
} else {
this.properties = context.addStaticVar(
"props",
this.properties
);
}
}
}
}
module.exports = HtmlElementVDOM;