From fa6eec434fc3c02655fa51fd571ec8fe45ee6812 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Mon, 30 May 2022 15:40:38 +0400 Subject: [PATCH] Teact: Support components returning fragments --- src/components/test/TestNoContainer.tsx | 74 ++++++ src/components/test/testTick.tsx | 2 +- src/index.tsx | 2 +- src/lib/teact/teact-dom.ts | 295 +++++++++++++++--------- src/lib/teact/teact.ts | 73 ++---- 5 files changed, 281 insertions(+), 165 deletions(-) create mode 100644 src/components/test/TestNoContainer.tsx diff --git a/src/components/test/TestNoContainer.tsx b/src/components/test/TestNoContainer.tsx new file mode 100644 index 000000000..4d2f4739c --- /dev/null +++ b/src/components/test/TestNoContainer.tsx @@ -0,0 +1,74 @@ +import React, { useState } from '../../lib/teact/teact'; + +const INTERACTIVE = 'cursor: pointer; text-decoration: underline;'; + +const TestA = () => { + const [shouldRender, setShouldRender] = useState(true); + + function handleClick() { + setShouldRender(false); + setTimeout(() => { + setShouldRender(true); + }, 1000); + } + + if (!shouldRender) { + // eslint-disable-next-line no-null/no-null + return null; + } + + return ( + <> + 4 + 5 + 6 + + ); +}; + +const TestB = () => { + const [shouldRender, setShouldRender] = useState(true); + + function handleClick() { + setShouldRender(false); + setTimeout(() => { + setShouldRender(true); + }, 1000); + } + + return ( + <> + {shouldRender && 7} + 8 + {shouldRender && ( + <> + 9 + 10 + + )} + + ); +}; + +const TestNoContainer = () => { + const [aKey, setAKey] = useState(1); + + function handleClick() { + setAKey((current) => (current === 1 ? 0 : 1)); + } + + return ( + <> + 1 + 2 + 3 + + + 11 + 12 + 13 + + ); +}; + +export default TestNoContainer; diff --git a/src/components/test/testTick.tsx b/src/components/test/testTick.tsx index b8797c53f..e4cf40ad0 100644 --- a/src/components/test/testTick.tsx +++ b/src/components/test/testTick.tsx @@ -8,7 +8,7 @@ function tick() {

It is {new Date().toLocaleTimeString()}.

); - TeactDOM.render(element, document.getElementById('root')); + TeactDOM.render(element, document.getElementById('root')!); } setInterval(tick, 1000); diff --git a/src/index.tsx b/src/index.tsx index 3040e9cae..f038bd4a2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,7 +30,7 @@ updateWebmanifest(); TeactDOM.render( , - document.getElementById('root'), + document.getElementById('root')!, ); if (DEBUG) { diff --git a/src/lib/teact/teact-dom.ts b/src/lib/teact/teact-dom.ts index 5d6110eab..68576d5d4 100644 --- a/src/lib/teact/teact-dom.ts +++ b/src/lib/teact/teact-dom.ts @@ -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 = {}; // 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( 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); } }); diff --git a/src/lib/teact/teact.ts b/src/lib/teact/teact.ts index b2629bead..61ffe7f73 100644 --- a/src/lib/teact/teact.ts +++ b/src/lib/teact/teact.ts @@ -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

= (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; // 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(initial?: T, debugKey?: string): [T, StateHookSetter] { const { cursor, byCursor } = renderingInstance.hooks.state;