Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf: hoist static objects/arrays in templates #2589

Merged
merged 15 commits into from
Jan 4, 2022
Merged
34 changes: 28 additions & 6 deletions packages/@lwc/engine-core/src/3rdparty/snabbdom/snabbdom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ function isVNode(vnode: any): vnode is VNode {
return vnode != null;
}

function createKeyToOldIdx(children: VNodes, beginIdx: number, endIdx: number): KeyToIndexMap {
function createKeyToOldIdx(
children: Readonly<VNodes>,
beginIdx: number,
endIdx: number
): KeyToIndexMap {
const map: KeyToIndexMap = {};
let j: number, key: Key | undefined, ch;
// TODO [#1637]: simplify this by assuming that all vnodes has keys
Expand All @@ -50,7 +54,7 @@ function createKeyToOldIdx(children: VNodes, beginIdx: number, endIdx: number):
function addVnodes(
parentElm: Node,
before: Node | null,
vnodes: VNodes,
vnodes: Readonly<VNodes>,
startIdx: number,
endIdx: number
) {
Expand All @@ -63,7 +67,12 @@ function addVnodes(
}
}

function removeVnodes(parentElm: Node, vnodes: VNodes, startIdx: number, endIdx: number): void {
function removeVnodes(
parentElm: Node,
vnodes: Readonly<VNodes>,
startIdx: number,
endIdx: number
): void {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx];
// text nodes do not have logic associated to them
Expand All @@ -73,7 +82,11 @@ function removeVnodes(parentElm: Node, vnodes: VNodes, startIdx: number, endIdx:
}
}

export function updateDynamicChildren(parentElm: Node, oldCh: VNodes, newCh: VNodes) {
export function updateDynamicChildren(
parentElm: Node,
oldCh: Readonly<VNodes>,
newCh: Readonly<VNodes>
) {
let oldStartIdx = 0;
let newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
Expand Down Expand Up @@ -139,7 +152,12 @@ export function updateDynamicChildren(parentElm: Node, oldCh: VNodes, newCh: VNo
newStartVnode.hook.insert(newStartVnode, parentElm, oldStartVnode.elm!);
} else {
patchVnode(elmToMove, newStartVnode);
oldCh[idxInOld] = undefined as any;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Due to VNodes being read-only now, I had to remove this line. AFAICT, it is not needed. I don't understand why we would need to set this to undefined in the oldCh array. The old children should just be garbage-collected, as they either belong to the old VNode:

fn(vnode.elm!, oldVnode.children, children);

Or are replaced on the current VM:

const children = renderComponent(vm);
patchShadowRoot(vm, children);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't yet fully understand the implications of removing this line here. The fact that it breaks no test is good news to me.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You CANNOT remove this line! :)

Basically, this line actuates when there is a new element who's key is found on the old vnodes, there are 2 possibilities:

  1. the old node is in a position prior to the new position.
  2. the old node is in a position after the new position.

The posibility of being in the same position is not possible in this branch of the code.

On top of that, there is another problem: "whether or not the tagName (sel) matches.

So, the problem is that if the sel matches, and the position is prior the new position (remember that this algo goes backward), since we are in a loop, there is a possibility that later in the game you encounter another element that must be inserted in the position marked by the old position, and the diffing algo tries to remove the element associated to the old vnodes.

My hunch is that this will result on the moved node to disappear from the DOM, and it is very hard to write a test that validate this assumption, but we can certainly try.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caridy Thanks for the explanation. I'm looking into it, and it turns out that this else block is never hit in either our Jest tests or Karma tests:

idxInOld = oldKeyToIdx[newStartVnode.key!];
if (isUndef(idxInOld)) {
// New element
newStartVnode.hook.create(newStartVnode);
newStartVnode.hook.insert(newStartVnode, parentElm, oldStartVnode.elm!);
newStartVnode = newCh[++newStartIdx];
} else {
elmToMove = oldCh[idxInOld];
if (isVNode(elmToMove)) {
if (elmToMove.sel !== newStartVnode.sel) {
// New element
newStartVnode.hook.create(newStartVnode);
newStartVnode.hook.insert(newStartVnode, parentElm, oldStartVnode.elm!);
} else {
patchVnode(elmToMove, newStartVnode);
oldCh[idxInOld] = undefined as any;
newStartVnode.hook.move(elmToMove, parentElm, oldStartVnode.elm!);
}
}
newStartVnode = newCh[++newStartIdx];

In other words, isUndef(idxInOld) is always true. I'll try to write a test where it is false.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I managed to find a Karma test to hit this line of code (I'll open a separate PR to improve our test coverage). Based on the usage patterns, I think we can do this:

  1. Instead of using oldCh directly, use a clone ([...oldCh]) inside of updateDynamicChildren. AFAICT, oldCh is only used inside of that function – afterwards, it is discard and GC'ed.
  2. To avoid the perf hit of cloning arrays, we can do the clone only on line 142 (i.e. when calling oldCh[idxInOld] = undefined as any; ).

@caridy How does this sound?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure the clone solves the problem, unless you always rely on the cloned array for the different paths of the algo.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also closing via [...oldCh] will trigger the iterable protocol that is not ideal.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caridy I'm saying we should do:

- oldCh[idxInOld] = undefined as any;
+ const oldChClone = [...oldCh];
+ oldChClone[idxInOld] = undefined as any;
+ oldCh = oldChClone;

It solves the problem because the oldCh array is Readonly – although only because of the empty array [], which may be shared between VNodes due to the optimization in this PR. (A non-empty array cannot be hoisted because it will contain VNodes, not static objects. This is why we only need a shallow clone, not a deep clone.)

also closing via [...oldCh] will trigger the iterable protocol that is not ideal.

Can you elaborate? Are you concerned about performance? It seems to me that this code path is very infrequent, so I'm not really concerned about the perf implications there.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I'm fine with this approach. In locker code, we try to stay away from [...array] due to the perf implications of the iterables. cc @jdalton, but as you said, this is a very rare code path anyways.

// Delete the old child, but copy the array since it is read-only.
// The `oldCh` will be GC'ed after `updateDynamicChildren` is complete,
// so we only care about the `oldCh` object inside this function.
const oldChClone = [...oldCh];
oldChClone[idxInOld] = undefined as any;
oldCh = oldChClone;
newStartVnode.hook.move(elmToMove, parentElm, oldStartVnode.elm!);
}
}
Expand All @@ -164,7 +182,11 @@ export function updateDynamicChildren(parentElm: Node, oldCh: VNodes, newCh: VNo
}
}

export function updateStaticChildren(parentElm: Node, oldCh: VNodes, newCh: VNodes) {
export function updateStaticChildren(
parentElm: Node,
oldCh: Readonly<VNodes>,
newCh: Readonly<VNodes>
) {
const oldChLength = oldCh.length;
const newChLength = newCh.length;

Expand Down
32 changes: 17 additions & 15 deletions packages/@lwc/engine-core/src/3rdparty/snabbdom/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ export type VNodes = Array<VNode | null>;

export interface VNode {
sel: string | undefined;
data: VNodeData;
children: VNodes | undefined;
data: Readonly<VNodeData>;
children: Readonly<VNodes> | undefined;
elm: Node | undefined;
parentElm?: Element;
text: string | undefined;
Expand All @@ -33,8 +33,8 @@ export interface VNode {

export interface VElement extends VNode {
sel: string;
data: VElementData;
children: VNodes;
data: Readonly<VElementData>;
children: Readonly<VNodes>;
elm: Element | undefined;
text: undefined;
key: Key;
Expand All @@ -44,7 +44,7 @@ export interface VCustomElement extends VElement {
mode: 'closed' | 'open';
ctor: any;
// copy of the last allocated children.
aChildren?: VNodes;
aChildren?: Readonly<VNodes>;
}

export interface VText extends VNode {
Expand All @@ -63,19 +63,21 @@ export interface VComment extends VNode {
}

export interface VNodeData {
props?: Record<string, any>;
attrs?: Record<string, string | number | boolean>;
className?: string;
style?: string;
classMap?: Record<string, boolean>;
styleDecls?: Array<[string, string, boolean]>;
context?: Record<string, Record<string, any>>;
on?: Record<string, Function>;
svg?: boolean;
// All props are readonly because VElementData may be shared across VNodes
// due to hoisting optimizations
readonly props?: Readonly<Record<string, any>>;
readonly attrs?: Readonly<Record<string, string | number | boolean>>;
readonly className?: string;
readonly style?: string;
readonly classMap?: Readonly<Record<string, boolean>>;
readonly styleDecls?: Readonly<Array<[string, string, boolean]>>;
readonly context?: Readonly<Record<string, Record<string, any>>>;
readonly on?: Readonly<Record<string, Function>>;
readonly svg?: boolean;
}

export interface VElementData extends VNodeData {
key: Key;
readonly key: Key;
}

export interface Hooks<N extends VNode> {
Expand Down
24 changes: 15 additions & 9 deletions packages/@lwc/engine-core/src/framework/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
import { logError, logWarn } from '../shared/logger';
import { invokeEventListener } from './invoker';
import { getVMBeingRendered } from './template';
import { EmptyArray, EmptyObject } from './utils';
import { cloneAndOmitKey, EmptyArray, EmptyObject } from './utils';
import {
appendVM,
getAssociatedVMIfPresent,
Expand Down Expand Up @@ -204,7 +204,11 @@ const ElementHook: Hooks<VElement> = {
const { props } = vnode.data;
if (!isUndefined(props) && !isUndefined(props.innerHTML)) {
if (elm.innerHTML === props.innerHTML) {
delete props.innerHTML;
// Do a shallow clone since VNodeData may be shared across VNodes due to hoist optimization
vnode.data = {
...vnode.data,
props: cloneAndOmitKey(props, 'innerHTML'),
};
Comment on lines +207 to +211
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When reaching this code path, there is no way the vnode.data is hoisted as the innerHTML prop requires an invocation to api_sanitize_html_content. It is safe to mutate it in place by case it to any.

By the way, this block of code looks a little misplaced.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would kind of prefer to leave it as a clone. It seems unsafe for a developer to rely on quirks of the optimization algorithm. In the future, we may very well want to hoist:

api_sanitize_html_content("static string")

In fact, we are already caching the output on the $ctx anyway:

props: {
innerHTML:
$ctx._sanitizedHtml$0 ||
($ctx._sanitizedHtml$0 = api_sanitize_html_content(
"Hello <b>world</b>!"
)),
},

So if we apply the hosting optimization, arguably we may not need the $ctx optimization anymore.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uff, uff! elm.innerHTML === props.innerHTML broke my heart! I will have never approve that change. The whole point of the diffing algo is to avoid touching the DOM at all cost to compare things... reading the innerHTML from the element to compare it with the vnode is just wrong. We should fix that.

Additionally, the innerHTML should NOT be a regular property, it should have been a snabbdom module who's job is to compare two vnodes to determine whether or not the innerHTML needs to be set, rather than tricking the props module to not see the innerHTML for some cases when we know that it is the same. This is obviously not related to this PR, but needs to be fixed at some point. I might be missing something obvious here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caridy Opened an issue to track this: #2603

} else {
logWarn(
`Mismatch hydrating element <${elm.tagName.toLowerCase()}>: innerHTML values do not match for element, will recover from the difference`,
Expand Down Expand Up @@ -349,7 +353,7 @@ function addVNodeToChildLWC(vnode: VCustomElement) {
}

// [h]tml node
function h(sel: string, data: VElementData, children: VNodes): VElement {
function h(sel: string, data: Readonly<VElementData>, children: Readonly<VNodes>): VElement {
const vmBeingRendered = getVMBeingRendered()!;
if (process.env.NODE_ENV !== 'production') {
assert.isTrue(isString(sel), `h() 1st argument sel must be a string.`);
Expand Down Expand Up @@ -428,10 +432,10 @@ function ti(value: any): number {
// [s]lot element node
function s(
slotName: string,
data: VElementData,
children: VNodes,
data: Readonly<VElementData>,
children: Readonly<VNodes>,
slotset: SlotSet | undefined
): VElement | VNodes {
): VElement | Readonly<VNodes> {
if (process.env.NODE_ENV !== 'production') {
assert.isTrue(isString(slotName), `s() 1st argument slotName must be a string.`);
assert.isTrue(isObject(data), `s() 2nd argument data must be an object.`);
Expand Down Expand Up @@ -791,8 +795,10 @@ function dc(
// the new vnode key is a mix of idx and compiler key, this is required by the diffing algo
// to identify different constructors as vnodes with different keys to avoid reusing the
// element used for previous constructors.
data.key = `dc:${idx}:${data.key}`;
return c(sel, Ctor, data, children);
// Shallow clone is necessary here becuase VElementData may be shared across VNodes due to
nolanlawson marked this conversation as resolved.
Show resolved Hide resolved
// hoisting optimization.
const newData = { ...data, key: `dc:${idx}:${data.key}` };
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The LWC compiler has been emitting unique keys to each VNode to speed up DOM diffing. The main downside with this is that it produces a lot of garbage in the generated template code.

I think there is certainly a better way to handle this without adding more junk to the generated code.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I was wondering what the key is for. If it's only to speed up DOM diffing, then what about removing it and relying on object equality? (Since objects are effectively immutable.) Or is the optimization still necessary?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keys are used to checking if vnodes are equal. it is primarily used whenever nodes are dynamic (if block, each block). In those cases, we can't use object equality as those VNodes can't be hoisted.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keys are to denotate placeables for elements in the template, that's the way I think about them, and we use that information in multiple places to determine whether or not a vnode is the result of a particular placeable or another.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another alternative here is to not use ... due to the perf implications, and instead just use obj inheritance via Object.create(data, { key: { value: dc:${idx}:${data.key} } }). Considering that we never use hasOwnProperty with such object, we should be fine with this as well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, this is not a critical path since dynamic components are almost never used, just saying! :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... I would personally prefer to keep ... because it's idiomatic. Also, I'm guessing the perf implications are only from Locker, and we disallow lwc:dynamic on the platform, so I'm assuming that this line of code will never run with Locker enabled?

return c(sel, Ctor, newData, children);
}

/**
Expand All @@ -808,7 +814,7 @@ function dc(
* - children that are produced by iteration
*
*/
function sc(vnodes: VNodes): VNodes {
function sc(vnodes: Readonly<VNodes>): Readonly<VNodes> {
if (process.env.NODE_ENV !== 'production') {
assert.isTrue(isArray(vnodes), 'sc() api can only work with arrays.');
}
Expand Down
12 changes: 8 additions & 4 deletions packages/@lwc/engine-core/src/framework/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,11 @@ function throwHydrationError() {
assert.fail('Server rendered elements do not match client side generated elements');
}

export function hydrateChildrenHook(elmChildren: NodeListOf<ChildNode>, children: VNodes, vm?: VM) {
export function hydrateChildrenHook(
elmChildren: Readonly<NodeListOf<ChildNode>>,
children: Readonly<VNodes>,
vm?: VM
) {
if (process.env.NODE_ENV !== 'production') {
const filteredVNodes = ArrayFilter.call(children, (vnode) => !!vnode);

Expand Down Expand Up @@ -448,14 +452,14 @@ export function removeElmHook(vnode: VElement) {
}

// Using a WeakMap instead of a WeakSet because this one works in IE11 :(
const FromIteration: WeakMap<VNodes, 1> = new WeakMap();
const FromIteration: WeakMap<Readonly<VNodes>, 1> = new WeakMap();

// dynamic children means it was generated by an iteration
// in a template, and will require a more complex diffing algo.
export function markAsDynamicChildren(children: VNodes) {
export function markAsDynamicChildren(children: Readonly<VNodes>) {
FromIteration.set(children, 1);
}

export function hasDynamicChildren(children: VNodes): boolean {
export function hasDynamicChildren(children: Readonly<VNodes>): boolean {
return FromIteration.has(children);
}
11 changes: 11 additions & 0 deletions packages/@lwc/engine-core/src/framework/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,14 @@ export function parseStyleText(cssText: string): { [name: string]: string } {

return styleMap;
}

// Make a shallow copy of an object but omit the given key
export function cloneAndOmitKey(object: { [key: string]: any }, keyToOmit: string) {
const result: { [key: string]: any } = {};
for (const key of Object.keys(object)) {
if (key !== keyToOmit) {
result[key] = object[key];
}
}
return result;
}
8 changes: 4 additions & 4 deletions packages/@lwc/engine-core/src/framework/vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,9 @@ export interface VM<N = HostNode, E = HostElement> {
/** The component connection state. */
state: VMState;
/** The list of VNodes associated with the shadow tree. */
children: VNodes;
children: Readonly<VNodes>;
/** The list of adopted children VNodes. */
aChildren: VNodes;
aChildren: Readonly<VNodes>;
/** The list of custom elements VNodes currently rendered in the shadow tree. We keep track of
* those elements to efficiently unmount them when the parent component is disconnected without
* having to traverse the VNode tree. */
Expand Down Expand Up @@ -634,7 +634,7 @@ function runLightChildNodesDisconnectedCallback(vm: VM) {
* custom element itself will trigger the removal of anything slotted or anything
* defined on its shadow.
*/
function recursivelyDisconnectChildren(vnodes: VNodes) {
function recursivelyDisconnectChildren(vnodes: Readonly<VNodes>) {
for (let i = 0, len = vnodes.length; i < len; i += 1) {
const vnode: VCustomElement | VNode | null = vnodes[i];
if (!isNull(vnode) && isArray(vnode.children) && !isUndefined(vnode.elm)) {
Expand Down Expand Up @@ -699,7 +699,7 @@ function getErrorBoundaryVM(vm: VM): VM | undefined {
// slow path routine
// NOTE: we should probably more this routine to the synthetic shadow folder
// and get the allocation to be cached by in the elm instead of in the VM
export function allocateInSlot(vm: VM, children: VNodes) {
export function allocateInSlot(vm: VM, children: Readonly<VNodes>) {
const { cmpSlots: oldSlots } = vm;
const cmpSlots = (vm.cmpSlots = create(null));
for (let i = 0, len = children.length; i < len; i += 1) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,15 @@

var _implicitStylesheets = [stylesheet];

var stc0$1 = {
key: 0
};

function tmpl$1($api, $cmp, $slotset, $ctx) {
var api_dynamic_text = $api._ES5ProxyType ? $api.get("d") : $api.d,
api_text = $api._ES5ProxyType ? $api.get("t") : $api.t,
api_element = $api._ES5ProxyType ? $api.get("h") : $api.h;
return [api_element("div", {
key: 0
}, [api_text(api_dynamic_text($cmp._ES5ProxyType ? $cmp.get("x") : $cmp.x))])];
return [api_element("div", stc0$1, [api_text(api_dynamic_text($cmp._ES5ProxyType ? $cmp.get("x") : $cmp.x))])];
}

var _tmpl$1 = lwc.registerTemplate(tmpl$1);
Expand Down Expand Up @@ -134,20 +136,24 @@
tmpl: _tmpl$1
});

var stc0 = {
classMap: {
"container": true
},
key: 0
};
var stc1 = {
props: {
"x": "1"
},
key: 1
};
var stc2 = [];

function tmpl($api, $cmp, $slotset, $ctx) {
var api_custom_element = $api._ES5ProxyType ? $api.get("c") : $api.c,
api_element = $api._ES5ProxyType ? $api.get("h") : $api.h;
return [api_element("div", {
classMap: {
"container": true
},
key: 0
}, [api_custom_element("x-foo", _xFoo, {
props: {
"x": "1"
},
key: 1
}, [])])];
return [api_element("div", stc0, [api_custom_element("x-foo", _xFoo, stc1, stc2)])];
}

var _tmpl = lwc.registerTemplate(tmpl);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
}
var _implicitStylesheets = [stylesheet];

const stc0$1 = {
key: 0
};
function tmpl$1($api, $cmp, $slotset, $ctx) {
const {d: api_dynamic_text, t: api_text, h: api_element} = $api;
return [api_element("div", {
key: 0
}, [api_text(api_dynamic_text($cmp.x))])];
return [api_element("div", stc0$1, [api_text(api_dynamic_text($cmp.x))])];
}
var _tmpl$1 = lwc.registerTemplate(tmpl$1);
tmpl$1.stylesheets = [];
Expand Down Expand Up @@ -42,19 +43,22 @@
tmpl: _tmpl$1
});

const stc0 = {
classMap: {
"container": true
},
key: 0
};
const stc1 = {
props: {
"x": "1"
},
key: 1
};
const stc2 = [];
function tmpl($api, $cmp, $slotset, $ctx) {
const {c: api_custom_element, h: api_element} = $api;
return [api_element("div", {
classMap: {
"container": true
},
key: 0
}, [api_custom_element("x-foo", _xFoo, {
props: {
"x": "1"
},
key: 1
}, [])])];
return [api_element("div", stc0, [api_custom_element("x-foo", _xFoo, stc1, stc2)])];
}
var _tmpl = lwc.registerTemplate(tmpl);
tmpl.stylesheets = [];
Expand Down
Loading