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

Rework stateful component rendering #305

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 71 additions & 136 deletions packages/diffhtml-components/lib/component.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
EMPTY,
ComponentTreeCache,
InstanceCache,
Props,
Transaction,
Expand All @@ -14,12 +13,11 @@ import {
$$unsubscribe,
$$type,
$$hooks,
$$insertAfter,
} from './util/symbols';
import diff from './util/binding';
import middleware from './middleware';
import middleware from './lifecycle/middleware';

const { outerHTML, innerHTML, createTree, release, Internals } = diff;
const { Internals } = diff;
const { isArray } = Array;
const { setPrototypeOf, defineProperty, keys, assign } = Object;
const RenderDebounce = new WeakMap();
Expand All @@ -32,7 +30,7 @@ const getObserved = ({ defaultProps }) =>
defaultProps ? keys(defaultProps) : EMPTY.ARR;

/**
* Creates the `component.props` object.
* Creates the props object for Web components.
*
* @param {any} domNode
* @param {Props} existingProps
Expand All @@ -47,32 +45,29 @@ const createProps = (domNode, existingProps = {}) => {
};

/**
* Creates the `component.state` object.
* Creates the state object for Web components.
*
* @param {Component} domNode
* @param {State} newState
*/
const createState = (domNode, newState) => assign({}, domNode.state, newState);

/**
* Finds all VTrees associated with a component.
* Recreates the VTree used for diffing a stateful component. In the case of
* Web components, this will use the Shadow Root instead of creating a virtual
* fragment.
*
* @param {VTree[]} childTrees
* @param {VTree} vTree
* @param {Component} instance - {Component} instance
* @param {Boolean} isWebComponent - Is this a web component?
* @return {VTree | HTMLElement} Recreated VTree including component references
*/
const getChildTrees = (childTrees, vTree) => {
ComponentTreeCache.forEach((parentTree, childTree) => {
if (parentTree === vTree) {
ComponentTreeCache.delete(childTree);

if (typeof childTree.rawNodeName !== 'function') {
childTrees.push(childTree);
}
else {
getChildTrees(childTrees, childTree);
}
}
});
const createMountPoint = (instance, isWebComponent) => {
if (isWebComponent) {
return /** @type {any} */(instance).shadowRoot;
}
else {
return instance[$$vTree];
}
};

/**
Expand Down Expand Up @@ -113,7 +108,6 @@ export default class Component {
static unsubscribeMiddleware() {
const unsubscribe = Component[$$unsubscribe];
unsubscribe && unsubscribe();
ComponentTreeCache.clear();
InstanceCache.clear();
}

Expand All @@ -136,7 +130,7 @@ export default class Component {
/** @type {any} */ (instance).attachShadow({ mode: 'open' });
/** @type {any} */ (instance)[$$type] = 'web';

} catch (e) {
} catch {
// Not a Web Component.
/** @type {any} */ (instance)[$$type] = 'class';
}
Expand All @@ -159,13 +153,12 @@ export default class Component {
}

