import { DEBUG, DEBUG_MORE } from '../../config';
import {
fastRaf, onTickEnd, throttleWithPrimaryRaf, throttleWithRaf,
} from '../../util/schedulers';
import { flatten, orderBy } from '../../util/iteratees';
import arePropsShallowEqual, { getUnequalProps } from '../../util/arePropsShallowEqual';
import { handleError } from '../../util/handleError';
import { removeAllDelegatedListeners } from './dom-events';
export type Props = AnyLiteral;
export type FC
= (props: P) => any;
// eslint-disable-next-line @typescript-eslint/naming-convention
export type FC_withDebug =
FC
& {
DEBUG_contentComponentName?: string;
};
export enum VirtualElementTypesEnum {
Empty,
Text,
Tag,
Component,
}
interface VirtualElementEmpty {
type: VirtualElementTypesEnum.Empty;
target?: Node;
}
interface VirtualElementText {
type: VirtualElementTypesEnum.Text;
target?: Node;
value: string;
}
export interface VirtualElementTag {
type: VirtualElementTypesEnum.Tag;
target?: Node;
tag: string;
props: Props;
children: VirtualElementChildren;
}
export interface VirtualElementComponent {
type: VirtualElementTypesEnum.Component;
componentInstance: ComponentInstance;
props: Props;
children: VirtualElementChildren;
}
export type StateHookSetter = (newValue: ((current: T) => T) | T) => void;
interface ComponentInstance {
$element: VirtualElementComponent;
Component: FC;
name: string;
props: Props;
renderedValue?: any;
isMounted: boolean;
hooks: {
state: {
cursor: number;
byCursor: {
value: any;
nextValue: any;
setter: StateHookSetter;
}[];
};
effects: {
cursor: number;
byCursor: {
effect: () => void;
dependencies?: any[];
cleanup?: Function;
}[];
};
memos: {
cursor: number;
byCursor: {
current: any;
dependencies: any[];
}[];
};
};
prepareForFrame?: () => void;
forceUpdate?: () => void;
onUpdate?: () => void;
}
export type VirtualElement =
VirtualElementEmpty
| VirtualElementText
| VirtualElementTag
| VirtualElementComponent;
export type VirtualRealElement =
VirtualElementTag
| VirtualElementComponent;
export type VirtualElementChildren = VirtualElement[];
const Fragment = Symbol('Fragment');
let renderingInstance: ComponentInstance;
export function isEmptyElement($element: VirtualElement): $element is VirtualElementEmpty {
return $element.type === VirtualElementTypesEnum.Empty;
}
export function isTextElement($element: VirtualElement): $element is VirtualElementText {
return $element.type === VirtualElementTypesEnum.Text;
}
export function isTagElement($element: VirtualElement): $element is VirtualElementTag {
return $element.type === VirtualElementTypesEnum.Tag;
}
export function isComponentElement($element: VirtualElement): $element is VirtualElementComponent {
return $element.type === VirtualElementTypesEnum.Component;
}
export function isRealElement($element: VirtualElement): $element is VirtualRealElement {
return isTagElement($element) || isComponentElement($element);
}
function createElement(
source: string | FC | typeof Fragment,
props: Props,
...children: any[]
): VirtualRealElement | VirtualElementChildren {
if (!props) {
props = {};
}
children = flatten(children);
if (source === Fragment) {
return children;
} else if (typeof source === 'function') {
return createComponentInstance(source, props, children);
} else {
return buildTagElement(source, props, children);
}
}
function createComponentInstance(Component: FC, props: Props, children: any[]): VirtualElementComponent {
let parsedChildren: any | any[] | undefined;
if (children.length === 0) {
parsedChildren = undefined;
} else if (children.length === 1) {
[parsedChildren] = children;
} else {
parsedChildren = children;
}
const componentInstance: ComponentInstance = {
$element: {} as VirtualElementComponent,
Component,
name: Component.name,
props: {
...props,
...(parsedChildren && { children: parsedChildren }),
},
isMounted: false,
hooks: {
state: {
cursor: 0,
byCursor: [],
},
effects: {
cursor: 0,
byCursor: [],
},
memos: {
cursor: 0,
byCursor: [],
},
},
};
componentInstance.$element = buildComponentElement(componentInstance);
return componentInstance.$element;
}
function buildComponentElement(
componentInstance: ComponentInstance,
children: VirtualElementChildren = [],
): VirtualElementComponent {
const { props } = componentInstance;
return {
componentInstance,
type: VirtualElementTypesEnum.Component,
props,
children,
};
}
function buildTagElement(tag: string, props: Props, children: any[]): VirtualElementTag {
return {
type: VirtualElementTypesEnum.Tag,
tag,
props,
children: dropEmptyTail(children).map(buildChildElement),
};
}
// We only need placeholders in the middle of collection (to ensure other elements order).
function dropEmptyTail(children: any[]) {
let i = children.length - 1;
for (; i >= 0; i--) {
if (!isEmptyPlaceholder(children[i])) {
break;
}
}
return i + 1 < children.length ? children.slice(0, i + 1) : children;
}
function isEmptyPlaceholder(child: any) {
// eslint-disable-next-line no-null/no-null
return child === false || child === null || child === undefined;
}
function buildChildElement(child: any): VirtualElement {
if (isEmptyPlaceholder(child)) {
return buildEmptyElement();
} else if (isRealElement(child)) {
return child;
} else {
return buildTextElement(child);
}
}
function buildTextElement(value: any): VirtualElementText {
return {
type: VirtualElementTypesEnum.Text,
value: String(value),
};
}
function buildEmptyElement(): VirtualElementEmpty {
return { type: VirtualElementTypesEnum.Empty };
}
// eslint-disable-next-line @typescript-eslint/naming-convention
const DEBUG_components: AnyLiteral = {};
document.addEventListener('dblclick', () => {
// eslint-disable-next-line no-console
console.log('COMPONENTS', orderBy(Object.values(DEBUG_components), 'renderCount', 'desc'));
});
export function renderComponent(componentInstance: ComponentInstance) {
renderingInstance = componentInstance;
componentInstance.hooks.state.cursor = 0;
componentInstance.hooks.effects.cursor = 0;
componentInstance.hooks.memos.cursor = 0;
const { Component, props } = componentInstance;
let newRenderedValue;
try {
// eslint-disable-next-line @typescript-eslint/naming-convention
let DEBUG_startAt: number | undefined;
if (DEBUG) {
const componentName = componentInstance.name;
if (!DEBUG_components[componentName]) {
DEBUG_components[componentName] = {
componentName,
renderCount: 0,
renderTimes: [],
};
}
if (DEBUG_MORE) {
if (componentName !== 'TeactMemoWrapper' && componentName !== 'TeactNContainer') {
// eslint-disable-next-line no-console
console.log(`[Teact] Render ${componentName}`);
}
}
DEBUG_startAt = performance.now();
}
newRenderedValue = Component(props);
if (DEBUG) {
const renderTime = performance.now() - DEBUG_startAt!;
const componentName = componentInstance.name;
if (renderTime > 7) {
// eslint-disable-next-line no-console
console.warn(`[Teact] Slow component render: ${componentName}, ${Math.round(renderTime)} ms`);
}
DEBUG_components[componentName].renderTimes.push(renderTime);
DEBUG_components[componentName].renderCount++;
}
} catch (err) {
handleError(err);
newRenderedValue = componentInstance.renderedValue;
}
if (componentInstance.isMounted && newRenderedValue === componentInstance.renderedValue) {
return componentInstance.$element;
}
componentInstance.renderedValue = newRenderedValue;
const newChild = buildChildElement(newRenderedValue);
componentInstance.$element = buildComponentElement(componentInstance, [newChild]);
return componentInstance.$element;
}
export function hasElementChanged($old: VirtualElement, $new: VirtualElement) {
if (typeof $old !== typeof $new) {
return true;
} else if ($old.type !== $new.type) {
return true;
} else if (isTextElement($old) && isTextElement($new)) {
return $old.value !== $new.value;
} else if (isTagElement($old) && isTagElement($new)) {
return ($old.tag !== $new.tag) || ($old.props.key !== $new.props.key);
} else if (isComponentElement($old) && isComponentElement($new)) {
return (
$old.componentInstance.Component !== $new.componentInstance.Component
) || (
$old.props.key !== $new.props.key
);
}
return false;
}
export function unmountTree($element: VirtualElement) {
if (!isRealElement($element)) {
return;
}
if (isComponentElement($element)) {
unmountComponent($element.componentInstance);
} else if ($element.target) {
removeAllDelegatedListeners($element.target as HTMLElement);
// Trying to help GC
// eslint-disable-next-line no-null/no-null
$element.target = null as any;
}
$element.children.forEach(unmountTree);
}
export function mountComponent(componentInstance: ComponentInstance) {
renderComponent(componentInstance);
componentInstance.isMounted = true;
return componentInstance.$element;
}
function unmountComponent(componentInstance: ComponentInstance) {
if (!componentInstance.isMounted) {
return;
}
componentInstance.hooks.memos.byCursor.forEach((hook) => {
// eslint-disable-next-line no-null/no-null
hook.current = null;
});
componentInstance.hooks.effects.byCursor.forEach(({ cleanup }) => {
if (typeof cleanup === 'function') {
try {
cleanup();
} catch (err) {
handleError(err);
}
}
});
componentInstance.isMounted = false;
helpGc(componentInstance);
}
// We need to remove all references to DOM objects. We also clean all other references, just in case.
function helpGc(componentInstance: ComponentInstance) {
/* eslint-disable no-null/no-null */
componentInstance.hooks.effects.byCursor.forEach((hook) => {
hook.cleanup = null as any;
hook.effect = null as any;
hook.dependencies = null as any;
});
componentInstance.hooks.state.byCursor.forEach((hook) => {
hook.value = null as any;
hook.nextValue = null as any;
hook.setter = null as any;
});
componentInstance.hooks.memos.byCursor.forEach((hook) => {
hook.dependencies = null as any;
});
componentInstance.hooks = null as any;
componentInstance.$element = null as any;
componentInstance.renderedValue = null as any;
componentInstance.Component = null as any;
componentInstance.props = null as any;
componentInstance.forceUpdate = null as any;
componentInstance.onUpdate = null as any;
/* eslint-enable no-null/no-null */
}
function prepareComponentForFrame(componentInstance: ComponentInstance) {
if (!componentInstance.isMounted) {
return;
}
componentInstance.hooks.state.byCursor.forEach((hook) => {
hook.value = hook.nextValue;
});
componentInstance.prepareForFrame = throttleWithPrimaryRaf(() => prepareComponentForFrame(componentInstance));
componentInstance.forceUpdate = throttleWithRaf(() => forceUpdateComponent(componentInstance));
}
function forceUpdateComponent(componentInstance: ComponentInstance) {
if (!componentInstance.isMounted || !componentInstance.onUpdate) {
return;
}
const currentElement = componentInstance.$element;
renderComponent(componentInstance);
if (componentInstance.$element !== currentElement) {
componentInstance.onUpdate();
}
}
export function getTarget($element: VirtualElement): Node | undefined {
if (isComponentElement($element)) {
return getTarget($element.children[0]);
} 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): [T, StateHookSetter] {
const { cursor, byCursor } = renderingInstance.hooks.state;
if (byCursor[cursor] === undefined) {
byCursor[cursor] = {
value: initial,
nextValue: initial,
setter: ((componentInstance) => (newValue: ((current: T) => T) | T) => {
if (byCursor[cursor].nextValue !== newValue) {
byCursor[cursor].nextValue = typeof newValue === 'function'
? (newValue as (current: T) => T)(byCursor[cursor].value)
: newValue;
if (!componentInstance.prepareForFrame || !componentInstance.forceUpdate) {
componentInstance.prepareForFrame = throttleWithPrimaryRaf(
() => prepareComponentForFrame(componentInstance),
);
componentInstance.forceUpdate = throttleWithRaf(
() => forceUpdateComponent(componentInstance),
);
}
componentInstance.prepareForFrame();
componentInstance.forceUpdate();
if (DEBUG_MORE) {
if (componentInstance.name !== 'TeactNContainer') {
// eslint-disable-next-line no-console
console.log(
'[Teact.useState]',
componentInstance.name,
// `componentInstance.Component` may be set to `null` by GC helper
componentInstance.Component && (componentInstance.Component as FC_withDebug).DEBUG_contentComponentName
? `> ${(componentInstance.Component as FC_withDebug).DEBUG_contentComponentName}`
: '',
`Forced update at cursor #${cursor}, next value: `,
byCursor[cursor].nextValue,
);
}
}
}
})(renderingInstance),
};
}
renderingInstance.hooks.state.cursor++;
return [
byCursor[cursor].value,
byCursor[cursor].setter,
];
}
function useLayoutEffectBase(
schedulerFn: typeof onTickEnd | typeof requestAnimationFrame,
effect: () => Function | void,
dependencies?: any[],
debugKey?: string,
) {
const { cursor, byCursor } = renderingInstance.hooks.effects;
const componentInstance = renderingInstance;
const exec = () => {
if (!componentInstance.isMounted) {
return;
}
const { cleanup } = byCursor[cursor];
if (typeof cleanup === 'function') {
try {
cleanup();
} catch (err) {
handleError(err);
}
}
byCursor[cursor].cleanup = effect() as Function;
};
if (byCursor[cursor] !== undefined && dependencies && byCursor[cursor].dependencies) {
if (dependencies.some((dependency, i) => dependency !== byCursor[cursor].dependencies![i])) {
if (debugKey) {
const causedBy = dependencies.reduce((res, newValue, i) => {
const prevValue = byCursor[cursor].dependencies![i];
if (newValue !== prevValue) {
res.push(`${i}: ${prevValue} => ${newValue}`);
}
return res;
}, []);
// eslint-disable-next-line no-console
console.log(
'[Teact]',
debugKey,
'Effect caused by dependencies.',
causedBy.join(', '),
);
}
schedulerFn(exec);
}
} else {
schedulerFn(exec);
}
byCursor[cursor] = {
effect,
dependencies,
cleanup: byCursor[cursor] ? byCursor[cursor].cleanup : undefined,
};
renderingInstance.hooks.effects.cursor++;
}
export function useEffect(effect: () => Function | void, dependencies?: any[], debugKey?: string) {
return useLayoutEffectBase(fastRaf, effect, dependencies, debugKey);
}
export function useLayoutEffect(effect: () => Function | void, dependencies?: any[], debugKey?: string) {
return useLayoutEffectBase(onTickEnd, effect, dependencies, debugKey);
}
export function useMemo(resolver: () => T, dependencies: any[], debugKey?: string): T {
const { cursor, byCursor } = renderingInstance.hooks.memos;
let { current } = byCursor[cursor] || {};
if (
byCursor[cursor] === undefined
|| dependencies.some((dependency, i) => dependency !== byCursor[cursor].dependencies[i])
) {
if (DEBUG && debugKey) {
// eslint-disable-next-line no-console
console.log(
`[Teact.useMemo] ${renderingInstance.name} (${debugKey}): Update is caused by:`,
byCursor[cursor]
? getUnequalProps(dependencies, byCursor[cursor].dependencies).join(', ')
: '[first render]',
);
}
current = resolver();
}
byCursor[cursor] = {
current,
dependencies,
};
renderingInstance.hooks.memos.cursor++;
return current;
}
export function useCallback(newCallback: F, dependencies: any[]): F {
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => newCallback, dependencies);
}
export function useRef(initial: T): { current: T };
export function useRef(): { current: T | undefined }; // TT way (empty is `undefined`)
export function useRef(initial: null): { current: T | null }; // React way (empty is `null`)
// eslint-disable-next-line no-null/no-null
export function useRef(initial?: T | null) {
return useMemo(() => ({
current: initial,
// eslint-disable-next-line react-hooks/exhaustive-deps
}), []);
}
export function memo(Component: T, areEqual = arePropsShallowEqual, debugKey?: string) {
return function TeactMemoWrapper(props: Props) {
// eslint-disable-next-line react-hooks/rules-of-hooks
const propsRef = useRef(props);
const renderedRef = useRef();
if (!renderedRef.current || (propsRef.current && !areEqual(propsRef.current, props))) {
if (DEBUG && debugKey) {
// eslint-disable-next-line no-console
console.log(
`[Teact.memo] ${Component.name} (${debugKey}): Update is caused by:`,
getUnequalProps(propsRef.current!, props).join(', '),
);
}
propsRef.current = props;
renderedRef.current = createElement(Component, props) as VirtualElementComponent;
}
return renderedRef.current;
} as T;
}
// We need to keep it here for JSX.
export default {
createElement,
Fragment,
};