Teact: Support components returning fragments

This commit is contained in:
Alexander Zinchuk 2022-05-30 15:40:38 +04:00
parent b2d4f78c65
commit fa6eec434f
5 changed files with 281 additions and 165 deletions

View File

@ -0,0 +1,74 @@
import React, { useState } from '../../lib/teact/teact';
const INTERACTIVE = 'cursor: pointer; text-decoration: underline;';
const TestA = () => {
const [shouldRender, setShouldRender] = useState<boolean>(true);
function handleClick() {
setShouldRender(false);
setTimeout(() => {
setShouldRender(true);
}, 1000);
}
if (!shouldRender) {
// eslint-disable-next-line no-null/no-null
return null;
}
return (
<>
<span onClick={handleClick} style={INTERACTIVE}>4</span>
<span onClick={handleClick} style={INTERACTIVE}>5</span>
<span onClick={handleClick} style={INTERACTIVE}>6</span>
</>
);
};
const TestB = () => {
const [shouldRender, setShouldRender] = useState<boolean>(true);
function handleClick() {
setShouldRender(false);
setTimeout(() => {
setShouldRender(true);
}, 1000);
}
return (
<>
{shouldRender && <span>7</span>}
<span onClick={handleClick} style={INTERACTIVE}>8</span>
{shouldRender && (
<>
<span>9</span>
<span>10</span>
</>
)}
</>
);
};
const TestNoContainer = () => {
const [aKey, setAKey] = useState(1);
function handleClick() {
setAKey((current) => (current === 1 ? 0 : 1));
}
return (
<>
<span onClick={handleClick} style={INTERACTIVE}>1</span>
<span>2</span>
<span>3</span>
<TestA key={aKey} />
<TestB />
<span>11</span>
<span>12</span>
<span>13</span>
</>
);
};
export default TestNoContainer;

View File

@ -8,7 +8,7 @@ function tick() {
<h2>It is {new Date().toLocaleTimeString()}.</h2>
</div>
);
TeactDOM.render(element, document.getElementById('root'));
TeactDOM.render(element, document.getElementById('root')!);
}
setInterval(tick, 1000);

View File

