import type { ReactElement } from 'react'; import { requestMeasure, requestMutation } from '../fasterdom/fasterdom'; import { DEBUG, DEBUG_MORE } from '../../config'; import { throttleWith } from '../../util/schedulers'; import { orderBy } from '../../util/iteratees'; import { logUnequalProps } from '../../util/arePropsShallowEqual'; import { incrementOverlayCounter } from '../../util/debugOverlay'; import { isSignal } from '../../util/signals'; import safeExec from '../../util/safeExec'; 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, Fragment, } interface VirtualElementEmpty { type: VirtualElementTypesEnum.Empty; target?: Node; } interface VirtualElementText { type: VirtualElementTypesEnum.Text; target?: Node; value: string; } export interface VirtualElementTag { type: VirtualElementTypesEnum.Tag; target?: HTMLElement; tag: string; props: Props; children: VirtualElementChildren; } export interface VirtualElementComponent { type: VirtualElementTypesEnum.Component; componentInstance: ComponentInstance; props: Props; children: VirtualElementChildren; } export interface VirtualElementFragment { type: VirtualElementTypesEnum.Fragment; children: VirtualElementChildren; } export type StateHookSetter = (newValue: ((current: T) => T) | T) => void; export enum MountState { New, Mounted, Unmounted, } interface ComponentInstance { id: number; $element: VirtualElementComponent; Component: FC; name: string; props: Props; renderedValue?: any; mountState: MountState; hooks: { state: { cursor: number; byCursor: { value: any; nextValue: any; setter: StateHookSetter; }[]; }; effects: { cursor: number; byCursor: { dependencies?: readonly any[]; schedule: NoneToVoidFunction; cleanup?: NoneToVoidFunction; releaseSignals?: NoneToVoidFunction; }[]; }; memos: { cursor: number; byCursor: { value: any; dependencies: any[]; }[]; }; refs: { cursor: number; byCursor: { current: any; }[]; }; }; prepareForFrame?: () => void; forceUpdate?: () => void; onUpdate?: () => void; } export type VirtualElement = VirtualElementEmpty | VirtualElementText | VirtualElementTag | VirtualElementComponent | VirtualElementFragment; export type VirtualElementParent = VirtualElementTag | VirtualElementComponent | VirtualElementFragment; export type VirtualElementChildren = VirtualElement[]; export type VirtualElementReal = Exclude; // Compatibility with JSX types export type TeactNode = ReactElement | string | number | boolean | TeactNode[]; type Effect = () => (NoneToVoidFunction | void); type EffectCleanup = NoneToVoidFunction; const Fragment = Symbol('Fragment'); const DEBUG_RENDER_THRESHOLD = 7; const DEBUG_EFFECT_THRESHOLD = 7; const DEBUG_SILENT_RENDERS_FOR = new Set(['TeactMemoWrapper', 'TeactNContainer', 'Button', 'ListItem', 'MenuItem']); let lastComponentId = 0; 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 isFragmentElement($element: VirtualElement): $element is VirtualElementFragment { return $element.type === VirtualElementTypesEnum.Fragment; } export function isParentElement($element: VirtualElement): $element is VirtualElementParent { return isTagElement($element) || isComponentElement($element) || isFragmentElement($element); } function createElement( source: string | FC | typeof Fragment, props: Props, ...children: any[] ): VirtualElementParent | VirtualElementChildren { children = children.flat(); if (source === Fragment) { return buildFragmentElement(children); } else if (typeof source === 'function') { return createComponentInstance(source, props || {}, children); } else { return buildTagElement(source, props || {}, children); } } function buildFragmentElement(children: any[]): VirtualElementFragment { return { type: VirtualElementTypesEnum.Fragment, children: dropEmptyTail(children, true).map(buildChildElement), }; } 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 = { id: ++lastComponentId, $element: {} as VirtualElementComponent, Component, name: Component.name, props: { ...props, ...(parsedChildren && { children: parsedChildren }), }, mountState: MountState.New, hooks: { state: { cursor: 0, byCursor: [], }, effects: { cursor: 0, byCursor: [], }, memos: { cursor: 0, byCursor: [], }, refs: { cursor: 0, byCursor: [], }, }, }; componentInstance.$element = buildComponentElement(componentInstance); return componentInstance.$element; } function buildComponentElement( componentInstance: ComponentInstance, children: VirtualElementChildren = [], ): VirtualElementComponent { return { type: VirtualElementTypesEnum.Component, componentInstance, props: componentInstance.props, children: dropEmptyTail(children, true).map(buildChildElement), }; } 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[], noEmpty = false) { let i = children.length - 1; for (; i >= 0; i--) { if (!isEmptyPlaceholder(children[i])) { break; } } if (i === children.length - 1) { return children; } if (i === -1 && noEmpty) { return children.slice(0, 1); } return children.slice(0, i + 1); } 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 (isParentElement(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 = { TOTAL: { name: 'TOTAL', renders: 0 } }; // eslint-disable-next-line @typescript-eslint/naming-convention const DEBUG_memos: Record = {}; const DEBUG_MEMOS_CALLS_THRESHOLD = 20; document.addEventListener('dblclick', () => { // eslint-disable-next-line no-console console.warn('COMPONENTS', orderBy( Object .values(DEBUG_components) .map(({ avgRenderTime, ...state }) => { return { ...state, ...(avgRenderTime !== undefined && { avgRenderTime: Number(avgRenderTime.toFixed(2)) }) }; }), 'renders', 'desc', )); // eslint-disable-next-line no-console console.warn('MEMOS', orderBy( Object .values(DEBUG_memos) .filter(({ calls }) => calls >= DEBUG_MEMOS_CALLS_THRESHOLD) .map((state) => ({ ...state, hitRate: Number(state.hitRate.toFixed(2)) })), 'hitRate', 'asc', )); }); let instancesPendingUpdate = new Set(); let idsToExcludeFromUpdate = new Set(); let pendingEffects = new Map(); let pendingCleanups = new Map(); let pendingLayoutEffects = new Map(); let pendingLayoutCleanups = new Map(); let areImmediateEffectsCaptured = false; /* Order: - component effect cleanups - component effects - measure tasks - mutation tasks - component updates - component layout effect cleanups - component layout effects - forced layout measure tasks - forced layout mutation tasks */ const runUpdatePassOnRaf = throttleWith(requestMeasure, () => { const runImmediateEffects = captureImmediateEffects(); idsToExcludeFromUpdate = new Set(); const instancesToUpdate = Array .from(instancesPendingUpdate) .sort((a, b) => a.id - b.id); instancesPendingUpdate = new Set(); const currentCleanups = pendingCleanups; pendingCleanups = new Map(); currentCleanups.forEach((cb) => cb()); const currentEffects = pendingEffects; pendingEffects = new Map(); currentEffects.forEach((cb) => cb()); requestMutation(() => { instancesToUpdate.forEach(prepareComponentForFrame); instancesToUpdate.forEach((instance) => { if (idsToExcludeFromUpdate!.has(instance.id)) { return; } forceUpdateComponent(instance); }); runImmediateEffects?.(); }); }); export function captureImmediateEffects() { if (areImmediateEffectsCaptured) { return undefined; } areImmediateEffectsCaptured = true; return runCapturedImmediateEffects; } function runCapturedImmediateEffects() { const currentLayoutCleanups = pendingLayoutCleanups; pendingLayoutCleanups = new Map(); currentLayoutCleanups.forEach((cb) => cb()); const currentLayoutEffects = pendingLayoutEffects; pendingLayoutEffects = new Map(); currentLayoutEffects.forEach((cb) => cb()); areImmediateEffectsCaptured = false; } export function renderComponent(componentInstance: ComponentInstance) { idsToExcludeFromUpdate.add(componentInstance.id); const { Component, props } = componentInstance; let newRenderedValue: any; safeExec(() => { renderingInstance = componentInstance; componentInstance.hooks.state.cursor = 0; componentInstance.hooks.effects.cursor = 0; componentInstance.hooks.memos.cursor = 0; componentInstance.hooks.refs.cursor = 0; // eslint-disable-next-line @typescript-eslint/naming-convention let DEBUG_startAt: number | undefined; if (DEBUG) { const componentName = DEBUG_resolveComponentName(Component); if (!DEBUG_components[componentName]) { DEBUG_components[componentName] = { name: componentName, renders: 0, avgRenderTime: 0, }; } if (DEBUG_MORE) { if (!DEBUG_SILENT_RENDERS_FOR.has(componentName)) { // eslint-disable-next-line no-console console.log(`[Teact] Render ${componentName}`); } } DEBUG_startAt = performance.now(); } newRenderedValue = Component(props); if (DEBUG) { const duration = performance.now() - DEBUG_startAt!; const componentName = DEBUG_resolveComponentName(Component); if (duration > DEBUG_RENDER_THRESHOLD) { // eslint-disable-next-line no-console console.warn(`[Teact] Slow component render: ${componentName}, ${Math.round(duration)} ms`); } const { renders, avgRenderTime } = DEBUG_components[componentName]; DEBUG_components[componentName].avgRenderTime = (avgRenderTime * renders + duration) / (renders + 1); DEBUG_components[componentName].renders++; DEBUG_components.TOTAL.renders++; if (DEBUG_MORE) { incrementOverlayCounter(`${componentName} renders`); incrementOverlayCounter(`${componentName} duration`, duration); } } }, () => { // eslint-disable-next-line no-console console.error(`[Teact] Error while rendering component ${componentInstance.name}`); newRenderedValue = componentInstance.renderedValue; }); if (componentInstance.mountState === MountState.Mounted && newRenderedValue === componentInstance.renderedValue) { return componentInstance.$element; } componentInstance.renderedValue = newRenderedValue; const children = Array.isArray(newRenderedValue) ? newRenderedValue : [newRenderedValue]; componentInstance.$element = buildComponentElement(componentInstance, children); 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 mountComponent(componentInstance: ComponentInstance) { renderComponent(componentInstance); componentInstance.mountState = MountState.Mounted; return componentInstance.$element; } export function unmountComponent(componentInstance: ComponentInstance) { if (componentInstance.mountState !== MountState.Mounted) { return; } idsToExcludeFromUpdate.add(componentInstance.id); componentInstance.hooks.effects.byCursor.forEach((effect) => { if (effect.cleanup) { safeExec(effect.cleanup); } effect.cleanup = undefined; effect.releaseSignals?.(); }); componentInstance.mountState = MountState.Unmounted; helpGc(componentInstance); } // We need to remove all references to DOM objects. We also clean all other references, just in case function helpGc(componentInstance: ComponentInstance) { componentInstance.hooks.effects.byCursor.forEach((hook) => { hook.schedule = undefined as any; hook.cleanup = undefined as any; hook.releaseSignals = undefined as any; hook.dependencies = undefined; }); componentInstance.hooks.state.byCursor.forEach((hook) => { hook.value = undefined; hook.nextValue = undefined; hook.setter = undefined as any; }); componentInstance.hooks.memos.byCursor.forEach((hook) => { hook.value = undefined as any; hook.dependencies = undefined as any; }); componentInstance.hooks.refs.byCursor.forEach((hook) => { hook.current = undefined as any; }); componentInstance.hooks = undefined as any; componentInstance.$element = undefined as any; componentInstance.renderedValue = undefined; componentInstance.Component = undefined as any; componentInstance.props = undefined as any; componentInstance.onUpdate = undefined; } function prepareComponentForFrame(componentInstance: ComponentInstance) { if (componentInstance.mountState !== MountState.Mounted) { return; } componentInstance.hooks.state.byCursor.forEach((hook) => { hook.value = hook.nextValue; }); } function forceUpdateComponent(componentInstance: ComponentInstance) { if (componentInstance.mountState !== MountState.Mounted || !componentInstance.onUpdate) { return; } const currentElement = componentInstance.$element; renderComponent(componentInstance); if (componentInstance.$element !== currentElement) { componentInstance.onUpdate(); } } export function useState(): [T | undefined, StateHookSetter]; export function useState(initial: T, debugKey?: string): [T, StateHookSetter]; export function useState(initial?: T, debugKey?: string): [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 (componentInstance.mountState === MountState.Unmounted) { return; } if (typeof newValue === 'function') { newValue = (newValue as (current: T) => T)(byCursor[cursor].nextValue); } if (byCursor[cursor].nextValue === newValue) { return; } byCursor[cursor].nextValue = newValue; instancesPendingUpdate.add(componentInstance); runUpdatePassOnRaf(); if (DEBUG_MORE) { // eslint-disable-next-line no-console console.log( '[Teact.useState]', DEBUG_resolveComponentName(componentInstance.Component), `State update at cursor #${cursor}${debugKey ? ` (${debugKey})` : ''}, next value: `, byCursor[cursor].nextValue, ); } })(renderingInstance), }; } renderingInstance.hooks.state.cursor++; return [ byCursor[cursor].value, byCursor[cursor].setter, ]; } function useEffectBase( isLayout: boolean, effect: Effect, dependencies?: readonly any[], debugKey?: string, ) { const { cursor, byCursor } = renderingInstance.hooks.effects; const componentInstance = renderingInstance; const runEffectCleanup = () => safeExec(() => { const { cleanup } = byCursor[cursor]; if (!cleanup) { return; } // eslint-disable-next-line @typescript-eslint/naming-convention let DEBUG_startAt: number | undefined; if (DEBUG) { DEBUG_startAt = performance.now(); } cleanup(); if (DEBUG) { const duration = performance.now() - DEBUG_startAt!; const componentName = DEBUG_resolveComponentName(componentInstance.Component); if (duration > DEBUG_EFFECT_THRESHOLD) { // eslint-disable-next-line no-console console.warn( `[Teact] Slow cleanup at effect cursor #${cursor}: ${componentName}, ${Math.round(duration)} ms`, ); } } }, () => { // eslint-disable-next-line no-console console.error(`[Teact] Error in effect cleanup at cursor #${cursor} in ${componentInstance.name}`); }, () => { byCursor[cursor].cleanup = undefined; }); const runEffect = () => safeExec(() => { if (componentInstance.mountState === MountState.Unmounted) { return; } // eslint-disable-next-line @typescript-eslint/naming-convention let DEBUG_startAt: number | undefined; if (DEBUG) { DEBUG_startAt = performance.now(); } const result = effect(); if (typeof result === 'function') { byCursor[cursor].cleanup = result; } if (DEBUG) { const duration = performance.now() - DEBUG_startAt!; const componentName = DEBUG_resolveComponentName(componentInstance.Component); if (duration > DEBUG_EFFECT_THRESHOLD) { // eslint-disable-next-line no-console console.warn(`[Teact] Slow effect at cursor #${cursor}: ${componentName}, ${Math.round(duration)} ms`); } } }, () => { // eslint-disable-next-line no-console console.error(`[Teact] Error in effect at cursor #${cursor} in ${componentInstance.name}`); }); function schedule() { const effectId = `${componentInstance.id}_${cursor}`; if (isLayout) { pendingLayoutCleanups.set(effectId, runEffectCleanup); pendingLayoutEffects.set(effectId, runEffect); } else { pendingCleanups.set(effectId, runEffectCleanup); pendingEffects.set(effectId, runEffect); } runUpdatePassOnRaf(); } if (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] Effect "${debugKey}" caused by dependencies.`, causedBy.join(', ')); } schedule(); } } else { if (debugKey) { // eslint-disable-next-line no-console console.log(`[Teact] Effect "${debugKey}" caused by missing dependencies.`); } schedule(); } const isFirstRun = !byCursor[cursor]; byCursor[cursor] = { ...byCursor[cursor], dependencies, schedule, }; function setupSignals() { const cleanups = dependencies?.filter(isSignal).map((signal) => signal.subscribe(() => { byCursor[cursor].schedule(); })); if (!cleanups?.length) { return undefined; } return () => { cleanups.forEach((cleanup) => cleanup()); }; } if (isFirstRun) { byCursor[cursor].releaseSignals = setupSignals(); } renderingInstance.hooks.effects.cursor++; } export function useEffect(effect: Effect, dependencies?: readonly any[], debugKey?: string) { return useEffectBase(false, effect, dependencies, debugKey); } export function useLayoutEffect(effect: Effect, dependencies?: readonly any[], debugKey?: string) { return useEffectBase(true, effect, dependencies, debugKey); } export function useMemo( resolver: () => T, dependencies: any[], debugKey?: string, debugHitRateKey?: string, ): T { const { cursor, byCursor } = renderingInstance.hooks.memos; let { value } = byCursor[cursor] || {}; // eslint-disable-next-line @typescript-eslint/naming-convention let DEBUG_state: typeof DEBUG_memos[string] | undefined; if (DEBUG && debugHitRateKey) { const instanceKey = `${debugHitRateKey}#${renderingInstance.id}`; DEBUG_state = DEBUG_memos[instanceKey]; if (!DEBUG_state) { DEBUG_state = { key: instanceKey, calls: 0, misses: 0, hitRate: 0, }; DEBUG_memos[instanceKey] = DEBUG_state; } DEBUG_state.calls++; DEBUG_state.hitRate = (DEBUG_state.calls - DEBUG_state.misses) / DEBUG_state.calls; } if ( byCursor[cursor] === undefined || dependencies.length !== byCursor[cursor].dependencies.length || dependencies.some((dependency, i) => dependency !== byCursor[cursor].dependencies[i]) ) { if (DEBUG) { if (debugKey) { const msg = `[Teact.useMemo] ${renderingInstance.name} (${debugKey}): Update is caused by:`; if (!byCursor[cursor]) { // eslint-disable-next-line no-console console.log(`${msg} [first render]`); } else { logUnequalProps(byCursor[cursor].dependencies, dependencies, msg, debugKey); } } if (DEBUG_state) { DEBUG_state.misses++; DEBUG_state.hitRate = (DEBUG_state.calls - DEBUG_state.misses) / DEBUG_state.calls; if ( DEBUG_state.calls % 10 === 0 && DEBUG_state.calls >= DEBUG_MEMOS_CALLS_THRESHOLD && DEBUG_state.hitRate < 0.25 ) { // eslint-disable-next-line no-console console.warn( // eslint-disable-next-line max-len `[Teact] ${DEBUG_state.key}: Hit rate is ${DEBUG_state.hitRate.toFixed(2)} for ${DEBUG_state.calls} calls`, ); } } } value = resolver(); } byCursor[cursor] = { value, dependencies, }; renderingInstance.hooks.memos.cursor++; return value; } export function useCallback(newCallback: F, dependencies: any[], debugKey?: string): F { // eslint-disable-next-line react-hooks-static-deps/exhaustive-deps return useMemo(() => newCallback, dependencies, debugKey); } 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) { const { cursor, byCursor } = renderingInstance.hooks.refs; if (!byCursor[cursor]) { byCursor[cursor] = { current: initial, }; } renderingInstance.hooks.refs.cursor++; return byCursor[cursor]; } export function memo(Component: T, debugKey?: string) { function TeactMemoWrapper(props: Props) { return useMemo( () => createElement(Component, props), // eslint-disable-next-line react-hooks-static-deps/exhaustive-deps Object.values(props), debugKey, DEBUG_MORE ? DEBUG_resolveComponentName(renderingInstance.Component) : undefined, ); } TeactMemoWrapper.DEBUG_contentComponentName = DEBUG_resolveComponentName(Component); return TeactMemoWrapper as T; } // eslint-disable-next-line @typescript-eslint/naming-convention export function DEBUG_resolveComponentName(Component: FC_withDebug) { // eslint-disable-next-line @typescript-eslint/naming-convention const { name, DEBUG_contentComponentName } = Component; if (name === 'TeactNContainer') { return `container>${DEBUG_contentComponentName}`; } if (name === 'TeactMemoWrapper') { return `memo>${DEBUG_contentComponentName}`; } return name + (DEBUG_contentComponentName ? `>${DEBUG_contentComponentName}` : ''); } export default { createElement, Fragment, };