From 1f0ac5b276ee12072855e0a0a1f37682b5a8205a Mon Sep 17 00:00:00 2001
From: zubiden <19638254+zubiden@users.noreply.github.com>
Date: Sun, 22 Feb 2026 23:43:18 +0100
Subject: [PATCH] Teact: Align effect order with other libraries (#6685)
---
src/components/App.tsx | 2 +-
src/components/test/TestCleanupOrder.tsx | 105 +++++++++++++++--------
src/lib/teact/teact.ts | 49 +++++++++--
3 files changed, 109 insertions(+), 47 deletions(-)
diff --git a/src/components/App.tsx b/src/components/App.tsx
index 8effa288b..ff8d7dd16 100644
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -24,7 +24,7 @@ import usePrevious from '../hooks/usePrevious';
import { useSignalEffect } from '../hooks/useSignalEffect';
import { getIsInBackground } from '../hooks/window/useBackgroundMode';
-// import Test from './test/TestSvg';
+// import Test from './test/TestCleanupOrder';
import Auth from './auth/Auth';
import Notifications from './common/Notifications';
import UiLoader from './common/UiLoader';
diff --git a/src/components/test/TestCleanupOrder.tsx b/src/components/test/TestCleanupOrder.tsx
index 2eeaede7c..139475e55 100644
--- a/src/components/test/TestCleanupOrder.tsx
+++ b/src/components/test/TestCleanupOrder.tsx
@@ -1,53 +1,84 @@
-import { useEffect, useLayoutEffect, useState } from '../../lib/teact/teact';
-
-const TestCleanupOrder = () => {
- const [, setRand] = useState(Math.random());
+/* eslint-disable no-console */
+import { type TeactNode, useEffect, useLayoutEffect, useState } from '../../lib/teact/teact';
+function Component1({ children }: { children?: TeactNode }) {
useEffect(() => {
- // eslint-disable-next-line no-console
- console.log('effect 1');
-
- setTimeout(() => {
- setRand(Math.random());
- }, 3000);
-
- return () => {
- // eslint-disable-next-line no-console
- console.log('cleanup 1');
- };
+ console.log('Effect 1');
+ return () => console.log('Cleanup 1');
});
useEffect(() => {
- // eslint-disable-next-line no-console
- console.log('effect 2');
-
- return () => {
- // eslint-disable-next-line no-console
- console.log('cleanup 2');
- };
+ console.log('Effect 1.2');
+ return () => console.log('Cleanup 1.2');
});
useLayoutEffect(() => {
- // eslint-disable-next-line no-console
- console.log('layout effect 1');
-
- return () => {
- // eslint-disable-next-line no-console
- console.log('layout cleanup 1');
- };
+ console.log('Layout 1');
+ return () => console.log('Layout Cleanup 1');
});
useLayoutEffect(() => {
- // eslint-disable-next-line no-console
- console.log('layout effect 2');
+ console.log('Layout 1.2');
+ return () => console.log('Layout Cleanup 1.2');
+ });
+ return children;
+}
- return () => {
- // eslint-disable-next-line no-console
- console.log('layout cleanup 2');
- };
+function Component1B({ children }: { children?: TeactNode }) {
+ useEffect(() => {
+ console.log('Effect 1b');
+ return () => console.log('Cleanup 1b');
});
- return
Test
;
-};
+ useLayoutEffect(() => {
+ console.log('Layout 1b');
+ return () => console.log('Layout Cleanup 1b');
+ });
+ return 'B';
+}
+
+function Component2({ children }: { children?: TeactNode }) {
+ useEffect(() => {
+ console.log('Effect 2');
+ return () => console.log('Cleanup 2');
+ });
+
+ useLayoutEffect(() => {
+ console.log('Layout 2');
+ return () => console.log('Layout Cleanup 2');
+ });
+ return children;
+}
+
+function Component3() {
+ useEffect(() => {
+ console.log('Effect 3');
+ return () => console.log('Cleanup 3');
+ });
+
+ useLayoutEffect(() => {
+ console.log('Layout 3');
+ return () => console.log('Layout Cleanup 3');
+ });
+ return Leaf
;
+}
+
+function TestCleanupOrder() {
+ const [isMounted, setIsMounted] = useState(true);
+ return (
+ setIsMounted((p) => !p)}>
+ {isMounted && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+ );
+}
export default TestCleanupOrder;
diff --git a/src/lib/teact/teact.ts b/src/lib/teact/teact.ts
index 8d1f9a663..0a0146366 100644
--- a/src/lib/teact/teact.ts
+++ b/src/lib/teact/teact.ts
@@ -155,6 +155,7 @@ const Fragment = Symbol('Fragment') as unknown as FC<{ children: TeactNode }>;
const DEBUG_RENDER_THRESHOLD = 7;
const DEBUG_EFFECT_THRESHOLD = 7;
const DEBUG_SILENT_RENDERS_FOR = new Set(['TeactMemoWrapper', 'TeactNContainer', 'Button', 'ListItem', 'MenuItem']);
+const MAX_EFFECT_CURSORS_PER_INSTANCE = 10000;
let contextCounter = 0;
@@ -315,12 +316,37 @@ if (DEBUG) {
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 pendingEffects = new Map();
+let pendingCleanups = new Map();
+let pendingLayoutEffects = new Map();
+let pendingLayoutCleanups = new Map();
let areImmediateEffectsCaptured = false;
+/*
+ Effect call order:
+ - Regular call order inside component
+ - Child to parent
+ - Parent to child on unmount
+ - Sibling behavior is not defined
+*/
+function runEffectsMap(map: Map) {
+ Array.from(map.entries())
+ .sort(([aKey], [bKey]) => {
+ const idA = Math.floor(aKey / MAX_EFFECT_CURSORS_PER_INSTANCE);
+ const idB = Math.floor(bKey / MAX_EFFECT_CURSORS_PER_INSTANCE);
+
+ const idDiff = idB - idA;
+ if (idDiff !== 0) {
+ return idDiff;
+ }
+
+ const cursorA = aKey % MAX_EFFECT_CURSORS_PER_INSTANCE;
+ const cursorB = bKey % MAX_EFFECT_CURSORS_PER_INSTANCE;
+
+ return cursorA - cursorB;
+ }).forEach(([_, cb]) => void cb());
+}
+
/*
Order:
- component effect cleanups
@@ -350,11 +376,11 @@ const runUpdatePassOnRaf = throttleWith(requestMeasure, () => {
const currentCleanups = pendingCleanups;
pendingCleanups = new Map();
- currentCleanups.forEach((cb) => cb());
+ runEffectsMap(currentCleanups);
const currentEffects = pendingEffects;
pendingEffects = new Map();
- currentEffects.forEach((cb) => cb());
+ runEffectsMap(currentEffects);
requestMutation(() => {
instancesToUpdate.forEach(prepareComponentForFrame);
@@ -382,11 +408,11 @@ export function captureImmediateEffects() {
function runCapturedImmediateEffects() {
const currentLayoutCleanups = pendingLayoutCleanups;
pendingLayoutCleanups = new Map();
- currentLayoutCleanups.forEach((cb) => cb());
+ runEffectsMap(currentLayoutCleanups);
const currentLayoutEffects = pendingLayoutEffects;
pendingLayoutEffects = new Map();
- currentLayoutEffects.forEach((cb) => cb());
+ runEffectsMap(currentLayoutEffects);
areImmediateEffectsCaptured = false;
}
@@ -738,6 +764,11 @@ function useEffectBase(
}
renderingInstance.hooks.effects.cursor++;
+
+ if (DEBUG && cursor >= MAX_EFFECT_CURSORS_PER_INSTANCE) {
+ // eslint-disable-next-line no-console
+ console.warn(`[Teact] Effect cursor #${cursor} in ${componentInstance.name} is out of bounds. How?`);
+ }
}
function scheduleEffect(
@@ -750,7 +781,7 @@ function scheduleEffect(
const cleanup = byCursor[cursor]?.cleanup;
const cleanupsContainer = isLayout ? pendingLayoutCleanups : pendingCleanups;
const effectsContainer = isLayout ? pendingLayoutEffects : pendingEffects;
- const effectId = `${componentInstance.id}_${cursor}`;
+ const effectId = componentInstance.id * MAX_EFFECT_CURSORS_PER_INSTANCE + cursor;
if (cleanup) {
const runEffectCleanup = () => safeExec(() => {