/**
* @param {Props} props
* @param {State} state
* @param {Props=} _props
* @param {State=} _state
*
* @returns {VTree[] | VTree | undefined}
* @returns {VTree[] | VTree | any}
*/
// @ts-ignore
render(props, state) {
render(_props, _state) {
return undefined;
}

Expand Down Expand Up @@ -234,134 +227,76 @@ export default class Component {

/**
* Stateful render. Used when a component changes and needs to re-render
* itself. This is triggered on `setState` and `forceUpdate` calls.
* itself and the underlying tree it contains. This is triggered on
* `setState` and `forceUpdate` calls with class components and `createState`
* updates with function components.
*
* Web Components are easy to implement using the Shadow DOM to encapsulate
* the mount point using `innerHTML`.
*
* @return {Transaction | undefined}
* React-like components are supported by recreating the previous component
* tree and comparing this to the new tree using `outerHTML`.
*
* @return {Transaction | unknown | undefined}
*/
[$$render]() {
// This is a WebComponent, so do something different.
if (this[$$type] === 'web') {
const oldProps = this.props;
const oldState = this.state;

this.props = createProps(this, this.props);
this.state = createState(this, this.state);

ActiveRenderState.push(this);

if ($$hooks in this) {
this[$$hooks].i = 0;
}

const transaction = /** @type {Transaction} */(innerHTML(
/** @type {any} */ (this).shadowRoot,
this.render(this.props, this.state),
));

ActiveRenderState.length = 0;

this.componentDidUpdate(oldProps, oldState);
return transaction;
}
const vTree = /** @type {VTree=} */ this[$$vTree];
const oldProps = this.props;
const oldState = this.state;

// Get the fragment tree associated with this component. This is used to
// lookup rendered children.
let vTree = this[$$vTree];
// There are some slight differences between rendering a typical class or
// function based component and a web component. Web Components are
// rendered into the shadow DOM, while the class based components need
// extra logic to handle the invisible component boundaries.
const isWebComponent = this[$$type] === 'web';

/**
* Find all previously rendered top-level children associated to this
* component. This will be used to diff against the newly rendered
* elements.
* Recreate the existing tree including this component. This is not
* directly stored anywhere, as the VDOM tree is flattened, and must be
* recreated per render.
*
* @type {VTree[]}
* @type {VTree | HTMLElement}
*/
const childTrees = [];
const mount = createMountPoint(this, isWebComponent);

// Lookup all DOM nodes that were associated at the top level with this
// component.
vTree && getChildTrees(childTrees, vTree);
if (isWebComponent) {
this.props = createProps(this, this.props);
this.state = createState(this, this.state);
}

// Render directly from the Component.
// Make this component active and then synchronously render.
ActiveRenderState.push(this);

// TBD Is this needed now that components track their own descendents?
if ($$hooks in this) {
this[$$hooks].i = 0;
}

let renderTree = this.render(this.props, this.state);
ActiveRenderState.length = 0;

// Do not render.
if (!renderTree) {
return;
}

const renderTreeAsVTree = /** @type {VTree} */ (renderTree);
const rendered = this.render(this.props, this.state);

// Always compare a fragment to a fragment. If the renderTree was not
// wrapped, ensure it is here.
if (renderTreeAsVTree.nodeType !== 11) {
const isList = 'length' in renderTree;
const renderTreeAsList = /** @type {VTree[]} */ (renderTree);
const transaction = Internals.Transaction.create(
mount,
rendered,
{ inner: true },
);

renderTree = createTree(isList ? renderTreeAsList : [renderTreeAsVTree]);
// Set the VTree attributes to match the current props and use this as the initial state for a re-render.
if (vTree) {
assign(vTree.attributes, this.props);
transaction.state.oldTree = vTree;
transaction.state.isDirty = false;
}

// Put all the nodes together into a fragment for diffing.
const fragment = createTree(childTrees);
const tasks = [...Internals.defaultTasks];
const syncTreesIndex = tasks.indexOf(Internals.tasks.syncTrees);

// Inject a custom task after syncing has finished, but before patching has
// occured. This gives us time to add additional patch logic per render.
tasks.splice(syncTreesIndex + 1, 0, (/** @type {Transaction} */transaction) => {
let lastTree = null;

// Reconcile all top-level replacements and additions.
for (let i = 0; i < fragment.childNodes.length; i++) {
const childTree = fragment.childNodes[i];

// Replace if the nodes are different.
if (childTree && childTrees[i] && childTrees[i] !== childTree) {
transaction.patches.push(
Internals.PATCH_TYPE.REPLACE_CHILD,
childTree,
childTrees[i],
);

lastTree = childTree;
}
// Add if there is no old Node.
else if (lastTree && !childTrees[i]) {
transaction.patches.push(
Internals.PATCH_TYPE.INSERT_BEFORE,
$$insertAfter,
childTree,
lastTree,
);

lastTree = childTree;
}
// Keep the old node.
else {
lastTree = childTrees[i];
}

ComponentTreeCache.set(childTree, vTree);
}
// Middleware and task changes can affect the return value, it's not always guarenteed to be the transaction
// this allows us to tap into that return value and remain consistent.
const retVal = transaction.start();

return transaction;
});

// Compare the existing component node(s) to the new node(s).
const transaction = /** @type {Transaction} */(outerHTML(fragment, renderTree, { tasks }));

// Empty the fragment after using.
fragment.childNodes.length = 0;
release(fragment);
// Reset the active state after rendering so we don't accidentally bleed
// into other components render cycle.
ActiveRenderState.length = 0;

this.componentDidUpdate(this.props, this.state);
return transaction;
this.componentDidUpdate(oldProps, oldState);
return retVal;
}

connectedCallback() {
Expand All @@ -370,7 +305,7 @@ export default class Component {
// This callback gets called during replace operations, there is no point
// in re-rendering or creating a new shadow root due to this.

// Always do a full render when mounting, so that something is visible.
// This is the initial render for the Web Component
this[$$render]();

this.componentDidMount();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import componentWillUnmount from './lifecycle/component-will-unmount';
import { invokeRef, invokeRefsForVTree } from './lifecycle/invoke-refs';
import diff from './util/binding';
import { Transaction } from './util/types';
import componentWillUnmount from './component-will-unmount';
import { invokeRef, invokeRefsForVTree } from './invoke-refs';
import diff from '../util/binding';
import { Transaction } from '../util/types';

const { createNode, NodeCache, PATCH_TYPE, decodeEntities } = diff.Internals;
const { NodeCache, PATCH_TYPE, decodeEntities, createNode } = diff.Internals;
const uppercaseEx = /[A-Z]/g;

/**
Expand Down Expand Up @@ -53,6 +53,7 @@ export default transaction => {
}
}

// TBD Remove because invokeRefs is handled in afterMount
if (name === 'ref') {
invokeRef(createNode(vTree), vTree);
}
Expand All @@ -69,6 +70,7 @@ export default transaction => {
case PATCH_TYPE.REPLACE_CHILD: {
const oldTree = patches[i + 2];

invokeRefsForVTree(oldTree, null);
componentWillUnmount(oldTree);

i += 3;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ComponentTreeCache, InstanceCache, VTree } from '../util/types';
import { InstanceCache, VTree } from '../util/types';
import diff from '../util/binding';
import { $$hooks } from '../util/symbols';

Expand All @@ -11,32 +11,26 @@ const { release, Internals } = diff;
* @param {VTree} vTree - The respecting tree pointing to the component
*/
export default function componentWillUnmount(vTree) {
const componentTree = ComponentTreeCache.get(vTree);

/** @type {VTree[]} */
const childTrees = [];

ComponentTreeCache.forEach((parentTree, childTree) => {
if (parentTree === componentTree) {
childTrees.push(childTree);
}
});

const domNode = Internals.NodeCache.get(vTree);

// Clean up attached Shadow DOM.
if (domNode && /** @type {any} */ (domNode).shadowRoot) {
release(/** @type {any} */ (domNode).shadowRoot);
}
else {
Internals.memory.unprotectVTree(vTree);
}

vTree.childNodes.forEach(componentWillUnmount);
for (let i = 0; i < vTree.childNodes.length; i++) {
componentWillUnmount(vTree.childNodes[i]);
}

if (!InstanceCache.has(componentTree)) {
if (!InstanceCache.has(vTree)) {
return;
}

const instance = InstanceCache.get(componentTree);
InstanceCache.delete(componentTree);
const instance = InstanceCache.get(vTree);
InstanceCache.delete(vTree);

// Empty out all hooks for gc. If using a stateless class or function, they
// may not have this value set.
Expand All @@ -45,13 +39,6 @@ export default function componentWillUnmount(vTree) {
instance[$$hooks].i = 0;
}

ComponentTreeCache.delete(vTree);

// If there is a parent, ensure it is called recursively.
if (ComponentTreeCache.has(componentTree)) {
componentWillUnmount(componentTree);
}

// Ensure this is a stateful component. Stateless components do not get
// lifecycle events yet.
instance && instance.componentWillUnmount && instance.componentWillUnmount();
Expand Down
Loading