@ -30,7 +30,7 @@ updateWebmanifest();
TeactDOM.render(
<App />,
document.getElementById('root'),
document.getElementById('root')!,
);
if (DEBUG) {

View File

@ -1,24 +1,25 @@
import type {
VirtualElement,
VirtualElementComponent,
VirtualRealElement,
VirtualElementTag,
VirtualElementParent,
VirtualElementChildren,
VirtualElementReal,
} from './teact';
import {
hasElementChanged,
isComponentElement,
isEmptyElement,
isRealElement,
isTagElement,
isParentElement,
isTextElement,
isEmptyElement,
mountComponent,
renderComponent,
unmountTree,
getTarget,
setTarget,
unmountComponent,
} from './teact';
import generateIdFor from '../../util/generateIdFor';
import { DEBUG } from '../../config';
import { addEventListener, removeEventListener } from './dom-events';
import { addEventListener, removeAllDelegatedListeners, removeEventListener } from './dom-events';
import { unique } from '../../util/iteratees';
type VirtualDomHead = {
@ -37,11 +38,7 @@ const headsByElement: Record<string, VirtualDomHead> = {};
// eslint-disable-next-line @typescript-eslint/naming-convention
let DEBUG_virtualTreeSize = 1;
function render($element?: VirtualElement, parentEl?: HTMLElement | null) {
if (!parentEl) {
return undefined;
}
function render($element: VirtualElement | undefined, parentEl: HTMLElement) {
let headId = parentEl.getAttribute('data-teact-head-id');
if (!headId) {
headId = generateIdFor(headsByElement);
@ -50,7 +47,8 @@ function render($element?: VirtualElement, parentEl?: HTMLElement | null) {
}
const $head = headsByElement[headId];
$head.children = [renderWithVirtual(parentEl, $head.children[0], $element, $head, 0) as VirtualElement];
const $newElement = renderWithVirtual(parentEl, $head.children[0], $element, $head, 0);
$head.children = $newElement ? [$newElement] : [];
if (process.env.APP_ENV === 'perf') {
DEBUG_virtualTreeSize = 0;
@ -62,38 +60,36 @@ function render($element?: VirtualElement, parentEl?: HTMLElement | null) {
return undefined;
}
function renderWithVirtual(
function renderWithVirtual<T extends VirtualElement | undefined>(
parentEl: HTMLElement,
$current: VirtualElement | undefined,
$new: VirtualElement | undefined,
$parent: VirtualRealElement | VirtualDomHead,
$new: T,
$parent: VirtualElementParent | VirtualDomHead,
index: number,
{
skipComponentUpdate = false,
forceIndex = false,
fragment,
moveDirection,
}: {
options: {
skipComponentUpdate?: boolean;
forceIndex?: boolean;
nextSibling?: ChildNode;
fragment?: DocumentFragment;
moveDirection?: 'up' | 'down';
} = {},
) {
): T {
const { skipComponentUpdate, fragment } = options;
let { nextSibling } = options;
const isCurrentComponent = $current && isComponentElement($current);
const isNewComponent = $new && isComponentElement($new);
const $newAsReal = $new as VirtualElementReal;
if (
!skipComponentUpdate
&& isCurrentComponent && isNewComponent
&& !hasElementChanged($current!, $new!)
) {
$new = updateComponent($current as VirtualElementComponent, $new as VirtualElementComponent);
$new = updateComponent($current, $new as VirtualElementComponent) as typeof $new;
}
// Parent element may have changed, so we need to update the listener closure.
if (!skipComponentUpdate && isNewComponent && ($new as VirtualElementComponent).componentInstance.isMounted) {
setupComponentUpdateListener($new as VirtualElementComponent, $parent, index, parentEl);
setupComponentUpdateListener(parentEl, $new as VirtualElementComponent, $parent, index);
}
if ($current === $new) {
@ -101,72 +97,72 @@ function renderWithVirtual(
}
if (DEBUG && $new) {
const newTarget = getTarget($new);
if (newTarget && (!$current || newTarget !== getTarget($current))) {
const newTarget = 'target' in $new && $new.target;
if (newTarget && (!$current || ('target' in $current && newTarget !== $current.target))) {
throw new Error('[Teact] Cached virtual element was moved within tree');
}
}
if (!$current && $new) {
if (isNewComponent) {
$new = initComponent($new as VirtualElementComponent, $parent, index, parentEl);
}
const node = createNode($new);
setTarget($new, node);
if (forceIndex && parentEl.childNodes[index]) {
parentEl.insertBefore(node, parentEl.childNodes[index]);
$new = initComponent(parentEl, $new as VirtualElementComponent, $parent, index) as typeof $new;
mountComponentChildren(parentEl, $new as VirtualElementComponent, { nextSibling, fragment });
} else {
(fragment || parentEl).appendChild(node);
const node = createNode($newAsReal);
$newAsReal.target = node;
insertBefore(fragment || parentEl, node, nextSibling);
}
} else if ($current && !$new) {
parentEl.removeChild(getTarget($current)!);
unmountTree($current);
remount(parentEl, $current, undefined);
} else if ($current && $new) {
if (hasElementChanged($current, $new)) {
if (!nextSibling) {
nextSibling = getNextSibling($current);
}
if (isNewComponent) {
$new = initComponent($new as VirtualElementComponent, $parent, index, parentEl);
$new = initComponent(parentEl, $new as VirtualElementComponent, $parent, index) as typeof $new;
remount(parentEl, $current, undefined);
mountComponentChildren(parentEl, $new as VirtualElementComponent, { nextSibling, fragment });
} else {
const node = createNode($newAsReal);
$newAsReal.target = node;
remount(parentEl, $current, node, nextSibling);
}
const node = createNode($new);
setTarget($new, node);
parentEl.replaceChild(node, getTarget($current)!);
unmountTree($current);
} else {
const areComponents = isCurrentComponent && isNewComponent;
const currentTarget = getTarget($current);
const isComponent = isCurrentComponent && isNewComponent;
if (isComponent) {
($new as VirtualElementComponent).children = renderChildren(
$current,
$new as VirtualElementComponent,
parentEl,
nextSibling,
);
} else {
const $currentAsReal = $current as VirtualElementReal;
const currentTarget = $currentAsReal.target!;
if (!areComponents) {
setTarget($new, currentTarget!);
setTarget($current, undefined as any); // Help GC
$newAsReal.target = currentTarget;
$currentAsReal.target = undefined; // Help GC
if ('props' in $current && 'props' in $new) {
$new.props.ref = $current.props.ref;
}
}
const isTag = isTagElement($current);
if (isTag) {
const $newAsTag = $new as VirtualElementTag;
if (isRealElement($new)) {
if (moveDirection) {
const node = currentTarget!;
const nextSibling = parentEl.childNodes[moveDirection === 'up' ? index : index + 1];
$newAsTag.props.ref = $current.props.ref;
if (nextSibling) {
parentEl.insertBefore(node, nextSibling);
} else {
(fragment || parentEl).appendChild(node);
insertBefore(parentEl, currentTarget, nextSibling);
}
}
if (!areComponents) {
updateAttributes(($current as VirtualRealElement), $new, currentTarget as HTMLElement);
}
updateAttributes($current, $newAsTag, currentTarget as HTMLElement);
$new.children = renderChildren(
($current as VirtualRealElement),
$new,
areComponents ? parentEl : currentTarget as HTMLElement,
);
$newAsTag.children = renderChildren(
$current,
$newAsTag,
currentTarget as HTMLElement,
);
}
}
}
}
@ -175,21 +171,20 @@ function renderWithVirtual(
}
function initComponent(
$element: VirtualElementComponent, $parent: VirtualRealElement | VirtualDomHead, index: number, parentEl: HTMLElement,
parentEl: HTMLElement,
$element: VirtualElementComponent,
$parent: VirtualElementParent | VirtualDomHead,
index: number,
) {
if (!isComponentElement($element)) {
return $element;
}
const { componentInstance } = $element;
if (!componentInstance.isMounted) {
$element = mountComponent(componentInstance);
setupComponentUpdateListener($element, $parent, index, parentEl);
setupComponentUpdateListener(parentEl, $element, $parent, index);
const $firstChild = $element.children[0];
if (isComponentElement($firstChild)) {
$element.children = [initComponent($firstChild, $element, 0, parentEl)];
$element.children = [initComponent(parentEl, $firstChild, $element, 0)];
}
componentInstance.isMounted = true;
@ -205,7 +200,10 @@ function updateComponent($current: VirtualElementComponent, $new: VirtualElement
}
function setupComponentUpdateListener(
$element: VirtualElementComponent, $parent: VirtualRealElement | VirtualDomHead, index: number, parentEl: HTMLElement,
parentEl: HTMLElement,
$element: VirtualElementComponent,
$parent: VirtualElementParent | VirtualDomHead,
index: number,
) {
const { componentInstance } = $element;
@ -217,11 +215,26 @@ function setupComponentUpdateListener(
$parent,
index,
{ skipComponentUpdate: true },
) as VirtualElementComponent;
);
};
}
function createNode($element: VirtualElement): Node {
function mountComponentChildren(parentEl: HTMLElement, $element: VirtualElementComponent, options: {
nextSibling?: ChildNode;
fragment?: DocumentFragment;
}) {
$element.children = $element.children.map(($child, i) => {
return renderWithVirtual(parentEl, undefined, $child, $element, i, options);
});
}
function unmountComponentChildren(parentEl: HTMLElement, $element: VirtualElementComponent) {
$element.children.forEach(($child) => {
renderWithVirtual(parentEl, $child, undefined, $element, -1);
});
}
function createNode($element: VirtualElementReal): Node {
if (isEmptyElement($element)) {
return document.createTextNode('');
}
@ -230,10 +243,6 @@ function createNode($element: VirtualElement): Node {
return document.createTextNode($element.value);
}
if (isComponentElement($element)) {
return createNode($element.children[0] as VirtualElement);
}
const { tag, props, children = [] } = $element;
const element = document.createElement(tag);
@ -248,14 +257,83 @@ function createNode($element: VirtualElement): Node {
});
$element.children = children.map(($child, i) => (
renderWithVirtual(element, undefined, $child, $element, i) as VirtualElement
renderWithVirtual(element, undefined, $child, $element, i)
));
return element;
}
function remount(
parentEl: HTMLElement,
$current: VirtualElement,
node: Node | undefined,
componentNextSibling?: ChildNode,
) {
if (isComponentElement($current)) {
unmountComponent($current.componentInstance);
unmountComponentChildren(parentEl, $current);
if (node) {
insertBefore(parentEl, node, componentNextSibling);
}
} else {
if (node) {
parentEl.replaceChild(node, $current.target!);
} else {
parentEl.removeChild($current.target!);
}
unmountRealTree($current);
}
}
export function unmountRealTree($element: VirtualElement) {
if (isComponentElement($element)) {
unmountComponent($element.componentInstance);
} else {
if (isTagElement($element)) {
if ($element.target) {
removeAllDelegatedListeners($element.target as HTMLElement);
}
if ($element.props.ref) {
$element.props.ref.current = undefined; // Help GC
}
}
if ($element.target) {
$element.target = undefined; // Help GC
}
if (!isParentElement($element)) {
return;
}
}
$element.children.forEach(unmountRealTree);
}
function insertBefore(parentEl: HTMLElement | DocumentFragment, node: Node, nextSibling?: ChildNode) {
if (nextSibling) {
parentEl.insertBefore(node, nextSibling);
} else {
parentEl.appendChild(node);
}
}
function getNextSibling($current: VirtualElement): ChildNode | undefined {
if (isComponentElement($current)) {
const lastChild = $current.children[$current.children.length - 1];
return getNextSibling(lastChild);
}
const target = $current.target!;
const { nextSibling } = target;
return nextSibling || undefined;
}
function renderChildren(
$current: VirtualRealElement, $new: VirtualRealElement, currentEl: HTMLElement,
$current: VirtualElementParent, $new: VirtualElementParent, currentEl: HTMLElement, nextSibling?: ChildNode,
) {
if (DEBUG) {
DEBUG_checkKeyUniqueness($new.children);
@ -269,7 +347,12 @@ function renderChildren(
const newChildrenLength = $new.children.length;
const maxLength = Math.max(currentChildrenLength, newChildrenLength);
const newChildren = [];
const fragment = newChildrenLength > currentChildrenLength + 1 ? document.createDocumentFragment() : undefined;
const fragment = newChildrenLength > currentChildrenLength ? document.createDocumentFragment() : undefined;
const lastCurrentChild = $current.children[currentChildrenLength - 1];
const fragmentNextSibling = nextSibling || (
newChildrenLength > currentChildrenLength && lastCurrentChild ? getNextSibling(lastCurrentChild) : undefined
);
for (let i = 0; i < maxLength; i++) {
const $newChild = renderWithVirtual(
@ -278,7 +361,7 @@ function renderChildren(
$new.children[i],
$new,
i,
i >= currentChildrenLength ? { fragment } : undefined,
i >= currentChildrenLength ? { fragment } : { nextSibling },
);
if ($newChild) {
@ -287,7 +370,7 @@ function renderChildren(
}
if (fragment) {
currentEl.appendChild(fragment);
insertBefore(currentEl, fragment, fragmentNextSibling);
}
return newChildren;
@ -295,13 +378,13 @@ function renderChildren(
// This function allows to prepend/append a bunch of new DOM nodes to the top/bottom of preserved ones.
// It also allows to selectively move particular preserved nodes within their DOM list.
function renderFastListChildren($current: VirtualRealElement, $new: VirtualRealElement, currentEl: HTMLElement) {
function renderFastListChildren($current: VirtualElementParent, $new: VirtualElementParent, currentEl: HTMLElement) {
const newKeys = new Set(
$new.children.map(($newChild) => {
const key = 'props' in $newChild && $newChild.props.key;
// eslint-disable-next-line no-null/no-null
if (DEBUG && isRealElement($newChild) && (key === undefined || key === null)) {
if (DEBUG && isParentElement($newChild) && (key === undefined || key === null)) {
// eslint-disable-next-line no-console
console.warn('Missing `key` in `teactFastList`');
}
@ -388,9 +471,9 @@ function renderFastListChildren($current: VirtualRealElement, $new: VirtualRealE
newChildren.push(
renderWithVirtual(currentEl, currentChildInfo.$element, $newChild, $new, i, {
forceIndex: true,
moveDirection: shouldMoveNode ? (isMovingDown ? 'down' : 'up') : undefined,
})!,
// `+ 1` is needed because before moving down the node still takes place above
nextSibling: shouldMoveNode ? currentEl.childNodes[isMovingDown ? i + 1 : i] : undefined,
}),
);
});
@ -403,27 +486,25 @@ function renderFastListChildren($current: VirtualRealElement, $new: VirtualRealE
}
function renderFragment(
elements: VirtualElement[], fragmentIndex: number, parentEl: HTMLElement, $parent: VirtualRealElement,
elements: VirtualElement[], fragmentIndex: number, parentEl: HTMLElement, $parent: VirtualElementParent,
) {
const nextSibling = parentEl.childNodes[fragmentIndex];
if (elements.length === 1) {
return [renderWithVirtual(parentEl, undefined, elements[0], $parent, fragmentIndex, { forceIndex: true })!];
return [renderWithVirtual(parentEl, undefined, elements[0], $parent, fragmentIndex, { nextSibling })];
}
const fragment = document.createDocumentFragment();
const newChildren = elements.map(($element) => (
renderWithVirtual(parentEl, undefined, $element, $parent, fragmentIndex, { fragment })!
const newChildren = elements.map(($element, i) => (
renderWithVirtual(parentEl, undefined, $element, $parent, fragmentIndex + i, { fragment })
));
if (parentEl.childNodes[fragmentIndex]) {
parentEl.insertBefore(fragment, parentEl.childNodes[fragmentIndex]);
} else {
parentEl.appendChild(fragment);
}
insertBefore(parentEl, fragment, nextSibling);
return newChildren;
}
function updateAttributes($current: VirtualRealElement, $new: VirtualRealElement, element: HTMLElement) {
function updateAttributes($current: VirtualElementParent, $new: VirtualElementParent, element: HTMLElement) {
const currentEntries = Object.entries($current.props);
const newEntries = Object.entries($new.props);
@ -492,11 +573,11 @@ function removeAttribute(element: HTMLElement, key: string, value: any) {
}
// eslint-disable-next-line @typescript-eslint/naming-convention
function DEBUG_addToVirtualTreeSize($current: VirtualRealElement | VirtualDomHead) {
function DEBUG_addToVirtualTreeSize($current: VirtualElementParent | VirtualDomHead) {
DEBUG_virtualTreeSize += $current.children.length;
$current.children.forEach(($child) => {
if (isRealElement($child)) {
if (isParentElement($child)) {
DEBUG_addToVirtualTreeSize($child);
}
});

View File

@ -6,7 +6,6 @@ import {
import { orderBy } from '../../util/iteratees';
import { getUnequalProps } from '../../util/arePropsShallowEqual';
import { handleError } from '../../util/handleError';
import { removeAllDelegatedListeners } from './dom-events';
export type Props = AnyLiteral;
export type FC<P extends Props = any> = (props: P) => any;
@ -98,13 +97,18 @@ export type VirtualElement =
| VirtualElementText
| VirtualElementTag
| VirtualElementComponent;
export type VirtualRealElement =
export type VirtualElementParent =
VirtualElementTag
| VirtualElementComponent;
export type VirtualElementChildren = VirtualElement[];
export type VirtualElementReal = Exclude<VirtualElement, VirtualElementComponent>;
// Compatibility with JSX types
export type TeactNode = ReactElement | string | number | boolean;
export type TeactNode =
ReactElement
| string
| number
| boolean;
const Fragment = Symbol('Fragment');
@ -130,7 +134,7 @@ export function isComponentElement($element: VirtualElement): $element is Virtua
return $element.type === VirtualElementTypesEnum.Component;
}
export function isRealElement($element: VirtualElement): $element is VirtualRealElement {
export function isParentElement($element: VirtualElement): $element is VirtualElementParent {
return isTagElement($element) || isComponentElement($element);
}
@ -138,7 +142,7 @@ function createElement(
source: string | FC | typeof Fragment,
props: Props,
...children: any[]
): VirtualRealElement | VirtualElementChildren {
): VirtualElementParent | VirtualElementChildren {
if (!props) {
props = {};
}
@ -202,13 +206,13 @@ function buildComponentElement(
componentInstance: ComponentInstance,
children: VirtualElementChildren = [],
): VirtualElementComponent {
const { props } = componentInstance;
const builtChildren = dropEmptyTail(children).map(buildChildElement);
return {
componentInstance,
type: VirtualElementTypesEnum.Component,
props,
children,
componentInstance,
props: componentInstance.props,
children: builtChildren.length ? builtChildren : [buildEmptyElement()],
};
}
@ -242,7 +246,7 @@ function isEmptyPlaceholder(child: any) {
function buildChildElement(child: any): VirtualElement {
if (isEmptyPlaceholder(child)) {
return buildEmptyElement();
} else if (isRealElement(child)) {
} else if (isParentElement(child)) {
return child;
} else {
return buildTextElement(child);
@ -325,8 +329,8 @@ export function renderComponent(componentInstance: ComponentInstance) {
componentInstance.renderedValue = newRenderedValue;
const newChild = buildChildElement(newRenderedValue);
componentInstance.$element = buildComponentElement(componentInstance, [newChild]);
const children = Array.isArray(newRenderedValue) ? newRenderedValue : [newRenderedValue];
componentInstance.$element = buildComponentElement(componentInstance, children);
return componentInstance.$element;
}
@ -351,39 +355,13 @@ export function hasElementChanged($old: VirtualElement, $new: VirtualElement) {
return false;
}
export function unmountTree($element: VirtualElement) {
if (isComponentElement($element)) {
unmountComponent($element.componentInstance);
} else {
if (isTagElement($element)) {
if ($element.target) {
removeAllDelegatedListeners($element.target as HTMLElement);
}
if ($element.props.ref) {
$element.props.ref.current = undefined; // Help GC
}
}
if ($element.target) {
$element.target = undefined; // Help GC
}
if (!isRealElement($element)) {
return;
}
}
$element.children.forEach(unmountTree);
}
export function mountComponent(componentInstance: ComponentInstance) {
renderComponent(componentInstance);
componentInstance.isMounted = true;
return componentInstance.$element;
}
function unmountComponent(componentInstance: ComponentInstance) {
export function unmountComponent(componentInstance: ComponentInstance) {
if (!componentInstance.isMounted) {
return;
}
@ -464,23 +442,6 @@ function forceUpdateComponent(componentInstance: ComponentInstance) {
}
}
export function getTarget($element: VirtualElement): Node | undefined {
if (isComponentElement($element)) {
const componentElement = $element.children[0];
return componentElement ? getTarget(componentElement) : undefined;
} else {
return $element.target;
}
}
export function setTarget($element: VirtualElement, target: Node) {
if (isComponentElement($element)) {
setTarget($element.children[0], target);
} else {
$element.target = target;
}
}
export function useState<T>(initial?: T, debugKey?: string): [T, StateHookSetter<T>] {
const { cursor, byCursor } = renderingInstance.hooks.state;