From dba6963c34c609a13dc3b69d46bacda71f58b9aa Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Sun, 23 Apr 2023 18:33:02 +0400 Subject: [PATCH] [Perf] Introduce Fasterdom and some performance fixes --- src/components/App.tsx | 2 + src/components/auth/AuthPhoneNumber.tsx | 3 +- src/components/auth/AuthQrCode.tsx | 13 +- src/components/common/AnimatedSticker.tsx | 19 +- .../common/ChatForumLastMessage.tsx | 4 +- src/components/common/CustomEmojiPicker.tsx | 46 +-- src/components/common/EmbeddedMessage.tsx | 14 +- src/components/common/PasswordForm.tsx | 3 +- src/components/common/Picker.tsx | 3 +- src/components/common/StickerButton.tsx | 32 ++- src/components/common/StickerSet.tsx | 29 +- .../common/hooks/useStickerPickerObservers.ts | 88 ++++++ src/components/left/main/ForumPanel.tsx | 8 +- .../left/main/hooks/useChatListEntry.tsx | 8 +- .../folders/SettingsFoldersChatsPicker.tsx | 3 +- src/components/main/ConfettiContainer.tsx | 5 +- src/components/main/Main.tsx | 4 +- .../mediaViewer/MediaViewerSlides.tsx | 12 +- .../mediaViewer/helpers/ghostAnimation.ts | 84 +++--- src/components/middle/HeaderActions.tsx | 7 +- src/components/middle/HeaderPinnedMessage.tsx | 15 +- src/components/middle/MessageList.tsx | 263 +++++++++--------- src/components/middle/MessageListContent.tsx | 4 + src/components/middle/MiddleColumn.tsx | 17 +- src/components/middle/MiddleHeader.tsx | 9 +- src/components/middle/MobileSearch.tsx | 27 +- .../middle/PinnedMessageNavigation.tsx | 4 +- .../middle/composer/AttachmentModal.tsx | 6 +- src/components/middle/composer/Composer.tsx | 23 +- .../middle/composer/EmojiPicker.tsx | 13 +- .../middle/composer/EmojiTooltip.tsx | 9 +- .../middle/composer/MessageInput.tsx | 58 ++-- src/components/middle/composer/PollModal.tsx | 6 +- .../middle/composer/StickerPicker.tsx | 46 +-- .../middle/composer/SymbolMenu.scss | 19 +- src/components/middle/composer/SymbolMenu.tsx | 27 +- .../composer/hooks/useCustomEmojiTooltip.ts | 3 +- .../middle/composer/hooks/useDraft.ts | 6 +- .../middle/composer/hooks/useEditing.ts | 25 +- .../middle/composer/hooks/useEmojiTooltip.ts | 3 +- .../composer/hooks/useInputCustomEmojis.ts | 6 +- .../composer/hooks/useMentionTooltip.ts | 3 +- .../composer/hooks/useVoiceRecording.ts | 28 +- src/components/middle/hooks/useAuthorWidth.ts | 21 ++ src/components/middle/hooks/useScrollHooks.ts | 37 ++- src/components/middle/hooks/useStickyDates.ts | 18 +- src/components/middle/message/Location.tsx | 23 +- src/components/middle/message/Message.tsx | 29 +- .../middle/message/MessageContextMenu.scss | 3 - src/components/middle/message/Photo.tsx | 7 +- src/components/middle/message/PollOption.tsx | 6 +- src/components/middle/message/RoundVideo.tsx | 12 +- .../message/helpers/calculateAuthorWidth.ts | 21 -- .../middle/message/hooks/useFocusMessage.ts | 5 +- .../middle/message/hooks/useOuterHandlers.ts | 6 +- .../middle/message/hooks/useVideoAutoPause.ts | 4 +- .../right/hooks/useTransitionFixes.ts | 23 +- src/components/ui/Button.tsx | 11 +- src/components/ui/Draggable.tsx | 16 +- src/components/ui/InfiniteScroll.tsx | 56 ++-- src/components/ui/ListItem.tsx | 4 +- src/components/ui/Menu.scss | 4 +- src/components/ui/Modal.tsx | 4 +- src/components/ui/ProgressSpinner.tsx | 19 +- src/components/ui/RippleEffect.tsx | 9 +- src/components/ui/Tab.tsx | 56 +++- src/components/ui/Transition.tsx | 158 ++++++----- src/config.ts | 5 +- src/global/actions/ui/calls.ts | 3 +- src/global/actions/ui/initial.ts | 56 ++-- src/hooks/useAsyncResolvers.ts | 16 ++ src/hooks/useBoundsInSharedCanvas.ts | 15 +- src/hooks/useCanvasBlur.ts | 27 +- src/hooks/useContextMenuHandlers.ts | 10 +- src/hooks/useDynamicColorListener.ts | 22 +- src/hooks/useFocusAfterAnimation.tsx | 8 +- src/hooks/useHistoryBack.ts | 7 +- src/hooks/useInputFocusOnOpen.ts | 4 +- src/hooks/useIntersectionObserver.ts | 24 +- src/hooks/useResizeObserver.ts | 4 +- src/hooks/useThrottledCallback.ts | 14 +- src/hooks/useVideoCleanup.ts | 5 +- src/index.tsx | 25 +- src/lib/fasterdom/fasterdom.ts | 86 ++++++ src/lib/fasterdom/layoutCauses.ts | 46 +++ src/lib/fasterdom/stricterdom.ts | 165 +++++++++++ src/lib/rlottie/RLottie.ts | 77 +++-- src/lib/teact/teact.ts | 171 ++++++------ src/lib/teact/teactn.tsx | 5 +- src/util/animation.ts | 23 +- src/util/audioPlayer.ts | 8 +- src/util/captureEvents.ts | 13 +- src/util/emoji.ts | 9 +- src/util/fastSmoothScroll.ts | 87 ++++-- src/util/fastSmoothScrollHorizontal.ts | 77 +++-- src/util/handleError.ts | 6 +- src/util/safeExec.ts | 20 ++ src/util/schedulers.ts | 16 +- src/util/switchTheme.ts | 50 ++-- src/util/voiceRecording.ts | 3 +- src/util/windowSize.ts | 7 +- 101 files changed, 1691 insertions(+), 982 deletions(-) create mode 100644 src/components/common/hooks/useStickerPickerObservers.ts create mode 100644 src/components/middle/hooks/useAuthorWidth.ts delete mode 100644 src/components/middle/message/helpers/calculateAuthorWidth.ts create mode 100644 src/lib/fasterdom/fasterdom.ts create mode 100644 src/lib/fasterdom/layoutCauses.ts create mode 100644 src/lib/fasterdom/stricterdom.ts create mode 100644 src/util/safeExec.ts diff --git a/src/components/App.tsx b/src/components/App.tsx index e61da5681..54e5086cc 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -156,7 +156,9 @@ const App: FC = ({ useEffect(() => { updateSizes(); + }, []); + useEffect(() => { if (IS_MULTITAB_SUPPORTED) return; addActiveTabChangeListener(() => { diff --git a/src/components/auth/AuthPhoneNumber.tsx b/src/components/auth/AuthPhoneNumber.tsx index f881f7dbd..7b5c14a5f 100644 --- a/src/components/auth/AuthPhoneNumber.tsx +++ b/src/components/auth/AuthPhoneNumber.tsx @@ -1,4 +1,5 @@ import type { ChangeEvent } from 'react'; +import { requestMeasure } from '../../lib/fasterdom/fasterdom'; import monkeyPath from '../../assets/monkey.svg'; @@ -149,7 +150,7 @@ const AuthPhoneNumber: FC = ({ const isJustPastedRef = useRef(false); const handlePaste = useCallback(() => { isJustPastedRef.current = true; - requestAnimationFrame(() => { + requestMeasure(() => { isJustPastedRef.current = false; }); }, []); diff --git a/src/components/auth/AuthQrCode.tsx b/src/components/auth/AuthQrCode.tsx index 4ef43d546..ffa07ba4c 100644 --- a/src/components/auth/AuthQrCode.tsx +++ b/src/components/auth/AuthQrCode.tsx @@ -1,6 +1,7 @@ import React, { - useEffect, useRef, memo, useCallback, + useEffect, useRef, memo, useCallback, useLayoutEffect, } from '../../lib/teact/teact'; +import { disableStrict, enableStrict } from '../../lib/fasterdom/stricterdom'; import { getActions, withGlobal } from '../../global'; import type { FC } from '../../lib/teact/teact'; @@ -33,6 +34,7 @@ type StateProps = const DATA_PREFIX = 'tg://login?token='; const QR_SIZE = 280; const QR_PLANE_SIZE = 54; +const QR_CODE_MUTATION_DURATION = 50; // The library is asynchronous and we need to wait for its mutation code let qrCodeStylingPromise: Promise; @@ -88,7 +90,7 @@ const AuthCode: FC = ({ const transitionClassNames = useMediaTransition(isQrMounted); - useEffect(() => { + useLayoutEffect(() => { if (!authQrCode || !qrCode) { return () => { unmarkQrMounted(); @@ -102,6 +104,8 @@ const AuthCode: FC = ({ const container = qrCodeRef.current!; const data = `${DATA_PREFIX}${authQrCode.token}`; + disableStrict(); + qrCode.update({ data, }); @@ -110,6 +114,11 @@ const AuthCode: FC = ({ qrCode.append(container); markQrMounted(); } + + setTimeout(() => { + enableStrict(); + }, QR_CODE_MUTATION_DURATION); + return undefined; }, [connectionState, authQrCode, isQrMounted, markQrMounted, unmarkQrMounted, qrCode]); diff --git a/src/components/common/AnimatedSticker.tsx b/src/components/common/AnimatedSticker.tsx index 0efe30417..d3b63eb7c 100644 --- a/src/components/common/AnimatedSticker.tsx +++ b/src/components/common/AnimatedSticker.tsx @@ -1,11 +1,11 @@ import type { RefObject } from 'react'; import type { FC } from '../../lib/teact/teact'; +import { requestMeasure } from '../../lib/fasterdom/fasterdom'; import React, { useEffect, useRef, memo, useCallback, useState, useMemo, } from '../../lib/teact/teact'; -import { fastRaf } from '../../util/schedulers'; import buildClassName from '../../util/buildClassName'; import buildStyle from '../../util/buildStyle'; import generateIdFor from '../../util/generateIdFor'; @@ -13,7 +13,7 @@ import generateIdFor from '../../util/generateIdFor'; import useHeavyAnimationCheck, { isHeavyAnimating } from '../../hooks/useHeavyAnimationCheck'; import usePriorityPlaybackCheck, { isPriorityPlaybackActive } from '../../hooks/usePriorityPlaybackCheck'; import useBackgroundMode, { isBackgroundModeActive } from '../../hooks/useBackgroundMode'; -import useSyncEffect from '../../hooks/useSyncEffect'; +import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps'; import { useStateRef } from '../../hooks/useStateRef'; export type OwnProps = { @@ -122,7 +122,8 @@ const AnimatedSticker: FC = ({ // Wait until element is properly mounted if (sharedCanvas && !sharedCanvas.offsetParent) { - setTimeout(exec, ANIMATION_END_TIMEOUT); + // `requestMeasure` is useful as timeouts are run in parallel with image loadings and thus causing reflow + setTimeout(() => requestMeasure(exec), ANIMATION_END_TIMEOUT); return; } @@ -156,7 +157,7 @@ const AnimatedSticker: FC = ({ exec(); } else { ensureLottie().then(() => { - fastRaf(() => { + requestMeasure(() => { if (containerRef.current) { exec(); } @@ -164,8 +165,8 @@ const AnimatedSticker: FC = ({ }); } }, [ - animation, renderId, tgsUrl, color, isLowPriority, noLoop, onLoad, quality, size, speed, onEnded, onLoop, - viewId, sharedCanvas, sharedCanvasCoords, + animation, renderId, tgsUrl, color, isLowPriority, noLoop, onLoad, quality, size, speed, onEnded, onLoop, viewId, + sharedCanvas, sharedCanvasCoords, ]); useEffect(() => { @@ -197,7 +198,7 @@ const AnimatedSticker: FC = ({ }, [animation, playRef, playSegmentRef, viewId]); const playAnimationOnRaf = useCallback(() => { - fastRaf(playAnimation); + requestMeasure(playAnimation); }, [playAnimation]); const pauseAnimation = useCallback(() => { @@ -206,13 +207,13 @@ const AnimatedSticker: FC = ({ } }, [animation, viewId]); - useSyncEffect(([prevNoLoop]) => { + useEffectWithPrevDeps(([prevNoLoop]) => { if (prevNoLoop !== undefined && noLoop !== prevNoLoop) { animation?.setNoLoop(noLoop); } }, [noLoop, animation]); - useSyncEffect(([prevSharedCanvasCoords]) => { + useEffectWithPrevDeps(([prevSharedCanvasCoords]) => { if (prevSharedCanvasCoords !== undefined && sharedCanvasCoords !== prevSharedCanvasCoords) { animation?.setSharedCanvasCoords(viewId, sharedCanvasCoords); } diff --git a/src/components/common/ChatForumLastMessage.tsx b/src/components/common/ChatForumLastMessage.tsx index d79932899..7c1f5a78d 100644 --- a/src/components/common/ChatForumLastMessage.tsx +++ b/src/components/common/ChatForumLastMessage.tsx @@ -1,6 +1,6 @@ import React, { memo, - useLayoutEffect, + useEffect, useMemo, useRef, useState, @@ -71,7 +71,7 @@ const ChatForumLastMessage: FC = ({ }); } - useLayoutEffect(() => { + useEffect(() => { const lastMessageElement = lastMessageRef.current; const mainColumnElement = mainColumnRef.current; if (!lastMessageElement || !mainColumnElement) return; diff --git a/src/components/common/CustomEmojiPicker.tsx b/src/components/common/CustomEmojiPicker.tsx index c5cf08a89..65833406d 100644 --- a/src/components/common/CustomEmojiPicker.tsx +++ b/src/components/common/CustomEmojiPicker.tsx @@ -35,10 +35,10 @@ import { } from '../../global/selectors'; import useAsyncRendering from '../right/hooks/useAsyncRendering'; -import { useIntersectionObserver } from '../../hooks/useIntersectionObserver'; import useHorizontalScroll from '../../hooks/useHorizontalScroll'; import useLang from '../../hooks/useLang'; import useAppLayout from '../../hooks/useAppLayout'; +import { useStickerPickerObservers } from './hooks/useStickerPickerObservers'; import Loading from '../ui/Loading'; import Button from '../ui/Button'; @@ -54,6 +54,7 @@ type OwnProps = { scrollHeaderRef?: RefObject; chatId?: string; className?: string; + isHidden?: boolean; loadAndPlay: boolean; idPrefix?: string; withDefaultTopicIcons?: boolean; @@ -84,9 +85,8 @@ type StateProps = { isCurrentUserPremium?: boolean; }; -const SMOOTH_SCROLL_DISTANCE = 500; +const SMOOTH_SCROLL_DISTANCE = 100; const HEADER_BUTTON_WIDTH = 52; // px (including margin) -const STICKER_INTERSECTION_THROTTLE = 200; const DEFAULT_ID_PREFIX = 'custom-emoji-set'; const TOP_REACTIONS_COUNT = 16; const RECENT_REACTIONS_COUNT = 32; @@ -99,12 +99,11 @@ const STICKER_SET_IDS_WITH_COVER = new Set([ PREMIUM_STICKER_SET_ID, ]); -const stickerSetIntersections: boolean[] = []; - const CustomEmojiPicker: FC = ({ scrollContainerRef, scrollHeaderRef, className, + isHidden, loadAndPlay, addedCustomEmojiIds, customEmojisById, @@ -155,31 +154,12 @@ const CustomEmojiPicker: FC = ({ : Object.values(pickTruthy(customEmojisById!, recentCustomEmojiIds!)); }, [customEmojisById, isStatusPicker, recentCustomEmojiIds, recentStatusEmojis]); - const { observe: observeIntersection } = useIntersectionObserver({ - rootRef: containerRef, - throttleMs: STICKER_INTERSECTION_THROTTLE, - }, (entries) => { - entries.forEach((entry) => { - const { id } = entry.target as HTMLDivElement; - if (!id || !id.startsWith(idPrefix)) { - return; - } - - const index = Number(id.replace(`${idPrefix}-`, '')); - stickerSetIntersections[index] = entry.isIntersecting; - }); - - const intersectingWithIndexes = stickerSetIntersections - .map((isIntersecting, index) => ({ index, isIntersecting })) - .filter(({ isIntersecting }) => isIntersecting); - - if (!intersectingWithIndexes.length) { - return; - } - - setActiveSetIndex(intersectingWithIndexes[Math.floor(intersectingWithIndexes.length / 2)].index); - }); - const { observe: observeIntersectionForCovers } = useIntersectionObserver({ rootRef: headerRef }); + const { + observeIntersectionForSet, + observeIntersectionForPlayingItems, + observeIntersectionForShowingItems, + observeIntersectionForCovers, + } = useStickerPickerObservers(containerRef, headerRef, idPrefix, setActiveSetIndex, isHidden); const lang = useLang(); @@ -412,8 +392,10 @@ const CustomEmojiPicker: FC = ({ loadAndPlay={Boolean(canAnimate && loadAndPlay)} index={i} idPrefix={idPrefix} - observeIntersection={observeIntersection} - shouldRender={activeSetIndex >= i - 1 && activeSetIndex <= i + 1} + observeIntersection={observeIntersectionForSet} + observeIntersectionForPlayingItems={observeIntersectionForPlayingItems} + observeIntersectionForShowingItems={observeIntersectionForShowingItems} + isNearActive={activeSetIndex >= i - 1 && activeSetIndex <= i + 1} isSavedMessages={isSavedMessages} isStatusPicker={isStatusPicker} isReactionPicker={isReactionPicker} diff --git a/src/components/common/EmbeddedMessage.tsx b/src/components/common/EmbeddedMessage.tsx index a9fbc5717..93711efb8 100644 --- a/src/components/common/EmbeddedMessage.tsx +++ b/src/components/common/EmbeddedMessage.tsx @@ -1,5 +1,5 @@ import type { FC } from '../../lib/teact/teact'; -import React, { useRef } from '../../lib/teact/teact'; +import React, { useCallback, useRef } from '../../lib/teact/teact'; import type { ApiUser, ApiMessage, ApiChat, @@ -18,6 +18,7 @@ import { getPictogramDimensions } from './helpers/mediaDimensions'; import buildClassName from '../../util/buildClassName'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; +import { IS_TOUCH_ENV, MouseButton } from '../../util/windowEnvironment'; import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; import useMedia from '../../hooks/useMedia'; import useThumbnail from '../../hooks/useThumbnail'; @@ -71,6 +72,14 @@ const EmbeddedMessage: FC = ({ const senderTitle = sender ? getSenderTitle(lang, sender) : message?.forwardInfo?.hiddenUserName; + const handleClick = useCallback((e: React.MouseEvent) => { + if (e.type === 'mousedown' && e.button !== MouseButton.Main) { + return; + } + + onClick?.(); + }, [onClick]); + return (
= ({ className, sender && !noUserColors && `color-${getUserColorKey(sender)}`, )} - onClick={message ? onClick : undefined} + onClick={message && IS_TOUCH_ENV ? handleClick : undefined} + onMouseDown={message && !IS_TOUCH_ENV ? handleClick : undefined} > {mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl, isRoundVideo, isProtected, isSpoiler)}
diff --git a/src/components/common/PasswordForm.tsx b/src/components/common/PasswordForm.tsx index 9759fa786..272907320 100644 --- a/src/components/common/PasswordForm.tsx +++ b/src/components/common/PasswordForm.tsx @@ -3,6 +3,7 @@ import type { FC } from '../../lib/teact/teact'; import React, { memo, useEffect, useRef, useState, } from '../../lib/teact/teact'; +import { requestMutation } from '../../lib/fasterdom/fasterdom'; import { MIN_PASSWORD_LENGTH } from '../../config'; import { IS_TOUCH_ENV } from '../../util/windowEnvironment'; @@ -72,7 +73,7 @@ const PasswordForm: FC = ({ useEffect(() => { if (error) { - requestAnimationFrame(() => { + requestMutation(() => { inputRef.current!.focus(); inputRef.current!.select(); }); diff --git a/src/components/common/Picker.tsx b/src/components/common/Picker.tsx index 02508fb3c..58a8faae2 100644 --- a/src/components/common/Picker.tsx +++ b/src/components/common/Picker.tsx @@ -2,6 +2,7 @@ import type { FC } from '../../lib/teact/teact'; import React, { useCallback, useRef, useEffect, memo, } from '../../lib/teact/teact'; +import { requestMutation } from '../../lib/fasterdom/fasterdom'; import { isUserId } from '../../global/helpers'; @@ -58,7 +59,7 @@ const Picker: FC = ({ useEffect(() => { setTimeout(() => { - requestAnimationFrame(() => { + requestMutation(() => { inputRef.current!.focus(); }); }, FOCUS_DELAY_MS); diff --git a/src/components/common/StickerButton.tsx b/src/components/common/StickerButton.tsx index b83e0dd39..d033ae493 100644 --- a/src/components/common/StickerButton.tsx +++ b/src/components/common/StickerButton.tsx @@ -39,6 +39,7 @@ type OwnProps = { isCurrentUserPremium?: boolean; sharedCanvasRef?: React.RefObject; observeIntersection: ObserveFn; + observeIntersectionForShowing?: ObserveFn; noShowPremium?: boolean; onClick?: (arg: OwnProps['clickArg'], isSilent?: boolean, shouldSchedule?: boolean) => void; clickArg: T; @@ -69,6 +70,7 @@ const StickerButton = - + {isIntesectingForShowing && ( + + )} {!noShowPremium && isLocked && (
void; onReactionSelect?: (reaction: ApiReaction) => void; @@ -74,7 +76,7 @@ const StickerSet: FC = ({ loadAndPlay, index, idPrefix, - shouldRender, + isNearActive, favoriteStickers, availableReactions, isSavedMessages, @@ -86,6 +88,8 @@ const StickerSet: FC = ({ selectedReactionIds, withDefaultStatusIcon, observeIntersection, + observeIntersectionForPlayingItems, + observeIntersectionForShowingItems, onReactionSelect, onStickerSelect, onStickerUnfave, @@ -119,9 +123,11 @@ const StickerSet: FC = ({ const [itemsPerRow, setItemsPerRow] = useState(getItemsPerRowFallback(windowWidth)); - const isIntersecting = useIsIntersecting(ref, observeIntersection); + const isIntersecting = useIsIntersecting(ref, observeIntersection ?? observeIntersectionForShowingItems); + const transitionClassNames = useMediaTransition(isIntersecting); - const transitionClassNames = useMediaTransition(shouldRender); + // `isNearActive` is set in advance during animation, but it is not reliable for short sets + const shouldRender = isNearActive || isIntersecting; const stickerMarginPx = isMobile ? 8 : 16; const emojiMarginPx = isMobile ? 8 : 10; @@ -194,13 +200,13 @@ const StickerSet: FC = ({ }, [calculateItemsPerRow]); useResizeObserver(ref, handleResize); - useLayoutEffect(() => { + useEffect(() => { if (!ref.current) return; setItemsPerRow(calculateItemsPerRow(ref.current.clientWidth)); }, [calculateItemsPerRow]); useEffect(() => { - if (isIntersecting && !stickerSet.stickers?.length && !stickerSet.reactions?.length && stickerSet.accessHash) { + if (shouldRender && !stickerSet.stickers?.length && !stickerSet.reactions?.length && stickerSet.accessHash) { loadStickers({ stickerSetInfo: { id: stickerSet.id, @@ -208,7 +214,7 @@ const StickerSet: FC = ({ }, }); } - }, [isIntersecting, loadStickers, stickerSet]); + }, [shouldRender, loadStickers, stickerSet]); const isLocked = !isSavedMessages && !isRecent && isEmoji && !isCurrentUserPremium && stickerSet.stickers?.some(({ isFree }) => !isFree); @@ -299,7 +305,7 @@ const StickerSet: FC = ({ isSelected={isSelected} loadAndPlay={loadAndPlay} availableReactions={availableReactions} - observeIntersection={observeIntersection} + observeIntersection={observeIntersectionForPlayingItems} onClick={onReactionSelect!} sharedCanvasRef={sharedCanvasRef} sharedCanvasHqRef={sharedCanvasHqRef} @@ -321,7 +327,8 @@ const StickerSet: FC = ({ key={sticker.id} sticker={sticker} size={itemSize} - observeIntersection={observeIntersection} + observeIntersection={observeIntersectionForPlayingItems} + observeIntersectionForShowing={observeIntersectionForShowingItems} noAnimate={!loadAndPlay} isSavedMessages={isSavedMessages} isStatusPicker={isStatusPicker} diff --git a/src/components/common/hooks/useStickerPickerObservers.ts b/src/components/common/hooks/useStickerPickerObservers.ts new file mode 100644 index 000000000..f16744bfa --- /dev/null +++ b/src/components/common/hooks/useStickerPickerObservers.ts @@ -0,0 +1,88 @@ +import type { RefObject } from 'react'; + +import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; +import useSyncEffect from '../../../hooks/useSyncEffect'; +import { useRef } from '../../../lib/teact/teact'; +import { ANIMATION_END_DELAY } from '../../../config'; + +const STICKER_INTERSECTION_THROTTLE = 200; +const STICKER_INTERSECTION_MARGIN = 100; +const SLIDE_TRANSITION_DURATION = 350 + ANIMATION_END_DELAY; + +export function useStickerPickerObservers( + containerRef: RefObject, + headerRef: RefObject, + idPrefix: string, + setActiveSetIndex: (index: number) => void, + isHidden?: boolean, +) { + const stickerSetIntersectionsRef = useRef([]); + + const { + observe: observeIntersectionForSet, + freeze: freezeForSet, + unfreeze: unfreezeForSet, + } = useIntersectionObserver({ + rootRef: containerRef, + }, (entries) => { + const stickerSetIntersections = stickerSetIntersectionsRef.current; + + entries.forEach((entry) => { + const index = Number(entry.target.id.replace(`${idPrefix}-`, '')); + stickerSetIntersections[index] = entry.isIntersecting; + }); + + const minIntersectingIndex = stickerSetIntersections.reduce((lowestIndex, isIntersecting, index) => { + return isIntersecting && index < lowestIndex ? index : lowestIndex; + }, Infinity); + + if (minIntersectingIndex === Infinity) { + return; + } + + setActiveSetIndex(minIntersectingIndex); + }); + + const { + observe: observeIntersectionForShowingItems, + freeze: freezeForShowingItems, + unfreeze: unfreezeForShowingItems, + } = useIntersectionObserver({ + rootRef: containerRef, + throttleMs: STICKER_INTERSECTION_THROTTLE, + margin: STICKER_INTERSECTION_MARGIN, + }); + + const { + observe: observeIntersectionForPlayingItems, + } = useIntersectionObserver({ + rootRef: containerRef, + throttleMs: STICKER_INTERSECTION_THROTTLE, + margin: STICKER_INTERSECTION_MARGIN, + }); + + const { + observe: observeIntersectionForCovers, + } = useIntersectionObserver({ + rootRef: headerRef, + }); + + useSyncEffect(() => { + if (isHidden) { + freezeForSet(); + freezeForShowingItems(); + } else { + setTimeout(() => { + unfreezeForShowingItems(); + unfreezeForSet(); + }, SLIDE_TRANSITION_DURATION); + } + }, [freezeForSet, freezeForShowingItems, isHidden, unfreezeForSet, unfreezeForShowingItems]); + + return { + observeIntersectionForSet, + observeIntersectionForShowingItems, + observeIntersectionForPlayingItems, + observeIntersectionForCovers, + }; +} diff --git a/src/components/left/main/ForumPanel.tsx b/src/components/left/main/ForumPanel.tsx index 10f266b01..9c110bf79 100644 --- a/src/components/left/main/ForumPanel.tsx +++ b/src/components/left/main/ForumPanel.tsx @@ -2,6 +2,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; +import { requestNextMutation } from '../../../lib/fasterdom/fasterdom'; import type { FC } from '../../../lib/teact/teact'; import type { ApiChat } from '../../../api/types'; @@ -20,7 +21,6 @@ import { getOrderedTopics } from '../../../global/helpers'; import captureEscKeyListener from '../../../util/captureEscKeyListener'; import { waitForTransitionEnd } from '../../../util/cssAnimationEndListeners'; import { captureEvents, SwipeDirection } from '../../../util/captureEvents'; -import { fastRaf } from '../../../util/schedulers'; import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; import { useIntersectionObserver, useOnIntersect } from '../../../hooks/useIntersectionObserver'; @@ -147,13 +147,11 @@ const ForumPanel: FC = ({ useEffect(() => { if (prevIsVisible !== isVisible) { // For performance reasons, we delay animation of the topic list panel to the next animation frame - fastRaf(() => { + requestNextMutation(() => { if (!ref.current) return; const dispatchHeavyAnimationStop = dispatchHeavyAnimationEvent(); - waitForTransitionEnd(ref.current, () => { - dispatchHeavyAnimationStop(); - }); + waitForTransitionEnd(ref.current, dispatchHeavyAnimationStop); onOpenAnimationStart?.(); diff --git a/src/components/left/main/hooks/useChatListEntry.tsx b/src/components/left/main/hooks/useChatListEntry.tsx index ed9482c4e..79d4b24c5 100644 --- a/src/components/left/main/hooks/useChatListEntry.tsx +++ b/src/components/left/main/hooks/useChatListEntry.tsx @@ -1,4 +1,5 @@ import React, { useLayoutEffect, useMemo, useRef } from '../../../../lib/teact/teact'; +import { requestMutation } from '../../../../lib/fasterdom/fasterdom'; import { getGlobal } from '../../../../global'; import type { AnimationLevel } from '../../../../types'; @@ -24,7 +25,6 @@ import useLang from '../../../../hooks/useLang'; import useEnsureMessage from '../../../../hooks/useEnsureMessage'; import useMedia from '../../../../hooks/useMedia'; import { ChatAnimationTypes } from './useChatAnimationType'; -import { fastRaf } from '../../../../util/schedulers'; import MessageSummary from '../../../common/MessageSummary'; import ChatForumLastMessage from '../../../common/ChatForumLastMessage'; @@ -169,14 +169,14 @@ export default function useChatListEntry({ if (animationType === ChatAnimationTypes.Opacity) { element.style.opacity = '0'; - fastRaf(() => { + requestMutation(() => { element.classList.add('animate-opacity'); element.style.opacity = '1'; }); } else if (animationType === ChatAnimationTypes.Move) { element.style.transform = `translate3d(0, ${-orderDiff * CHAT_HEIGHT_PX}px, 0)`; - fastRaf(() => { + requestMutation(() => { element.classList.add('animate-transform'); element.style.transform = ''; }); @@ -185,7 +185,7 @@ export default function useChatListEntry({ } setTimeout(() => { - fastRaf(() => { + requestMutation(() => { element.classList.remove('animate-opacity', 'animate-transform'); element.style.opacity = ''; element.style.transform = ''; diff --git a/src/components/left/settings/folders/SettingsFoldersChatsPicker.tsx b/src/components/left/settings/folders/SettingsFoldersChatsPicker.tsx index fd1d6ac7a..fa6a03f01 100644 --- a/src/components/left/settings/folders/SettingsFoldersChatsPicker.tsx +++ b/src/components/left/settings/folders/SettingsFoldersChatsPicker.tsx @@ -2,6 +2,7 @@ import type { FC } from '../../../../lib/teact/teact'; import React, { useCallback, useRef, useEffect, memo, } from '../../../../lib/teact/teact'; +import { requestMutation } from '../../../../lib/fasterdom/fasterdom'; import { getActions, withGlobal } from '../../../../global'; import { isUserId } from '../../../../global/helpers'; @@ -66,7 +67,7 @@ const SettingsFoldersChatsPicker: FC = ({ useEffect(() => { setTimeout(() => { - requestAnimationFrame(() => { + requestMutation(() => { inputRef.current!.focus(); }); }, FOCUS_DELAY_MS); diff --git a/src/components/main/ConfettiContainer.tsx b/src/components/main/ConfettiContainer.tsx index 6beb54f26..197c1fc13 100644 --- a/src/components/main/ConfettiContainer.tsx +++ b/src/components/main/ConfettiContainer.tsx @@ -1,4 +1,5 @@ import React, { memo, useCallback, useRef } from '../../lib/teact/teact'; +import { requestMeasure } from '../../lib/fasterdom/fasterdom'; import { withGlobal } from '../../global'; import type { TabState } from '../../global/types'; @@ -162,7 +163,7 @@ const ConfettiContainer: FC = ({ confetti }) => { }); confettiRef.current = confettiRef.current.filter((c) => !confettiToRemove.includes(c)); if (confettiRef.current.length) { - requestAnimationFrame(updateCanvas); + requestMeasure(updateCanvas); } else { isRafStartedRef.current = false; } @@ -175,7 +176,7 @@ const ConfettiContainer: FC = ({ confetti }) => { hideTimeout = setTimeout(forceUpdate, CONFETTI_FADEOUT_TIMEOUT); if (!isRafStartedRef.current) { isRafStartedRef.current = true; - requestAnimationFrame(updateCanvas); + requestMeasure(updateCanvas); } } return () => { diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index b760d075f..8bbe7d8d0 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -3,6 +3,7 @@ import React, { useEffect, memo, useCallback, useState, useRef, useLayoutEffect, } from '../../lib/teact/teact'; import { addExtraClass } from '../../lib/teact/teact-dom'; +import { requestNextMutation } from '../../lib/fasterdom/fasterdom'; import { getActions, getGlobal, withGlobal } from '../../global'; import type { AnimationLevel, LangCode } from '../../types'; @@ -32,7 +33,6 @@ import buildClassName from '../../util/buildClassName'; import { waitForTransitionEnd } from '../../util/cssAnimationEndListeners'; import { processDeepLink } from '../../util/deeplink'; import { parseInitialLocationHash, parseLocationHash } from '../../util/routing'; -import { fastRaf } from '../../util/schedulers'; import { Bundles, loadBundle } from '../../util/moduleLoader'; import updateIcon from '../../util/updateIcon'; @@ -393,7 +393,7 @@ const Main: FC = ({ willAnimateLeftColumnRef.current = true; if (IS_ANDROID) { - fastRaf(() => { + requestNextMutation(() => { document.body.classList.toggle('android-left-blackout-open', !isLeftColumnOpen); }); } diff --git a/src/components/mediaViewer/MediaViewerSlides.tsx b/src/components/mediaViewer/MediaViewerSlides.tsx index a438797e3..adae19ef9 100644 --- a/src/components/mediaViewer/MediaViewerSlides.tsx +++ b/src/components/mediaViewer/MediaViewerSlides.tsx @@ -1,6 +1,6 @@ import type { FC } from '../../lib/teact/teact'; import React, { - memo, useCallback, useEffect, useRef, useState, + memo, useCallback, useEffect, useLayoutEffect, useRef, useState, } from '../../lib/teact/teact'; import type { AnimationLevel, MediaViewerOrigin } from '../../types'; @@ -22,12 +22,12 @@ import useSignal from '../../hooks/useSignal'; import useDerivedState from '../../hooks/useDerivedState'; import { useFullscreenStatus } from '../../hooks/useFullscreen'; import useZoomChange from './hooks/useZoomChangeSignal'; +import { useSignalRef } from '../../hooks/useSignalRef'; +import useControlsSignal from './hooks/useControlsSignal'; import MediaViewerContent from './MediaViewerContent'; import './MediaViewerSlides.scss'; -import { useSignalRef } from '../../hooks/useSignalRef'; -import useControlsSignal from './hooks/useControlsSignal'; const { easeOutCubic, easeOutQuart } = timingFunctions; @@ -143,7 +143,7 @@ const MediaViewerSlides: FC = ({ useTimeout(() => setControlsVisible(true), ANIMATION_DURATION); - useEffect(() => { + useLayoutEffect(() => { const { x, y, scale } = getTransform(); lockControls(scale !== 1); if (leftSlideRef.current) { @@ -251,7 +251,7 @@ const MediaViewerSlides: FC = ({ const calculateOffsetBoundaries = ( { x, y, scale }: Transform, offsetTop = 0, - ):[Transform, boolean, boolean] => { + ): [Transform, boolean, boolean] => { if (!initialContentRect) return [{ x, y, scale }, true, true]; // Get current content boundaries let inBoundsX = true; @@ -425,7 +425,7 @@ const MediaViewerSlides: FC = ({ content = activeSlideRef.current.querySelector('img, video'); if (!content) return; // Store initial content rect, without transformations - initialContentRect = content.getBoundingClientRect(); + initialContentRect = content!.getBoundingClientRect(); } }, onDrag: (event, captureEvent, { diff --git a/src/components/mediaViewer/helpers/ghostAnimation.ts b/src/components/mediaViewer/helpers/ghostAnimation.ts index 7e64644c0..1aac7dcec 100644 --- a/src/components/mediaViewer/helpers/ghostAnimation.ts +++ b/src/components/mediaViewer/helpers/ghostAnimation.ts @@ -1,5 +1,6 @@ -import type { ApiDimensions, ApiMessage } from '../../../api/types'; +import { requestMutation } from '../../../lib/fasterdom/fasterdom'; +import type { ApiDimensions, ApiMessage } from '../../../api/types'; import { MediaViewerOrigin } from '../../../types'; import { ANIMATION_END_DELAY, MESSAGE_CONTENT_SELECTOR } from '../../../config'; @@ -62,27 +63,26 @@ export function animateOpening( const fromScaleX = fromWidth / toWidth; const fromScaleY = fromHeight / toHeight; - const ghost = createGhost(bestImageData || fromImage); - applyStyles(ghost, { - top: `${toTop}px`, - left: `${toLeft}px`, - width: `${toWidth}px`, - height: `${toHeight}px`, - transform: `translate3d(${fromTranslateX}px, ${fromTranslateY}px, 0) scale(${fromScaleX}, ${fromScaleY})`, - }); - applyShape(ghost, origin); + requestMutation(() => { + const ghost = createGhost(bestImageData || fromImage); + applyStyles(ghost, { + top: `${toTop}px`, + left: `${toLeft}px`, + width: `${toWidth}px`, + height: `${toHeight}px`, + transform: `translate3d(${fromTranslateX}px, ${fromTranslateY}px, 0) scale(${fromScaleX}, ${fromScaleY})`, + }); + applyShape(ghost, origin); - document.body.classList.add('ghost-animating'); - - requestAnimationFrame(() => { document.body.appendChild(ghost); + document.body.classList.add('ghost-animating'); - requestAnimationFrame(() => { + requestMutation(() => { ghost.style.transform = ''; clearShape(ghost); setTimeout(() => { - requestAnimationFrame(() => { + requestMutation(() => { if (document.body.contains(ghost)) { document.body.removeChild(ghost); } @@ -146,43 +146,41 @@ export function animateClosing(origin: MediaViewerOrigin, bestImageData: string, } const existingGhost = document.getElementsByClassName('ghost')[0] as HTMLDivElement; - const ghost = existingGhost || createGhost(bestImageData || toImage, origin); - if (!existingGhost) { - applyStyles(ghost, { + + let styles: Record; + if (existingGhost) { + const { + top, left, width, height, + } = existingGhost.getBoundingClientRect(); + const scaleX = width / toWidth; + const scaleY = height / toHeight; + + styles = { + transition: 'none', + top: `${toTop}px`, + left: `${toLeft}px`, + transformOrigin: 'top left', + transform: `translate3d(${left - toLeft}px, ${top - toTop}px, 0) scale(${scaleX}, ${scaleY})`, + width: `${toWidth}px`, + height: `${toHeight}px`, + }; + } else { + styles = { top: `${toTop}px`, left: `${toLeft}px`, width: `${toWidth}px`, height: `${toHeight}px`, transform: `translate3d(${fromTranslateX}px, ${fromTranslateY}px, 0) scale(${fromScaleX}, ${fromScaleY})`, - }); + }; } - requestAnimationFrame(() => { - if (existingGhost) { - const { - top, - left, - width, - height, - } = existingGhost.getBoundingClientRect(); - const scaleX = width / toWidth; - const scaleY = height / toHeight; - - applyStyles(ghost, { - transition: 'none', - top: `${toTop}px`, - left: `${toLeft}px`, - transformOrigin: 'top left', - transform: `translate3d(${left - toLeft}px, ${top - toTop}px, 0) scale(${scaleX}, ${scaleY})`, - width: `${toWidth}px`, - height: `${toHeight}px`, - }); - } - document.body.classList.add('ghost-animating'); + requestMutation(() => { + applyStyles(ghost, styles); if (!existingGhost) document.body.appendChild(ghost); + document.body.classList.add('ghost-animating'); - requestAnimationFrame(() => { + requestMutation(() => { if (existingGhost) { existingGhost.style.transition = ''; } @@ -196,7 +194,7 @@ export function animateClosing(origin: MediaViewerOrigin, bestImageData: string, applyShape(ghost, origin); setTimeout(() => { - requestAnimationFrame(() => { + requestMutation(() => { if (document.body.contains(ghost)) { document.body.removeChild(ghost); } diff --git a/src/components/middle/HeaderActions.tsx b/src/components/middle/HeaderActions.tsx index 2bd12bb2d..0111a561d 100644 --- a/src/components/middle/HeaderActions.tsx +++ b/src/components/middle/HeaderActions.tsx @@ -5,6 +5,7 @@ import React, { useCallback, useState, } from '../../lib/teact/teact'; +import { requestMeasure, requestNextMutation } from '../../lib/fasterdom/fasterdom'; import { getActions, withGlobal } from '../../global'; import type { MessageListType } from '../../global/types'; @@ -154,9 +155,9 @@ const HeaderActions: FC = ({ const searchInput = document.querySelector('#MobileSearch input')!; searchInput.focus(); } else if (noAnimation) { - // The second RAF is necessary because teact must update the state and render the async component - requestAnimationFrame(() => { - requestAnimationFrame(setFocusInSearchInput); + // The second RAF is necessary because Teact must update the state and render the async component + requestMeasure(() => { + requestNextMutation(setFocusInSearchInput); }); } else { setTimeout(setFocusInSearchInput, SEARCH_FOCUS_DELAY_MS); diff --git a/src/components/middle/HeaderPinnedMessage.tsx b/src/components/middle/HeaderPinnedMessage.tsx index 32705b5dd..3141336cc 100644 --- a/src/components/middle/HeaderPinnedMessage.tsx +++ b/src/components/middle/HeaderPinnedMessage.tsx @@ -10,7 +10,7 @@ import { getMessageMediaHash, getMessageSingleInlineButton, } from '../../global/helpers'; import buildClassName from '../../util/buildClassName'; -import { IS_TOUCH_ENV } from '../../util/windowEnvironment'; +import { IS_TOUCH_ENV, MouseButton } from '../../util/windowEnvironment'; import renderText from '../common/helpers/renderText'; import useMedia from '../../hooks/useMedia'; @@ -39,7 +39,7 @@ type OwnProps = { customTitle?: string; className?: string; onUnpinMessage?: (id: number) => void; - onClick?: (e: React.MouseEvent) => void; + onClick?: (e: React.MouseEvent) => void; onAllPinnedClick?: () => void; isLoading?: boolean; isFullWidth?: boolean; @@ -78,6 +78,14 @@ const HeaderPinnedMessage: FC = ({ const [noHoverColor, markNoHoverColor, unmarkNoHoverColor] = useFlag(); + const handleClick = useCallback((e: React.MouseEvent) => { + if (e.type === 'mousedown' && e.button !== MouseButton.Main) { + return; + } + + onClick?.(e); + }, [onClick]); + function renderPictogram(thumbDataUri?: string, blobUrl?: string, spoiler?: boolean) { const { width, height } = getPictogramDimensions(); const srcUrl = blobUrl || thumbDataUri; @@ -140,7 +148,8 @@ const HeaderPinnedMessage: FC = ({ />
= ({ onPinnedIntersectionChange({ hasScrolled: true }); } - fastRaf(() => { - if (!container.parentElement) { - return; - } + if (!container.parentElement) { + return; + } - scrollOffsetRef.current = container.scrollHeight - container.scrollTop; + scrollOffsetRef.current = container.scrollHeight - container.scrollTop; - if (type === 'thread') { - setScrollOffset({ chatId, threadId, scrollOffset: scrollOffsetRef.current }); - } - }); + if (type === 'thread') { + setScrollOffset({ chatId, threadId, scrollOffset: scrollOffsetRef.current }); + } }); }, [ updateStickyDates, hasTools, getForceNextPinnedInHeader, onPinnedIntersectionChange, type, chatId, threadId, @@ -381,7 +385,7 @@ const MessageList: FC = ({ }); useSyncEffect( - () => rememberScrollPositionRef.current(), + () => forceMeasure(() => rememberScrollPositionRef.current()), // This will run before modifying content and should match deps for `useLayoutEffectWithPrevDeps` below [messageIds, isViewportNewest, hasTools, rememberScrollPositionRef], ); @@ -397,123 +401,133 @@ const MessageList: FC = ({ const prevContainerHeight = prevContainerHeightRef.current; prevContainerHeightRef.current = containerHeight; - const container = containerRef.current!; - listItemElementsRef.current = Array.from(container.querySelectorAll('.message-list-item')); + requestForcedReflow(() => { + const container = containerRef.current!; + listItemElementsRef.current = Array.from(container.querySelectorAll('.message-list-item')); - const hasLastMessageChanged = ( - messageIds && prevMessageIds && messageIds[messageIds.length - 1] !== prevMessageIds[prevMessageIds.length - 1] - ); - const hasViewportShifted = ( - messageIds?.[0] !== prevMessageIds?.[0] && messageIds?.length === (MESSAGE_LIST_SLICE / 2 + 1) - ); - const wasMessageAdded = hasLastMessageChanged && !hasViewportShifted; - const isAlreadyFocusing = messageIds && memoFocusingIdRef.current === messageIds[messageIds.length - 1]; - - // Add extra height when few messages to allow smooth scroll animation. Uses assumption that `parentElement` - // is a Transition slide and its CSS class can not be reset in a declarative way. - const shouldForceScroll = ( - isViewportNewest - && wasMessageAdded - && (messageIds && messageIds.length < MESSAGE_LIST_SLICE / 2) - && !container.parentElement!.classList.contains('force-messages-scroll') - && (container.firstElementChild as HTMLDivElement)!.clientHeight <= container.offsetHeight * 2 - ); - - if (shouldForceScroll) { - container.parentElement!.classList.add('force-messages-scroll'); - - setTimeout(() => { - if (container.parentElement) { - container.parentElement.classList.remove('force-messages-scroll'); - } - }, MESSAGE_ANIMATION_DURATION); - } - - const { scrollTop, scrollHeight, offsetHeight } = container; - const scrollOffset = scrollOffsetRef.current; - const lastItemElement = listItemElementsRef.current[listItemElementsRef.current.length - 1]; - - let bottomOffset = scrollOffset - (prevContainerHeight || offsetHeight); - if (wasMessageAdded) { - // If two new messages come at once (e.g. when bot responds) then the first message will update `scrollOffset` - // right away (before animation) which creates inconsistency until the animation completes. To work around that, - // we calculate `isAtBottom` with a "buffer" of the latest message height (this is approximate). - const lastItemHeight = lastItemElement ? lastItemElement.offsetHeight : 0; - bottomOffset -= lastItemHeight; - } - const isAtBottom = isViewportNewest && prevIsViewportNewest && bottomOffset <= BOTTOM_THRESHOLD; - - let newScrollTop!: number; - - if (wasMessageAdded && isAtBottom && !isAlreadyFocusing) { - if (lastItemElement) { - fastRaf(() => { - fastSmoothScroll( - container, - lastItemElement, - 'end', - BOTTOM_FOCUS_MARGIN, - ); - }); - } - - newScrollTop = scrollHeight - offsetHeight; - scrollOffsetRef.current = Math.max(Math.ceil(scrollHeight - newScrollTop), offsetHeight); - - // Scroll still needs to be restored after container resize - if (!shouldForceScroll) { - return; - } - } - - if (process.env.APP_ENV === 'perf') { - // eslint-disable-next-line no-console - console.time('scrollTop'); - } - - const isResized = prevContainerHeight && prevContainerHeight !== containerHeight; - const anchor = anchorIdRef.current && container.querySelector(`#${anchorIdRef.current}`); - const unreadDivider = ( - !anchor - && memoUnreadDividerBeforeIdRef.current - && container.querySelector(`.${UNREAD_DIVIDER_CLASS}`) - ); - - if (isAtBottom && isResized) { - if (isAnimatingScroll()) { - return; - } - - newScrollTop = scrollHeight - offsetHeight; - } else if (anchor) { - const newAnchorTop = anchor.getBoundingClientRect().top; - newScrollTop = scrollTop + (newAnchorTop - (anchorTopRef.current || 0)); - } else if (unreadDivider) { - newScrollTop = Math.min( - unreadDivider.offsetTop - (hasTools ? UNREAD_DIVIDER_TOP_WITH_TOOLS : UNREAD_DIVIDER_TOP), - scrollHeight - scrollOffset, + const hasLastMessageChanged = ( + messageIds && prevMessageIds && messageIds[messageIds.length - 1] !== prevMessageIds[prevMessageIds.length - 1] ); - } else { - newScrollTop = scrollHeight - scrollOffset; - } + const hasViewportShifted = ( + messageIds?.[0] !== prevMessageIds?.[0] && messageIds?.length === (MESSAGE_LIST_SLICE / 2 + 1) + ); + const wasMessageAdded = hasLastMessageChanged && !hasViewportShifted; + const isAlreadyFocusing = messageIds && memoFocusingIdRef.current === messageIds[messageIds.length - 1]; - resetScroll(container, Math.ceil(newScrollTop)); + // Add extra height when few messages to allow smooth scroll animation. Uses assumption that `parentElement` + // is a Transition slide and its CSS class can not be reset in a declarative way. + const shouldForceScroll = ( + isViewportNewest + && wasMessageAdded + && (messageIds && messageIds.length < MESSAGE_LIST_SLICE / 2) + && !container.parentElement!.classList.contains('force-messages-scroll') + && (container.firstElementChild as HTMLDivElement)!.clientHeight <= container.offsetHeight * 2 + ); - if (!memoFocusingIdRef.current) { - isScrollTopJustUpdatedRef.current = true; - fastRaf(() => { - isScrollTopJustUpdatedRef.current = false; - }); - } + const { + scrollTop, + scrollHeight, + offsetHeight, + } = container; + const scrollOffset = scrollOffsetRef.current; + const lastItemElement = listItemElementsRef.current[listItemElementsRef.current.length - 1]; - scrollOffsetRef.current = Math.max(Math.ceil(scrollHeight - newScrollTop), offsetHeight); + let bottomOffset = scrollOffset - (prevContainerHeight || offsetHeight); + if (wasMessageAdded) { + // If two new messages come at once (e.g. when bot responds) then the first message will update `scrollOffset` + // right away (before animation) which creates inconsistency until the animation completes. To work around that, + // we calculate `isAtBottom` with a "buffer" of the latest message height (this is approximate). + const lastItemHeight = lastItemElement ? lastItemElement.offsetHeight : 0; + bottomOffset -= lastItemHeight; + } + const isAtBottom = isViewportNewest && prevIsViewportNewest && bottomOffset <= BOTTOM_THRESHOLD; - if (process.env.APP_ENV === 'perf') { - // eslint-disable-next-line no-console - console.timeEnd('scrollTop'); - } + let newScrollTop!: number; + + if (wasMessageAdded && isAtBottom && !isAlreadyFocusing) { + if (lastItemElement) { + // Break out of `forceLayout` + requestMeasure(() => { + fastSmoothScroll( + container, + lastItemElement, + 'end', + BOTTOM_FOCUS_MARGIN, + ); + }); + } + + newScrollTop = scrollHeight - offsetHeight; + scrollOffsetRef.current = Math.max(Math.ceil(scrollHeight - newScrollTop), offsetHeight); + + // Scroll still needs to be restored after container resize + if (!shouldForceScroll) { + return undefined; + } + } + + if (process.env.APP_ENV === 'perf') { + // eslint-disable-next-line no-console + console.time('scrollTop'); + } + + const isResized = prevContainerHeight !== undefined && prevContainerHeight !== containerHeight; + if (isResized && isAnimatingScroll()) { + return undefined; + } + + const anchor = anchorIdRef.current && container.querySelector(`#${anchorIdRef.current}`); + const unreadDivider = ( + !anchor + && memoUnreadDividerBeforeIdRef.current + && container.querySelector(`.${UNREAD_DIVIDER_CLASS}`) + ); + + if (isAtBottom && isResized) { + newScrollTop = scrollHeight - offsetHeight; + } else if (anchor) { + const newAnchorTop = anchor.getBoundingClientRect().top; + newScrollTop = scrollTop + (newAnchorTop - (anchorTopRef.current || 0)); + } else if (unreadDivider) { + newScrollTop = Math.min( + unreadDivider.offsetTop - (hasTools ? UNREAD_DIVIDER_TOP_WITH_TOOLS : UNREAD_DIVIDER_TOP), + scrollHeight - scrollOffset, + ); + } else { + newScrollTop = scrollHeight - scrollOffset; + } + + return () => { + if (shouldForceScroll) { + container.parentElement!.classList.add('force-messages-scroll'); + + setTimeout(() => { + if (container.parentElement) { + container.parentElement.classList.remove('force-messages-scroll'); + } + }, MESSAGE_ANIMATION_DURATION); + } + + resetScroll(container, Math.ceil(newScrollTop)); + + if (!memoFocusingIdRef.current) { + isScrollTopJustUpdatedRef.current = true; + + requestMeasure(() => { + isScrollTopJustUpdatedRef.current = false; + }); + } + + scrollOffsetRef.current = Math.max(Math.ceil(scrollHeight - newScrollTop), offsetHeight); + + if (process.env.APP_ENV === 'perf') { + // eslint-disable-next-line no-console + console.timeEnd('scrollTop'); + } + }; + }); // This should match deps for `useSyncEffect` above - }, [messageIds, isViewportNewest, getContainerHeight, prevContainerHeightRef, hasTools]); + }, [messageIds, isViewportNewest, hasTools, getContainerHeight, prevContainerHeightRef]); useEffectWithPrevDeps(([prevIsSelectModeActive]) => { if (prevIsSelectModeActive !== undefined) { @@ -631,6 +645,7 @@ const MessageList: FC = ({ isChannelChat={isChannelChat} messageIds={messageIds || [lastMessage!.id]} messageGroups={messageGroups || groupMessages([lastMessage!])} + getContainerHeight={getContainerHeight} isViewportNewest={Boolean(isViewportNewest)} isUnread={Boolean(firstUnreadId)} withUsers={withUsers} diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index a2b88aae5..6561c338e 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -5,6 +5,7 @@ import { getActions } from '../../global'; import type { MessageListType } from '../../global/types'; import type { PinnedIntersectionChangedCallback } from './hooks/usePinnedMessage'; +import type { Signal } from '../../util/signals'; import { SCHEDULED_WHEN_ONLINE } from '../../config'; import { MAIN_THREAD_ID } from '../../api/types'; @@ -31,6 +32,7 @@ interface OwnProps { threadId: number; messageIds: number[]; messageGroups: MessageDateGroup[]; + getContainerHeight: Signal; isViewportNewest: boolean; isUnread: boolean; withUsers: boolean; @@ -60,6 +62,7 @@ const MessageListContent: FC = ({ threadId, messageIds, messageGroups, + getContainerHeight, isViewportNewest, isUnread, isComments, @@ -96,6 +99,7 @@ const MessageListContent: FC = ({ type, containerRef, messageIds, + getContainerHeight, isViewportNewest, isUnread, onFabToggle, diff --git a/src/components/middle/MiddleColumn.tsx b/src/components/middle/MiddleColumn.tsx index 11a5ba7c5..3ea0b2391 100644 --- a/src/components/middle/MiddleColumn.tsx +++ b/src/components/middle/MiddleColumn.tsx @@ -2,6 +2,7 @@ import type { FC } from '../../lib/teact/teact'; import React, { useEffect, useState, memo, useMemo, useCallback, } from '../../lib/teact/teact'; +import { requestMutation } from '../../lib/fasterdom/fasterdom'; import { getActions, withGlobal } from '../../global'; import type { ApiChat, ApiChatBannedRights } from '../../api/types'; @@ -27,7 +28,7 @@ import { GENERAL_TOPIC_ID, TMP_CHAT_ID, } from '../../config'; -import { MASK_IMAGE_DISABLED } from '../../util/windowEnvironment'; +import { IS_ANDROID, IS_IOS, MASK_IMAGE_DISABLED } from '../../util/windowEnvironment'; import { DropAreaState } from './composer/DropArea'; import { selectChat, @@ -277,17 +278,21 @@ const MiddleColumn: FC = ({ // Fix for mobile virtual keyboard useEffect(() => { + if (!IS_IOS && !IS_ANDROID) { + return undefined; + } + const { visualViewport } = window; if (!visualViewport) { return undefined; } const handleResize = () => { - if (visualViewport.height !== document.documentElement.clientHeight) { - document.body.classList.add('keyboard-visible'); - } else { - document.body.classList.remove('keyboard-visible'); - } + const isFixNeeded = visualViewport.height !== document.documentElement.clientHeight; + + requestMutation(() => { + document.body.classList.toggle('keyboard-visible', isFixNeeded); + }); }; visualViewport.addEventListener('resize', handleResize); diff --git a/src/components/middle/MiddleHeader.tsx b/src/components/middle/MiddleHeader.tsx index a785e9007..981937cb2 100644 --- a/src/components/middle/MiddleHeader.tsx +++ b/src/components/middle/MiddleHeader.tsx @@ -1,7 +1,8 @@ import type { FC } from '../../lib/teact/teact'; import React, { - memo, useCallback, useEffect, useRef, + memo, useCallback, useEffect, useLayoutEffect, useRef, } from '../../lib/teact/teact'; +import { requestMutation } from '../../lib/fasterdom/fasterdom'; import { getActions, withGlobal } from '../../global'; import type { GlobalState, MessageListType } from '../../global/types'; @@ -289,7 +290,7 @@ const MiddleHeader: FC = ({ || (shouldRenderAudioPlayer && renderingAudioMessage); // Logic for transition to and from custom display of AudioPlayer/PinnedMessage on smaller screens - useEffect(() => { + useLayoutEffect(() => { const componentEl = componentRef.current; if (!componentEl) { return; @@ -309,7 +310,9 @@ const MiddleHeader: FC = ({ // Remove animation class to prevent it messing up the show transitions setTimeout(() => { - componentEl.classList.remove('animated'); + requestMutation(() => { + componentEl.classList.remove('animated'); + }); }, ANIMATION_DURATION); } else { componentEl.classList.remove('tools-stacked'); diff --git a/src/components/middle/MobileSearch.tsx b/src/components/middle/MobileSearch.tsx index d76773326..88db4f1df 100644 --- a/src/components/middle/MobileSearch.tsx +++ b/src/components/middle/MobileSearch.tsx @@ -2,10 +2,12 @@ import type { FC } from '../../lib/teact/teact'; import React, { memo, useCallback, useEffect, useRef, useState, useLayoutEffect, } from '../../lib/teact/teact'; +import { requestMutation } from '../../lib/fasterdom/fasterdom'; import { getActions, withGlobal } from '../../global'; import type { ApiChat } from '../../api/types'; +import { IS_IOS } from '../../util/windowEnvironment'; import { debounce } from '../../util/schedulers'; import { selectCurrentTextSearch, @@ -69,12 +71,17 @@ const MobileSearchFooter: FC = ({ const { activeElement } = document; if (activeElement && (activeElement === inputRef.current)) { const { pageTop, height } = visualViewport; - mainEl.style.transform = `translateY(${pageTop}px)`; - mainEl.style.height = `${height}px`; - document.documentElement.scrollTop = pageTop; + + requestMutation(() => { + mainEl.style.transform = `translateY(${pageTop}px)`; + mainEl.style.height = `${height}px`; + document.documentElement.scrollTop = pageTop; + }); } else { - mainEl.style.transform = ''; - mainEl.style.height = ''; + requestMutation(() => { + mainEl.style.transform = ''; + mainEl.style.height = ''; + }); } }; @@ -96,14 +103,12 @@ const MobileSearchFooter: FC = ({ }, [chat?.id, focusMessage, foundIds, threadId]); // Disable native up/down buttons on iOS - useEffect(() => { + useLayoutEffect(() => { + if (!IS_IOS) return; + Array.from(document.querySelectorAll('input')).forEach((input) => { input.disabled = Boolean(isActive && input !== inputRef.current); }); - - Array.from(document.querySelectorAll('div[contenteditable]')).forEach((div) => { - div.contentEditable = isActive ? 'false' : 'true'; - }); }, [isActive]); // Blur on exit @@ -113,7 +118,7 @@ const MobileSearchFooter: FC = ({ } }, [isActive]); - useLayoutEffect(() => { + useEffect(() => { const searchInput = document.querySelector('#MobileSearch input')!; searchInput.blur(); }, [isHistoryCalendarOpen]); diff --git a/src/components/middle/PinnedMessageNavigation.tsx b/src/components/middle/PinnedMessageNavigation.tsx index b17567db2..fe066b471 100644 --- a/src/components/middle/PinnedMessageNavigation.tsx +++ b/src/components/middle/PinnedMessageNavigation.tsx @@ -1,7 +1,7 @@ import type { FC } from '../../lib/teact/teact'; import React, { useRef, - useEffect, + useLayoutEffect, useMemo, memo, } from '../../lib/teact/teact'; @@ -27,7 +27,7 @@ const PinnedMessageNavigation: FC = ({ return calculateMarkup(count, index); }, [count, index]); - useEffect(() => { + useLayoutEffect(() => { if (!containerRef.current) { return; } diff --git a/src/components/middle/composer/AttachmentModal.tsx b/src/components/middle/composer/AttachmentModal.tsx index 5fbac1c86..6c0fdc24f 100644 --- a/src/components/middle/composer/AttachmentModal.tsx +++ b/src/components/middle/composer/AttachmentModal.tsx @@ -1,6 +1,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState, } from '../../../lib/teact/teact'; +import { requestMutation } from '../../../lib/fasterdom/fasterdom'; import { getActions, withGlobal } from '../../../global'; import type { FC } from '../../../lib/teact/teact'; @@ -360,7 +361,10 @@ const AttachmentModal: FC = ({ if (!mainButton || !input) return; const { width } = mainButton.getBoundingClientRect(); - input.style.setProperty('--margin-for-scrollbar', `${width}px`); + + requestMutation(() => { + input.style.setProperty('--margin-for-scrollbar', `${width}px`); + }); }, [lang, isOpen]); const MoreMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { diff --git a/src/components/middle/composer/Composer.tsx b/src/components/middle/composer/Composer.tsx index 5e8883b89..872605746 100644 --- a/src/components/middle/composer/Composer.tsx +++ b/src/components/middle/composer/Composer.tsx @@ -2,6 +2,7 @@ import type { FC } from '../../../lib/teact/teact'; import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from '../../../lib/teact/teact'; +import { requestMeasure, requestNextMutation } from '../../../lib/fasterdom/fasterdom'; import { getActions, withGlobal } from '../../../global'; import type { @@ -532,7 +533,7 @@ const Composer: FC = ({ setHtml(`${getHtml()}${newHtml}`); // If selection is outside of input, set cursor at the end of input - requestAnimationFrame(() => { + requestNextMutation(() => { focusEditableElement(messageInput); }); }, [isComposerBlocked, getHtml, setHtml]); @@ -755,7 +756,7 @@ const Composer: FC = ({ clearDraft({ chatId, localOnly: true }); // Wait until message animation starts - requestAnimationFrame(() => { + requestMeasure(() => { resetComposer(); }); }, [ @@ -842,7 +843,7 @@ const Composer: FC = ({ } // Wait until message animation starts - requestAnimationFrame(() => { + requestMeasure(() => { resetComposer(); }); }, [ @@ -906,7 +907,8 @@ const Composer: FC = ({ if (requestedDraftText) { setHtml(requestedDraftText); resetOpenChatWithDraft(); - requestAnimationFrame(() => { + + requestNextMutation(() => { const messageInput = document.getElementById(EDITABLE_INPUT_ID)!; focusEditableElement(messageInput, true); }); @@ -939,13 +941,13 @@ const Composer: FC = ({ requestCalendar((scheduledAt) => { cancelForceShowSymbolMenu(); handleMessageSchedule({ gif, isSilent }, scheduledAt); - requestAnimationFrame(() => { + requestMeasure(() => { resetComposer(true); }); }); } else { sendMessage({ gif, isSilent }); - requestAnimationFrame(() => { + requestMeasure(() => { resetComposer(true); }); } @@ -971,13 +973,13 @@ const Composer: FC = ({ requestCalendar((scheduledAt) => { cancelForceShowSymbolMenu(); handleMessageSchedule({ sticker, isSilent }, scheduledAt); - requestAnimationFrame(() => { + requestMeasure(() => { resetComposer(shouldPreserveInput); }); }); } else { sendMessage({ sticker, isSilent, shouldUpdateStickerSetsOrder }); - requestAnimationFrame(() => { + requestMeasure(() => { resetComposer(shouldPreserveInput); }); } @@ -1015,7 +1017,7 @@ const Composer: FC = ({ } clearDraft({ chatId, localOnly: true }); - requestAnimationFrame(() => { + requestMeasure(() => { resetComposer(); }); }, [ @@ -1025,7 +1027,7 @@ const Composer: FC = ({ const handleBotCommandSelect = useCallback(() => { clearDraft({ chatId, localOnly: true }); - requestAnimationFrame(() => { + requestMeasure(() => { resetComposer(); }); }, [chatId, clearDraft, resetComposer]); @@ -1519,6 +1521,7 @@ const Composer: FC = ({ className={buildClassName(mainButtonState, !isReady && 'not-ready', activeVoiceRecording && 'recording')} disabled={areVoiceMessagesNotAllowed} allowDisabledClick + noFastClick ariaLabel={lang(sendButtonAriaLabel)} onClick={mainButtonHandler} onContextMenu={ diff --git a/src/components/middle/composer/EmojiPicker.tsx b/src/components/middle/composer/EmojiPicker.tsx index fabacaf86..7ec96067f 100644 --- a/src/components/middle/composer/EmojiPicker.tsx +++ b/src/components/middle/composer/EmojiPicker.tsx @@ -53,8 +53,7 @@ const ICONS_BY_CATEGORY: Record = { }; const OPEN_ANIMATION_DELAY = 200; -// Only a few categories are above this height. -const SMOOTH_SCROLL_DISTANCE = 800; +const SMOOTH_SCROLL_DISTANCE = 100; const FOCUS_MARGIN = 50; const HEADER_BUTTON_WIDTH = 42; // px. Includes margins const INTERSECTION_THROTTLE = 200; @@ -94,15 +93,15 @@ const EmojiPicker: FC = ({ categoryIntersections[index] = entry.isIntersecting; }); - const intersectingWithIndexes = categoryIntersections - .map((isIntersecting, index) => ({ index, isIntersecting })) - .filter(({ isIntersecting }) => isIntersecting); + const minIntersectingIndex = categoryIntersections.reduce((lowestIndex, isIntersecting, index) => { + return isIntersecting && index < lowestIndex ? index : lowestIndex; + }, Infinity); - if (!intersectingWithIndexes.length) { + if (minIntersectingIndex === Infinity) { return; } - setActiveCategoryIndex(intersectingWithIndexes[Math.floor(intersectingWithIndexes.length / 2)].index); + setActiveCategoryIndex(minIntersectingIndex); }); const canRenderContents = useAsyncRendering([], MENU_TRANSITION_DURATION); diff --git a/src/components/middle/composer/EmojiTooltip.tsx b/src/components/middle/composer/EmojiTooltip.tsx index a0146137a..e71b0a6cc 100644 --- a/src/components/middle/composer/EmojiTooltip.tsx +++ b/src/components/middle/composer/EmojiTooltip.tsx @@ -1,5 +1,5 @@ import React, { - memo, useCallback, useEffect, useRef, + memo, useCallback, useRef, } from '../../../lib/teact/teact'; import type { ApiSticker } from '../../../api/types'; @@ -10,6 +10,7 @@ import findInViewport from '../../../util/findInViewport'; import isFullyVisible from '../../../util/isFullyVisible'; import fastSmoothScrollHorizontal from '../../../util/fastSmoothScrollHorizontal'; +import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps'; import useShowTransition from '../../../hooks/useShowTransition'; import usePrevDuringAnimation from '../../../hooks/usePrevDuringAnimation'; import { useKeyboardNavigation } from './hooks/useKeyboardNavigation'; @@ -126,7 +127,11 @@ const EmojiTooltip: FC = ({ onClose, }); - useEffect(() => { + useEffectWithPrevDeps(([prevSelectedIndex]) => { + if (prevSelectedIndex === undefined || prevSelectedIndex === -1) { + return; + } + setItemVisible(selectedIndex, containerRef); }, [selectedIndex]); diff --git a/src/components/middle/composer/MessageInput.tsx b/src/components/middle/composer/MessageInput.tsx index f47a7a5a2..097b71956 100644 --- a/src/components/middle/composer/MessageInput.tsx +++ b/src/components/middle/composer/MessageInput.tsx @@ -3,6 +3,7 @@ import type { FC } from '../../../lib/teact/teact'; import React, { useEffect, useRef, memo, useState, useCallback, useLayoutEffect, } from '../../../lib/teact/teact'; +import { requestMutation, requestForcedReflow } from '../../../lib/fasterdom/fasterdom'; import { getActions, withGlobal } from '../../../global'; import type { IAnchorPosition, ISettings } from '../../../types'; @@ -159,36 +160,41 @@ const MessageInput: FC = ({ isActive, ); - const maxInputHeight = isMobile ? 256 : 416; + const maxInputHeight = isAttachmentModalInput ? MAX_ATTACHMENT_MODAL_INPUT_HEIGHT : (isMobile ? 256 : 416); const updateInputHeight = useCallback((willSend = false) => { - const scroller = inputRef.current!.closest(`.${SCROLLER_CLASS}`)!; - const clone = scrollerCloneRef.current!; - const currentHeight = Number(scroller.style.height.replace('px', '')); - const maxHeight = isAttachmentModalInput ? MAX_ATTACHMENT_MODAL_INPUT_HEIGHT : maxInputHeight; - const newHeight = Math.min(clone.scrollHeight, maxHeight); - if (newHeight === currentHeight) { - return; - } + requestForcedReflow(() => { + const scroller = inputRef.current!.closest(`.${SCROLLER_CLASS}`)!; + const currentHeight = Number(scroller.style.height.replace('px', '')); + const clone = scrollerCloneRef.current!; + const { scrollHeight } = clone; + const newHeight = Math.min(scrollHeight, maxInputHeight); - const transitionDuration = Math.round( - TRANSITION_DURATION_FACTOR * Math.log(Math.abs(newHeight - currentHeight)), - ); + if (newHeight === currentHeight) { + return undefined; + } - const exec = () => { - scroller.style.height = `${newHeight}px`; - scroller.style.transitionDuration = `${transitionDuration}ms`; - scroller.classList.toggle('overflown', clone.scrollHeight > maxHeight); - }; + const isOverflown = scrollHeight > maxInputHeight; - if (willSend) { - // Sync with sending animation - requestAnimationFrame(exec); - } else { - exec(); - } - }, [isAttachmentModalInput, maxInputHeight]); + function exec() { + const transitionDuration = Math.round( + TRANSITION_DURATION_FACTOR * Math.log(Math.abs(newHeight - currentHeight)), + ); + scroller.style.height = `${newHeight}px`; + scroller.style.transitionDuration = `${transitionDuration}ms`; + scroller.classList.toggle('overflown', isOverflown); + } - useEffect(() => { + if (willSend) { + // Delay to next frame to sync with sending animation + requestMutation(exec); + return undefined; + } else { + return exec; + } + }); + }, [maxInputHeight]); + + useLayoutEffect(() => { if (!isAttachmentModalInput) return; updateInputHeight(false); }, [isAttachmentModalInput, updateInputHeight]); @@ -481,7 +487,7 @@ const MessageInput: FC = ({ const captureFirstTab = debounce((e: KeyboardEvent) => { if (e.key === 'Tab' && !getIsDirectTextInputDisabled()) { e.preventDefault(); - requestAnimationFrame(focusInput); + requestMutation(focusInput); } }, TAB_INDEX_PRIORITY_TIMEOUT, true, false); diff --git a/src/components/middle/composer/PollModal.tsx b/src/components/middle/composer/PollModal.tsx index ee0530f94..c7efdd594 100644 --- a/src/components/middle/composer/PollModal.tsx +++ b/src/components/middle/composer/PollModal.tsx @@ -3,6 +3,7 @@ import type { FC } from '../../../lib/teact/teact'; import React, { memo, useCallback, useEffect, useLayoutEffect, useRef, useState, } from '../../../lib/teact/teact'; +import { requestNextMutation } from '../../../lib/fasterdom/fasterdom'; import type { ApiNewPoll } from '../../../api/types'; @@ -85,7 +86,8 @@ const PollModal: FC = ({ const addNewOption = useCallback((newOptions: string[] = []) => { setOptions([...newOptions, '']); - requestAnimationFrame(() => { + + requestNextMutation(() => { const list = optionsListRef.current; if (!list) { return; @@ -189,7 +191,7 @@ const PollModal: FC = ({ } } - requestAnimationFrame(() => { + requestNextMutation(() => { if (!optionsListRef.current) { return; } diff --git a/src/components/middle/composer/StickerPicker.tsx b/src/components/middle/composer/StickerPicker.tsx index ca9bff2e2..d07483402 100644 --- a/src/components/middle/composer/StickerPicker.tsx +++ b/src/components/middle/composer/StickerPicker.tsx @@ -24,10 +24,10 @@ import { pickTruthy, uniqueByField } from '../../../util/iteratees'; import { selectChat, selectIsChatWithSelf, selectIsCurrentUserPremium } from '../../../global/selectors'; import useAsyncRendering from '../../right/hooks/useAsyncRendering'; -import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; import useHorizontalScroll from '../../../hooks/useHorizontalScroll'; import useLang from '../../../hooks/useLang'; import useSendMessageAction from '../../../hooks/useSendMessageAction'; +import { useStickerPickerObservers } from '../../common/hooks/useStickerPickerObservers'; import Avatar from '../../common/Avatar'; import Loading from '../../ui/Loading'; @@ -43,6 +43,7 @@ type OwnProps = { chatId: string; threadId?: number; className: string; + isHidden?: boolean; loadAndPlay: boolean; canSendStickers?: boolean; onStickerSelect: ( @@ -62,16 +63,14 @@ type StateProps = { isCurrentUserPremium?: boolean; }; -const SMOOTH_SCROLL_DISTANCE = 500; +const SMOOTH_SCROLL_DISTANCE = 100; const HEADER_BUTTON_WIDTH = 52; // px (including margin) -const STICKER_INTERSECTION_THROTTLE = 200; - -const stickerSetIntersections: boolean[] = []; const StickerPicker: FC = ({ chat, threadId, className, + isHidden, loadAndPlay, canSendStickers, recentStickers, @@ -103,31 +102,12 @@ const StickerPicker: FC = ({ const sendMessageAction = useSendMessageAction(chat!.id, threadId); - const { observe: observeIntersection } = useIntersectionObserver({ - rootRef: containerRef, - throttleMs: STICKER_INTERSECTION_THROTTLE, - }, (entries) => { - entries.forEach((entry) => { - const { id } = entry.target as HTMLDivElement; - if (!id || !id.startsWith('sticker-set-')) { - return; - } - - const index = Number(id.replace('sticker-set-', '')); - stickerSetIntersections[index] = entry.isIntersecting; - }); - - const intersectingWithIndexes = stickerSetIntersections - .map((isIntersecting, index) => ({ index, isIntersecting })) - .filter(({ isIntersecting }) => isIntersecting); - - if (!intersectingWithIndexes.length) { - return; - } - - setActiveSetIndex(intersectingWithIndexes[Math.floor(intersectingWithIndexes.length / 2)].index); - }); - const { observe: observeIntersectionForCovers } = useIntersectionObserver({ rootRef: headerRef }); + const { + observeIntersectionForSet, + observeIntersectionForPlayingItems, + observeIntersectionForShowingItems, + observeIntersectionForCovers, + } = useStickerPickerObservers(containerRef, headerRef, 'sticker-set', setActiveSetIndex, isHidden); const lang = useLang(); @@ -366,8 +346,10 @@ const StickerPicker: FC = ({ stickerSet={stickerSet} loadAndPlay={Boolean(canAnimate && loadAndPlay)} index={i} - observeIntersection={observeIntersection} - shouldRender={activeSetIndex >= i - 1 && activeSetIndex <= i + 1} + observeIntersection={observeIntersectionForSet} + observeIntersectionForPlayingItems={observeIntersectionForPlayingItems} + observeIntersectionForShowingItems={observeIntersectionForShowingItems} + isNearActive={activeSetIndex >= i - 1 && activeSetIndex <= i + 1} favoriteStickers={favoriteStickers} isSavedMessages={isSavedMessages} isCurrentUserPremium={isCurrentUserPremium} diff --git a/src/components/middle/composer/SymbolMenu.scss b/src/components/middle/composer/SymbolMenu.scss index 739078857..7fcb6e707 100644 --- a/src/components/middle/composer/SymbolMenu.scss +++ b/src/components/middle/composer/SymbolMenu.scss @@ -24,11 +24,7 @@ padding-right: env(safe-area-inset-right); padding-bottom: env(safe-area-inset-bottom); padding-left: env(safe-area-inset-left); - transform: translate3d( - 0, - calc(var(--symbol-menu-height) + var(--symbol-menu-footer-height) + env(safe-area-inset-bottom)), - 0 - ); + transform: translate3d(0, calc(var(--symbol-menu-height) + var(--symbol-menu-footer-height) + env(safe-area-inset-bottom)), 0); &.open:not(.in-attachment-modal) { transform: translate3d(0, 0, 0); @@ -40,11 +36,7 @@ &.open.in-attachment-modal { z-index: calc(var(--z-modal) + 1); - transform: translate3d( - 0, - calc(var(--symbol-menu-height) + var(--symbol-menu-footer-height)), - 0 - ); + transform: translate3d(0, calc(var(--symbol-menu-height) + var(--symbol-menu-footer-height)), 0); } // Target: Old Firefox (Waterfox Classic) @@ -157,6 +149,12 @@ padding: 0; overflow: hidden; + &:not(.open) { + transform: scale(0.85) !important; + } + + transition-duration: 200ms !important; + @supports (overflow: overlay) { width: var(--symbol-menu-width); } @@ -244,6 +242,7 @@ &:focus { background-color: var(--color-interactive-element-hover); } + @media (hover: hover) { &:hover { background-color: var(--color-interactive-element-hover); diff --git a/src/components/middle/composer/SymbolMenu.tsx b/src/components/middle/composer/SymbolMenu.tsx index 3f45a940b..2c0cf217e 100644 --- a/src/components/middle/composer/SymbolMenu.tsx +++ b/src/components/middle/composer/SymbolMenu.tsx @@ -1,6 +1,7 @@ import React, { memo, useCallback, useEffect, useLayoutEffect, useRef, useState, } from '../../../lib/teact/teact'; +import { requestMutation } from '../../../lib/fasterdom/fasterdom'; import { getActions, withGlobal } from '../../../global'; import type { FC } from '../../../lib/teact/teact'; @@ -8,7 +9,6 @@ import type { ApiSticker, ApiVideo } from '../../../api/types'; import type { GlobalActions } from '../../../global'; import { IS_TOUCH_ENV } from '../../../util/windowEnvironment'; -import { fastRaf } from '../../../util/schedulers'; import buildClassName from '../../../util/buildClassName'; import { selectTabState, selectIsCurrentUserPremium } from '../../../global/selectors'; @@ -47,7 +47,7 @@ export type OwnProps = { isSilent?: boolean, shouldSchedule?: boolean, shouldPreserveInput?: boolean, - shouldUpdateStickerSetsOrder?: boolean + shouldUpdateStickerSetsOrder?: boolean, ) => void; onGifSelect?: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void; onRemoveSymbol: () => void; @@ -130,24 +130,21 @@ const SymbolMenu: FC = ({ }, [isCurrentUserPremium, lastSyncTime, loadPremiumSetStickers]); useLayoutEffect(() => { - if (!isMobile || isAttachmentModal) { + if (!isMobile || !isOpen || isAttachmentModal) { return undefined; } - if (isOpen) { - document.body.classList.add('enable-symbol-menu-transforms'); - document.body.classList.add('is-symbol-menu-open'); - } + document.body.classList.add('enable-symbol-menu-transforms'); + document.body.classList.add('is-symbol-menu-open'); return () => { - if (isOpen) { - fastRaf(() => { - document.body.classList.remove('is-symbol-menu-open'); - setTimeout(() => { - document.body.classList.remove('enable-symbol-menu-transforms'); - }, ANIMATION_DURATION); + document.body.classList.remove('is-symbol-menu-open'); + + setTimeout(() => { + requestMutation(() => { + document.body.classList.remove('enable-symbol-menu-transforms'); }); - } + }, ANIMATION_DURATION); }; }, [isAttachmentModal, isMobile, isOpen]); @@ -219,6 +216,7 @@ const SymbolMenu: FC = ({ return ( = ({ return ( { + requestNextMutation(() => { focusEditableElement(inputEl, true, true); }); }, [getLastEmoji, isEnabled, inputRef, setHtml]); diff --git a/src/components/middle/composer/hooks/useDraft.ts b/src/components/middle/composer/hooks/useDraft.ts index a9afede11..530dccba6 100644 --- a/src/components/middle/composer/hooks/useDraft.ts +++ b/src/components/middle/composer/hooks/useDraft.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect } from '../../../../lib/teact/teact'; +import { requestMeasure, requestNextMutation } from '../../../../lib/fasterdom/fasterdom'; import { getActions } from '../../../../global'; import type { ApiDraft } from '../../../../global/types'; @@ -21,7 +22,8 @@ let isFrozen = false; function freeze() { isFrozen = true; - requestAnimationFrame(() => { + + requestMeasure(() => { isFrozen = false; }); } @@ -91,7 +93,7 @@ const useDraft = ( if (customEmojiIds.length) loadCustomEmojis({ ids: customEmojiIds }); if (!IS_TOUCH_ENV) { - requestAnimationFrame(() => { + requestNextMutation(() => { const messageInput = document.querySelector(EDITABLE_INPUT_CSS_SELECTOR); if (messageInput) { focusEditableElement(messageInput, true); diff --git a/src/components/middle/composer/hooks/useEditing.ts b/src/components/middle/composer/hooks/useEditing.ts index d5593d261..0524e00a7 100644 --- a/src/components/middle/composer/hooks/useEditing.ts +++ b/src/components/middle/composer/hooks/useEditing.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from '../../../../lib/teact/teact'; import { getActions } from '../../../../global'; +import { requestMeasure, requestNextMutation } from '../../../../lib/fasterdom/fasterdom'; import type { ApiFormattedText, ApiMessage } from '../../../../api/types'; import type { MessageListType } from '../../../../global/types'; @@ -12,7 +13,6 @@ import parseMessageInput from '../../../../util/parseMessageInput'; import focusEditableElement from '../../../../util/focusEditableElement'; import { hasMessageMedia } from '../../../../global/helpers'; import { getTextWithEntitiesAsHtml } from '../../../common/helpers/renderTextWithEntities'; -import { fastRaf } from '../../../../util/schedulers'; import useBackgroundMode from '../../../../hooks/useBackgroundMode'; import useBeforeUnload from '../../../../hooks/useBeforeUnload'; import { useDebouncedResolver } from '../../../../hooks/useAsyncResolvers'; @@ -57,8 +57,8 @@ const useEditing = ( setHtml(html); setShouldForceShowEditing(true); - // `fastRaf` would execute syncronously in this case - requestAnimationFrame(() => { + + requestNextMutation(() => { const messageInput = document.querySelector(EDITABLE_INPUT_CSS_SELECTOR); if (messageInput) { focusEditableElement(messageInput, true); @@ -72,7 +72,7 @@ const useEditing = ( } const shouldSetNoWebPage = !('webPage' in editedMessage.content) - && editedMessage.content.text?.entities?.some((entity) => URL_ENTITIES.has(entity.type)); + && editedMessage.content.text?.entities?.some((entity) => URL_ENTITIES.has(entity.type)); toggleMessageWebPage({ chatId, @@ -120,15 +120,18 @@ const useEditing = ( const restoreNewDraftAfterEditing = useCallback(() => { if (!draft) return; - // Run 1 frame after editing draft reset - fastRaf(() => { + + // Run one frame after editing draft reset + requestMeasure(() => { setHtml(getTextWithEntitiesAsHtml(draft)); - const messageInput = document.querySelector(EDITABLE_INPUT_CSS_SELECTOR); - if (messageInput) { - requestAnimationFrame(() => { + + // Wait one more frame until new HTML is rendered + requestNextMutation(() => { + const messageInput = document.querySelector(EDITABLE_INPUT_CSS_SELECTOR); + if (messageInput) { focusEditableElement(messageInput, true); - }); - } + } + }); }); }, [draft, setHtml]); diff --git a/src/components/middle/composer/hooks/useEmojiTooltip.ts b/src/components/middle/composer/hooks/useEmojiTooltip.ts index 39007d26f..1fd6802f6 100644 --- a/src/components/middle/composer/hooks/useEmojiTooltip.ts +++ b/src/components/middle/composer/hooks/useEmojiTooltip.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from '../../../../lib/teact/teact'; +import { requestNextMutation } from '../../../../lib/fasterdom/fasterdom'; import { getGlobal } from '../../../../global'; import type { ApiSticker } from '../../../../api/types'; @@ -123,7 +124,7 @@ export default function useEmojiTooltip( ? document.querySelector(EDITABLE_INPUT_CSS_SELECTOR)! : document.getElementById(inputId) as HTMLDivElement; - requestAnimationFrame(() => { + requestNextMutation(() => { focusEditableElement(messageInput, true, true); }); } diff --git a/src/components/middle/composer/hooks/useInputCustomEmojis.ts b/src/components/middle/composer/hooks/useInputCustomEmojis.ts index 51cb10b13..6ff895aa5 100644 --- a/src/components/middle/composer/hooks/useInputCustomEmojis.ts +++ b/src/components/middle/composer/hooks/useInputCustomEmojis.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, } from '../../../../lib/teact/teact'; import RLottie from '../../../../lib/rlottie/RLottie'; +import { requestMeasure } from '../../../../lib/fasterdom/fasterdom'; import type { ApiSticker } from '../../../../api/types'; import type { Signal } from '../../../../util/signals'; @@ -14,7 +15,6 @@ import { removeCustomEmojiInputRenderCallback, } from '../../../../util/customEmojiManager'; import { round } from '../../../../util/math'; -import { fastRaf } from '../../../../util/schedulers'; import AbsoluteVideo from '../../../../util/AbsoluteVideo'; import { REM } from '../../../common/helpers/mediaDimensions'; @@ -131,7 +131,7 @@ export default function useInputCustomEmojis( } // Wait one frame for DOM to update - fastRaf(() => { + requestMeasure(() => { synchronizeElements(); }); }, [getHtml, synchronizeElements, inputRef, clearPlayers, sharedCanvasRef, isActive]); @@ -163,7 +163,7 @@ export default function useInputCustomEmojis( }, []); const unfreezeAnimationOnRaf = useCallback(() => { - fastRaf(unfreezeAnimation); + requestMeasure(unfreezeAnimation); }, [unfreezeAnimation]); // Pausing frame may not happen in background, diff --git a/src/components/middle/composer/hooks/useMentionTooltip.ts b/src/components/middle/composer/hooks/useMentionTooltip.ts index 7a567fb71..5202b4fff 100644 --- a/src/components/middle/composer/hooks/useMentionTooltip.ts +++ b/src/components/middle/composer/hooks/useMentionTooltip.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState, } from '../../../../lib/teact/teact'; import { getGlobal } from '../../../../global'; +import { requestNextMutation } from '../../../../lib/fasterdom/fasterdom'; import type { ApiChatMember, ApiUser } from '../../../../api/types'; import type { Signal } from '../../../../util/signals'; @@ -120,7 +121,7 @@ export default function useMentionTooltip( const caretPosition = getCaretPosition(inputEl); setHtml(`${newHtml}${htmlAfterSelection}`); - requestAnimationFrame(() => { + requestNextMutation(() => { const newCaretPosition = caretPosition + shiftCaretPosition + 1; focusEditableElement(inputEl, forceFocus); if (newCaretPosition >= 0) { diff --git a/src/components/middle/composer/hooks/useVoiceRecording.ts b/src/components/middle/composer/hooks/useVoiceRecording.ts index d979411bc..d97a59e7d 100644 --- a/src/components/middle/composer/hooks/useVoiceRecording.ts +++ b/src/components/middle/composer/hooks/useVoiceRecording.ts @@ -1,12 +1,15 @@ import { useCallback, useEffect, useRef, useState, } from '../../../../lib/teact/teact'; +import { requestMutation } from '../../../../lib/fasterdom/fasterdom'; import { IS_SAFARI, IS_VOICE_RECORDING_SUPPORTED } from '../../../../util/windowEnvironment'; import * as voiceRecording from '../../../../util/voiceRecording'; import captureEscKeyListener from '../../../../util/captureEscKeyListener'; -type ActiveVoiceRecording = { stop: () => Promise; pause: NoneToVoidFunction } | undefined; +type ActiveVoiceRecording = + { stop: () => Promise; pause: NoneToVoidFunction } + | undefined; const useVoiceRecording = () => { // eslint-disable-next-line no-null/no-null @@ -27,7 +30,9 @@ const useVoiceRecording = () => { const { stop, pause } = await voiceRecording.start((tickVolume: number) => { if (recordButtonRef.current) { if (startRecordTimeRef.current && Date.now() % 4 === 0) { - recordButtonRef.current.style.boxShadow = `0 0 0 ${(tickVolume || 0) * 50}px rgba(0,0,0,.15)`; + requestMutation(() => { + recordButtonRef.current!.style.boxShadow = `0 0 0 ${(tickVolume || 0) * 50}px rgba(0,0,0,.15)`; + }); } setCurrentRecordTime(Date.now()); } @@ -47,9 +52,12 @@ const useVoiceRecording = () => { return undefined; } - if (recordButtonRef.current) { - recordButtonRef.current.style.boxShadow = 'none'; - } + requestMutation(() => { + if (recordButtonRef.current) { + recordButtonRef.current!.style.boxShadow = 'none'; + } + }); + try { return activeVoiceRecording!.pause(); } catch (err) { @@ -67,9 +75,13 @@ const useVoiceRecording = () => { setActiveVoiceRecording(undefined); startRecordTimeRef.current = undefined; setCurrentRecordTime(undefined); - if (recordButtonRef.current) { - recordButtonRef.current.style.boxShadow = 'none'; - } + + requestMutation(() => { + if (recordButtonRef.current) { + recordButtonRef.current!.style.boxShadow = 'none'; + } + }); + try { return activeVoiceRecording!.stop(); } catch (err) { diff --git a/src/components/middle/hooks/useAuthorWidth.ts b/src/components/middle/hooks/useAuthorWidth.ts new file mode 100644 index 000000000..fa995287f --- /dev/null +++ b/src/components/middle/hooks/useAuthorWidth.ts @@ -0,0 +1,21 @@ +import type { RefObject } from 'react'; +import { useLayoutEffect } from '../../../lib/teact/teact'; +import { requestForcedReflow } from '../../../lib/fasterdom/fasterdom'; + +export default function useAuthorWidth( + containerRef: RefObject, + signature?: string, +) { + useLayoutEffect(() => { + if (!signature) return; + + requestForcedReflow(() => { + const width = containerRef.current!.querySelector('.message-signature')?.offsetWidth; + if (!width) return undefined; + + return () => { + containerRef.current!.style.setProperty('--meta-safe-author-width', `${width}px`); + }; + }); + }, [containerRef, signature]); +} diff --git a/src/components/middle/hooks/useScrollHooks.ts b/src/components/middle/hooks/useScrollHooks.ts index 949808788..c9783528d 100644 --- a/src/components/middle/hooks/useScrollHooks.ts +++ b/src/components/middle/hooks/useScrollHooks.ts @@ -1,24 +1,33 @@ import type { RefObject } from 'react'; +import { + useCallback, useEffect, useMemo, useRef, +} from '../../../lib/teact/teact'; +import { requestMeasure } from '../../../lib/fasterdom/fasterdom'; import { getActions } from '../../../global'; -import { useMemo, useRef } from '../../../lib/teact/teact'; import { LoadMoreDirection } from '../../../types'; import type { MessageListType } from '../../../global/types'; +import type { Signal } from '../../../util/signals'; import { LOCAL_MESSAGE_MIN_ID } from '../../../config'; import { MESSAGE_LIST_SENSITIVE_AREA } from '../../../util/windowEnvironment'; import { debounce } from '../../../util/schedulers'; import { useIntersectionObserver, useOnIntersect } from '../../../hooks/useIntersectionObserver'; import useSyncEffect from '../../../hooks/useSyncEffect'; +import { useStateRef } from '../../../hooks/useStateRef'; +import { useSignalEffect } from '../../../hooks/useSignalEffect'; +import { useDebouncedSignal } from '../../../hooks/useAsyncResolvers'; const FAB_THRESHOLD = 50; const NOTCH_THRESHOLD = 1; // Notch has zero height so we at least need a 1px margin to intersect +const CONTAINER_HEIGHT_DEBOUNCE = 100; const TOOLS_FREEZE_TIMEOUT = 250; // Approximate message sending animation duration export default function useScrollHooks( type: MessageListType, containerRef: RefObject, messageIds: number[], + getContainerHeight: Signal, isViewportNewest: boolean, isUnread: boolean, onFabToggle: AnyToVoidFunction, @@ -58,11 +67,12 @@ export default function useScrollHooks( return; } - if (!containerRef.current) { + const container = containerRef.current; + if (!container) { return; } - const { offsetHeight, scrollHeight, scrollTop } = containerRef.current; + const { offsetHeight, scrollHeight, scrollTop } = container; const scrollBottom = Math.round(scrollHeight - scrollTop - offsetHeight); const isNearBottom = scrollBottom <= FAB_THRESHOLD; const isAtBottom = scrollBottom <= NOTCH_THRESHOLD; @@ -113,6 +123,7 @@ export default function useScrollHooks( } = useIntersectionObserver({ rootRef: containerRef, margin: FAB_THRESHOLD * 2, + throttleScheduler: requestMeasure, }, toggleScrollTools); useOnIntersect(fabTriggerRef, observeIntersectionForFab); @@ -124,20 +135,19 @@ export default function useScrollHooks( } = useIntersectionObserver({ rootRef: containerRef, margin: NOTCH_THRESHOLD, + throttleScheduler: requestMeasure, }, toggleScrollTools); useOnIntersect(fabTriggerRef, observeIntersectionForNotch); - const toggleScrollToolsRef = useRef(); - toggleScrollToolsRef.current = toggleScrollTools; - useSyncEffect(() => { + const toggleScrollToolsRef = useStateRef(toggleScrollTools); + useEffect(() => { if (isReady) { toggleScrollToolsRef.current!(); } - }, [isReady]); + }, [isReady, toggleScrollToolsRef]); - // Workaround for FAB and notch flickering with tall incoming message - useSyncEffect(() => { + const freezeShortly = useCallback(() => { freezeForFab(); freezeForNotch(); @@ -145,7 +155,14 @@ export default function useScrollHooks( unfreezeForNotch(); unfreezeForFab(); }, TOOLS_FREEZE_TIMEOUT); - }, [freezeForFab, freezeForNotch, messageIds, unfreezeForFab, unfreezeForNotch]); + }, [freezeForFab, freezeForNotch, unfreezeForFab, unfreezeForNotch]); + + // Workaround for FAB and notch flickering with tall incoming message + useSyncEffect(freezeShortly, [freezeShortly, messageIds]); + + // Workaround for notch flickering when opening Composer Embedded Message + const getContainerHeightDebounced = useDebouncedSignal(getContainerHeight, CONTAINER_HEIGHT_DEBOUNCE); + useSignalEffect(freezeShortly, [freezeShortly, getContainerHeightDebounced]); return { backwardsTriggerRef, forwardsTriggerRef, fabTriggerRef }; } diff --git a/src/components/middle/hooks/useStickyDates.ts b/src/components/middle/hooks/useStickyDates.ts index 93ecd9b70..3549d2176 100644 --- a/src/components/middle/hooks/useStickyDates.ts +++ b/src/components/middle/hooks/useStickyDates.ts @@ -1,6 +1,6 @@ import { useCallback } from '../../../lib/teact/teact'; +import { requestMutation } from '../../../lib/fasterdom/fasterdom'; -import { fastRaf } from '../../../util/schedulers'; import useRunDebounced from '../../../hooks/useRunDebounced'; import useFlag from '../../../hooks/useFlag'; @@ -19,23 +19,25 @@ export default function useStickyDates() { markIsScrolled(); if (!document.body.classList.contains('is-scrolling-messages')) { - fastRaf(() => { + requestMutation(() => { document.body.classList.add('is-scrolling-messages'); }); } runDebounced(() => { - fastRaf(() => { + const stuckDateEl = findStuckDate(container, hasTools); + if (stuckDateEl) { + requestMutation(() => { + stuckDateEl.classList.add('stuck'); + }); + } + + requestMutation(() => { const currentStuck = document.querySelector('.stuck'); if (currentStuck) { currentStuck.classList.remove('stuck'); } - const stuckDateEl = findStuckDate(container, hasTools); - if (stuckDateEl) { - stuckDateEl.classList.add('stuck'); - } - document.body.classList.remove('is-scrolling-messages'); }); }); diff --git a/src/components/middle/message/Location.tsx b/src/components/middle/message/Location.tsx index 2d7e2e6be..8ced4fd01 100644 --- a/src/components/middle/message/Location.tsx +++ b/src/components/middle/message/Location.tsx @@ -1,6 +1,7 @@ import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from '../../../lib/teact/teact'; +import { requestMutation } from '../../../lib/fasterdom/fasterdom'; import { getActions } from '../../../global'; import type { FC } from '../../../lib/teact/teact'; @@ -34,9 +35,10 @@ import useLayoutEffectWithPrevDeps from '../../../hooks/useLayoutEffectWithPrevD import Avatar from '../../common/Avatar'; import Skeleton from '../../ui/Skeleton'; -import mapPin from '../../../assets/map-pin.svg'; import './Location.scss'; +import mapPin from '../../../assets/map-pin.svg'; + const MOVE_THRESHOLD = 0.0001; // ~11m const DEFAULT_MAP_CONFIG = { width: 400, @@ -156,9 +158,11 @@ const Location: FC = ({ if (mapBlobUrl) { const contentEl = ref.current!.closest(MESSAGE_CONTENT_SELECTOR)!; getCustomAppendixBg(mapBlobUrl, isOwn, isInSelectMode, isSelected, theme).then((appendixBg) => { - contentEl.style.setProperty('--appendix-bg', appendixBg); - contentEl.classList.add('has-appendix-thumb'); - contentEl.setAttribute(CUSTOM_APPENDIX_ATTRIBUTE, ''); + requestMutation(() => { + contentEl.style.setProperty('--appendix-bg', appendixBg); + contentEl.classList.add('has-appendix-thumb'); + contentEl.setAttribute(CUSTOM_APPENDIX_ATTRIBUTE, ''); + }); }); } }, [shouldRenderText, isOwn, isInSelectMode, isSelected, theme, mapBlobUrl]); @@ -182,11 +186,12 @@ const Location: FC = ({ }, !isExpired ? (secondsBeforeEnd || 0) * 1000 : undefined); useInterval(() => { - const countdownEl = countdownRef.current; - - if (countdownEl) { - updateCountdown(countdownEl); - } + requestMutation(() => { + const countdownEl = countdownRef.current; + if (countdownEl) { + updateCountdown(countdownEl); + } + }); }, secondsBeforeEnd ? 1000 : undefined); function renderInfo() { diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index b82ea1507..a4a140fd0 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -106,7 +106,6 @@ import { } from './helpers/mediaDimensions'; import { calculateAlbumLayout } from './helpers/calculateAlbumLayout'; import renderText from '../../common/helpers/renderText'; -import calculateAuthorWidth from './helpers/calculateAuthorWidth'; import { getServerTime } from '../../../util/serverTime'; import { isElementInViewport } from '../../../util/isElementInViewport'; import { getCustomEmojiSize } from '../composer/helpers/customEmoji'; @@ -127,6 +126,7 @@ import useThrottledCallback from '../../../hooks/useThrottledCallback'; import useMessageTranslation from './hooks/useMessageTranslation'; import usePrevious from '../../../hooks/usePrevious'; import useTextLanguage from '../../../hooks/useTextLanguage'; +import useAuthorWidth from '../hooks/useAuthorWidth'; import Button from '../../ui/Button'; import Avatar from '../../common/Avatar'; @@ -388,9 +388,12 @@ const Message: FC = ({ useOnIntersect(bottomMarkerRef, observeIntersectionForBottom); const { - isContextMenuOpen, contextMenuPosition, - handleBeforeContextMenu, handleContextMenu: onContextMenu, - handleContextMenuClose, handleContextMenuHide, + isContextMenuOpen, + contextMenuPosition, + handleBeforeContextMenu, + handleContextMenu: onContextMenu, + handleContextMenuClose, + handleContextMenuHide, } = useContextMenuHandlers(ref, IS_TOUCH_ENV && isInSelectMode, true, IS_ANDROID); useEffect(() => { @@ -560,7 +563,7 @@ const Message: FC = ({ Boolean(message.views) && 'has-views', message.isEdited && 'was-edited', hasReply && 'has-reply', - isContextMenuShown && 'has-menu-open', + isContextMenuOpen && 'has-menu-open', isFocused && !noFocusHighlight && 'focused', isForwarding && 'is-forwarding', message.isDeleting && 'is-deleting', @@ -658,13 +661,19 @@ const Message: FC = ({ ref, messageId, chatId, isFocused, focusDirection, noFocusHighlight, viewportIds, isResizingContainer, ); + const signature = (isChannel && message.postAuthorTitle) + || (!asForwarded && forwardInfo?.postAuthorTitle) + || undefined; + useAuthorWidth(ref, signature); + const shouldFocusOnResize = isLastInGroup; const handleResize = useCallback((entry: ResizeObserverEntry) => { const lastHeight = messageHeightRef.current; - const newHeight = entry.target.clientHeight; + const newHeight = entry.contentRect.height; messageHeightRef.current = newHeight; + if (isAnimatingScroll() || !lastHeight || newHeight <= lastHeight) return; const container = entry.target.closest('.MessageList'); @@ -754,13 +763,6 @@ const Message: FC = ({ reactionsMaxWidth = width + EXTRA_SPACE_FOR_REACTIONS; } - const signature = (isChannel && message.postAuthorTitle) - || (!asForwarded && forwardInfo?.postAuthorTitle) - || undefined; - const metaSafeAuthorWidth = useMemo(() => { - return signature ? calculateAuthorWidth(signature) : undefined; - }, [signature]); - function renderAvatar() { const isAvatarPeerUser = avatarPeer && isUserId(avatarPeer.id); const avatarUser = (avatarPeer && isAvatarPeerUser) ? avatarPeer as ApiUser : undefined; @@ -1208,7 +1210,6 @@ const Message: FC = ({ ref={ref} id={getMessageHtmlId(message.id)} className={containerClassName} - style={metaSafeAuthorWidth ? `--meta-safe-author-width: ${metaSafeAuthorWidth}px` : undefined} data-message-id={messageId} onMouseDown={handleMouseDown} onClick={handleClick} diff --git a/src/components/middle/message/MessageContextMenu.scss b/src/components/middle/message/MessageContextMenu.scss index a41e54ba4..abe358b74 100644 --- a/src/components/middle/message/MessageContextMenu.scss +++ b/src/components/middle/message/MessageContextMenu.scss @@ -14,8 +14,6 @@ } .bubble { - transition: opacity 0.15s cubic-bezier(0.2, 0, 0.2, 1), transform 0.15s cubic-bezier(0.2, 0, 0.2, 1) !important; - transform: scale(0.7); overflow: initial; padding: 0 !important; } @@ -35,7 +33,6 @@ padding: 0.25rem 0; } - .backdrop { touch-action: none; } diff --git a/src/components/middle/message/Photo.tsx b/src/components/middle/message/Photo.tsx index 27ff25497..7064de192 100644 --- a/src/components/middle/message/Photo.tsx +++ b/src/components/middle/message/Photo.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useRef, useState, } from '../../../lib/teact/teact'; +import { requestMutation } from '../../../lib/fasterdom/fasterdom'; import type { FC } from '../../../lib/teact/teact'; import type { ApiMessage } from '../../../api/types'; @@ -159,8 +160,10 @@ const Photo: FC = ({ const contentEl = ref.current!.closest(MESSAGE_CONTENT_SELECTOR)!; if (fullMediaData) { getCustomAppendixBg(fullMediaData, isOwn, isInSelectMode, isSelected, theme).then((appendixBg) => { - contentEl.style.setProperty('--appendix-bg', appendixBg); - contentEl.setAttribute(CUSTOM_APPENDIX_ATTRIBUTE, ''); + requestMutation(() => { + contentEl.style.setProperty('--appendix-bg', appendixBg); + contentEl.setAttribute(CUSTOM_APPENDIX_ATTRIBUTE, ''); + }); }); } else { contentEl.classList.add('has-appendix-thumb'); diff --git a/src/components/middle/message/PollOption.tsx b/src/components/middle/message/PollOption.tsx index 7806e0fd9..5a5a81f3e 100644 --- a/src/components/middle/message/PollOption.tsx +++ b/src/components/middle/message/PollOption.tsx @@ -1,5 +1,7 @@ import type { FC } from '../../../lib/teact/teact'; -import React, { useState, useEffect, useRef } from '../../../lib/teact/teact'; +import React, { + useState, useEffect, useRef, useLayoutEffect, +} from '../../../lib/teact/teact'; import type { ApiPollAnswer, ApiPollResult } from '../../../api/types'; @@ -41,7 +43,7 @@ const PollOption: FC = ({ } }, [shouldAnimate, answerPercent]); - useEffect(() => { + useLayoutEffect(() => { const lineEl = lineRef.current; if (lineEl && shouldAnimate) { diff --git a/src/components/middle/message/RoundVideo.tsx b/src/components/middle/message/RoundVideo.tsx index d42d35d6a..ef41eb0bb 100644 --- a/src/components/middle/message/RoundVideo.tsx +++ b/src/components/middle/message/RoundVideo.tsx @@ -1,23 +1,23 @@ import type { FC } from '../../../lib/teact/teact'; import React, { useCallback, - useEffect, + useLayoutEffect, useRef, useState, } from '../../../lib/teact/teact'; +import { requestMutation } from '../../../lib/fasterdom/fasterdom'; import { getActions } from '../../../global'; import type { ApiMessage } from '../../../api/types'; -import { ApiMediaFormat } from '../../../api/types'; +import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; +import { ApiMediaFormat } from '../../../api/types'; import { ROUND_VIDEO_DIMENSIONS_PX } from '../../common/helpers/mediaDimensions'; import { getMessageMediaFormat, getMessageMediaHash, getMessageMediaThumbDataUri } from '../../../global/helpers'; import { formatMediaDuration } from '../../../util/dateFormat'; import buildClassName from '../../../util/buildClassName'; import { stopCurrentAudio } from '../../../util/audioPlayer'; import safePlay from '../../../util/safePlay'; -import { fastRaf } from '../../../util/schedulers'; -import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import { useIsIntersecting } from '../../../hooks/useIntersectionObserver'; import useMediaWithLoadProgress from '../../../hooks/useMediaWithLoadProgress'; import useShowTransition from '../../../hooks/useShowTransition'; @@ -92,7 +92,7 @@ const RoundVideo: FC = ({ const [isActivated, setIsActivated] = useState(false); const [progress, setProgress] = useState(0); - useEffect(() => { + useLayoutEffect(() => { if (!isActivated) { return; } @@ -133,7 +133,7 @@ const RoundVideo: FC = ({ setProgress(0); safePlay(playerRef.current); - fastRaf(() => { + requestMutation(() => { playingProgressRef.current!.innerHTML = ''; }); }, []); diff --git a/src/components/middle/message/helpers/calculateAuthorWidth.ts b/src/components/middle/message/helpers/calculateAuthorWidth.ts deleted file mode 100644 index 7e25a4593..000000000 --- a/src/components/middle/message/helpers/calculateAuthorWidth.ts +++ /dev/null @@ -1,21 +0,0 @@ -let element: HTMLSpanElement | undefined; -let fontFamily: string | undefined; -export default function calculateAuthorWidth(text: string) { - if (!fontFamily) { - fontFamily = getComputedStyle(document.documentElement).getPropertyValue('--font-family'); - } - - if (!element) { - element = document.createElement('span'); - element.style.font = `400 12px ${fontFamily}`; - element.style.whiteSpace = 'nowrap'; - element.style.position = 'absolute'; - element.style.left = '-999px'; - element.style.opacity = '.01'; - document.body.appendChild(element); - } - - element.textContent = text; - - return element.offsetWidth; -} diff --git a/src/components/middle/message/hooks/useFocusMessage.ts b/src/components/middle/message/hooks/useFocusMessage.ts index 76bff9cba..2acede6e6 100644 --- a/src/components/middle/message/hooks/useFocusMessage.ts +++ b/src/components/middle/message/hooks/useFocusMessage.ts @@ -1,6 +1,7 @@ +import { useLayoutEffect, useMemo } from '../../../../lib/teact/teact'; + import type { FocusDirection } from '../../../../types'; -import { useLayoutEffect, useMemo } from '../../../../lib/teact/teact'; import fastSmoothScroll from '../../../../util/fastSmoothScroll'; // This is used when the viewport was replaced. @@ -42,6 +43,8 @@ export default function useFocusMessage( focusDirection, undefined, isResizingContainer, + // We need this to override scroll setting from Message List layout effect + true, ); } }, [ diff --git a/src/components/middle/message/hooks/useOuterHandlers.ts b/src/components/middle/message/hooks/useOuterHandlers.ts index 2d2330f5b..09e526945 100644 --- a/src/components/middle/message/hooks/useOuterHandlers.ts +++ b/src/components/middle/message/hooks/useOuterHandlers.ts @@ -1,6 +1,7 @@ import type { RefObject } from 'react'; import type React from '../../../../lib/teact/teact'; import { useEffect, useRef } from '../../../../lib/teact/teact'; +import { requestMeasure } from '../../../../lib/fasterdom/fasterdom'; import { getActions } from '../../../../global'; import { IS_ANDROID, IS_TOUCH_ENV } from '../../../../util/windowEnvironment'; @@ -10,6 +11,7 @@ import useFlag from '../../../../hooks/useFlag'; import { preventMessageInputBlur } from '../../helpers/preventMessageInputBlur'; import stopEvent from '../../../../util/stopEvent'; import { REM } from '../../../common/helpers/mediaDimensions'; +import useThrottledCallback from '../../../../hooks/useThrottledCallback'; const ANDROID_KEYBOARD_HIDE_DELAY_MS = 350; const SWIPE_ANIMATION_DURATION = 150; @@ -45,7 +47,7 @@ export default function useOuterHandlers( handleBeforeContextMenu(e); } - function handleMouseMove(e: React.MouseEvent) { + const handleMouseMove = useThrottledCallback((e: React.MouseEvent) => { const quickReactionContainer = quickReactionRef.current; if (!quickReactionContainer) return; @@ -63,7 +65,7 @@ export default function useOuterHandlers( } else { unmarkQuickReactionVisible(); } - } + }, [quickReactionRef], requestMeasure); function handleSendQuickReaction(e: React.MouseEvent) { e.stopPropagation(); diff --git a/src/components/middle/message/hooks/useVideoAutoPause.ts b/src/components/middle/message/hooks/useVideoAutoPause.ts index 19aef38d4..b454d0045 100644 --- a/src/components/middle/message/hooks/useVideoAutoPause.ts +++ b/src/components/middle/message/hooks/useVideoAutoPause.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef } from '../../../../lib/teact/teact'; +import { requestMeasure } from '../../../../lib/fasterdom/fasterdom'; -import { fastRaf } from '../../../../util/schedulers'; import useBackgroundMode, { isBackgroundModeActive } from '../../../../hooks/useBackgroundMode'; import useHeavyAnimationCheck, { isHeavyAnimating } from '../../../../hooks/useHeavyAnimationCheck'; import usePriorityPlaybackCheck, { isPriorityPlaybackActive } from '../../../../hooks/usePriorityPlaybackCheck'; @@ -18,7 +18,7 @@ export default function useVideoAutoPause(playerRef: { current: HTMLVideoElement }, [play]); const unfreezePlayingOnRaf = useCallback(() => { - fastRaf(unfreezePlaying); + requestMeasure(unfreezePlaying); }, [unfreezePlaying]); useBackgroundMode(pause, unfreezePlayingOnRaf, !canPlay); diff --git a/src/components/right/hooks/useTransitionFixes.ts b/src/components/right/hooks/useTransitionFixes.ts index 47c79bd00..2bec7c838 100644 --- a/src/components/right/hooks/useTransitionFixes.ts +++ b/src/components/right/hooks/useTransitionFixes.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect } from '../../../lib/teact/teact'; +import { requestMutation, requestMeasure } from '../../../lib/fasterdom/fasterdom'; export default function useTransitionFixes( containerRef: { current: HTMLDivElement | null }, @@ -11,7 +12,11 @@ export default function useTransitionFixes( const transitionEl = container.querySelector(transitionElSelector); const tabsEl = container.querySelector('.TabList'); if (transitionEl && tabsEl) { - transitionEl.style.minHeight = `${container.offsetHeight - tabsEl.offsetHeight}px`; + const newHeight = container.offsetHeight - tabsEl.offsetHeight; + + requestMutation(() => { + transitionEl.style.minHeight = `${newHeight}px`; + }); } } @@ -26,12 +31,18 @@ export default function useTransitionFixes( // Workaround for scrollable content flickering during animation. const applyTransitionFix = useCallback(() => { - const container = containerRef.current!; - if (container.style.overflowY !== 'hidden') { + // This callback is called from `Transition.onStart` which is "mutate" phase + requestMeasure(() => { + const container = containerRef.current!; + if (container.style.overflowY === 'hidden') return; + const scrollBarWidth = container.offsetWidth - container.clientWidth; - container.style.overflowY = 'hidden'; - container.style.paddingRight = `${scrollBarWidth}px`; - } + + requestMutation(() => { + container.style.overflowY = 'hidden'; + container.style.paddingRight = `${scrollBarWidth}px`; + }); + }); }, [containerRef]); const releaseTransitionFix = useCallback(() => { diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 19f5d9d8c..9c6951d85 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -3,6 +3,7 @@ import type { MouseEvent as ReactMouseEvent, RefObject } from 'react'; import type { FC } from '../../lib/teact/teact'; import React, { useRef, useCallback, useState } from '../../lib/teact/teact'; +import { IS_TOUCH_ENV, MouseButton } from '../../util/windowEnvironment'; import buildClassName from '../../util/buildClassName'; import buildStyle from '../../util/buildStyle'; @@ -34,6 +35,7 @@ export type OwnProps = { download?: string; disabled?: boolean; allowDisabledClick?: boolean; + noFastClick?: boolean; ripple?: boolean; faded?: boolean; tabIndex?: number; @@ -85,6 +87,7 @@ const Button: FC = ({ download, disabled, allowDisabledClick, + noFastClick = color === 'danger', ripple, faded, tabIndex, @@ -140,7 +143,11 @@ const Button: FC = ({ if ((allowDisabledClick || !disabled) && onMouseDown) { onMouseDown(e); } - }, [allowDisabledClick, disabled, noPreventDefault, onMouseDown]); + + if (!IS_TOUCH_ENV && e.button === MouseButton.Main && !noFastClick) { + handleClick(e); + } + }, [allowDisabledClick, disabled, handleClick, noFastClick, noPreventDefault, onMouseDown]); if (href) { return ( @@ -172,7 +179,7 @@ const Button: FC = ({ id={id} type={type} className={fullClassName} - onClick={handleClick} + onClick={IS_TOUCH_ENV || noFastClick ? handleClick : undefined} onContextMenu={onContextMenu} onMouseDown={handleMouseDown} onMouseEnter={onMouseEnter && !disabled ? onMouseEnter : undefined} diff --git a/src/components/ui/Draggable.tsx b/src/components/ui/Draggable.tsx index 969236a03..a154c9e34 100644 --- a/src/components/ui/Draggable.tsx +++ b/src/components/ui/Draggable.tsx @@ -85,16 +85,14 @@ const Draggable: FC = ({ }, [id, onDrag, state.origin.x, state.origin.y]); const handleMouseUp = useCallback(() => { - requestAnimationFrame(() => { - setState((current) => ({ - ...current, - isDragging: false, - width: undefined, - height: undefined, - })); + setState((current) => ({ + ...current, + isDragging: false, + width: undefined, + height: undefined, + })); - onDragEnd(); - }); + onDragEnd(); }, [onDragEnd]); useEffect(() => { diff --git a/src/components/ui/InfiniteScroll.tsx b/src/components/ui/InfiniteScroll.tsx index bc511db9d..573ae1c6e 100644 --- a/src/components/ui/InfiniteScroll.tsx +++ b/src/components/ui/InfiniteScroll.tsx @@ -1,10 +1,11 @@ import type { RefObject, UIEvent } from 'react'; -import { LoadMoreDirection } from '../../types'; - -import type { FC } from '../../lib/teact/teact'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, } from '../../lib/teact/teact'; +import { requestForcedReflow } from '../../lib/fasterdom/fasterdom'; + +import { LoadMoreDirection } from '../../types'; +import type { FC } from '../../lib/teact/teact'; import { debounce } from '../../util/schedulers'; import resetScroll from '../../util/resetScroll'; @@ -108,36 +109,41 @@ const InfiniteScroll: FC = ({ // Restore `scrollTop` after adding items useLayoutEffect(() => { - const container = containerRef.current!; - const state = stateRef.current; + requestForcedReflow(() => { + const container = containerRef.current!; + const state = stateRef.current; - state.listItemElements = container.querySelectorAll(itemSelector); + state.listItemElements = container.querySelectorAll(itemSelector); - let newScrollTop; + let newScrollTop: number; - if (state.currentAnchor && Array.from(state.listItemElements).includes(state.currentAnchor)) { - const { scrollTop } = container; - const newAnchorTop = state.currentAnchor.getBoundingClientRect().top; - newScrollTop = scrollTop + (newAnchorTop - state.currentAnchorTop!); - } else { - const nextAnchor = state.listItemElements[0]; - if (nextAnchor) { - state.currentAnchor = nextAnchor; - state.currentAnchorTop = nextAnchor.getBoundingClientRect().top; + if (state.currentAnchor && Array.from(state.listItemElements).includes(state.currentAnchor)) { + const { scrollTop } = container; + const newAnchorTop = state.currentAnchor!.getBoundingClientRect().top; + newScrollTop = scrollTop + (newAnchorTop - state.currentAnchorTop!); + } else { + const nextAnchor = state.listItemElements[0]; + if (nextAnchor) { + state.currentAnchor = nextAnchor; + state.currentAnchorTop = nextAnchor.getBoundingClientRect().top; + } } - } - if (withAbsolutePositioning || noScrollRestore) { - return; - } + if (withAbsolutePositioning || noScrollRestore) { + return undefined; + } - if (noScrollRestoreOnTop && container.scrollTop === 0) { - return; - } + const { scrollTop } = container; + if (noScrollRestoreOnTop && scrollTop === 0) { + return undefined; + } - resetScroll(container, newScrollTop); + return () => { + resetScroll(container, newScrollTop); - state.isScrollTopJustUpdated = true; + state.isScrollTopJustUpdated = true; + }; + }); }, [items, itemSelector, noScrollRestore, noScrollRestoreOnTop, cacheBuster, withAbsolutePositioning]); const handleScroll = useCallback((e: UIEvent) => { diff --git a/src/components/ui/ListItem.tsx b/src/components/ui/ListItem.tsx index 8db1000a0..7c685c69e 100644 --- a/src/components/ui/ListItem.tsx +++ b/src/components/ui/ListItem.tsx @@ -1,9 +1,9 @@ import type { RefObject } from 'react'; import type { FC, TeactNode } from '../../lib/teact/teact'; import React, { useRef, useCallback } from '../../lib/teact/teact'; +import { requestMeasure } from '../../lib/fasterdom/fasterdom'; import { IS_TOUCH_ENV, MouseButton } from '../../util/windowEnvironment'; -import { fastRaf } from '../../util/schedulers'; import buildClassName from '../../util/buildClassName'; import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; @@ -158,7 +158,7 @@ const ListItem: FC = ({ if (IS_TOUCH_ENV && !ripple) { markIsTouched(); - fastRaf(unmarkIsTouched); + requestMeasure(unmarkIsTouched); } }, [allowDisabledClick, clickArg, disabled, markIsTouched, onClick, ripple, unmarkIsTouched, href]); diff --git a/src/components/ui/Menu.scss b/src/components/ui/Menu.scss index 73c5e4cfc..b1b8b4eb6 100644 --- a/src/components/ui/Menu.scss +++ b/src/components/ui/Menu.scss @@ -27,8 +27,8 @@ z-index: var(--z-menu-bubble); overscroll-behavior: contain; - transform: scale(0.5); - transition: opacity 0.2s cubic-bezier(0.2, 0, 0.2, 1), transform 0.2s cubic-bezier(0.2, 0, 0.2, 1) !important; + transform: scale(0.85); + transition: opacity 150ms cubic-bezier(0.2, 0, 0.2, 1), transform 150ms cubic-bezier(0.2, 0, 0.2, 1) !important; &.open { transform: scale(1); diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 04056f1f1..4b080d189 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -10,7 +10,7 @@ import buildClassName from '../../util/buildClassName'; import { enableDirectTextInput, disableDirectTextInput } from '../../util/directInputManager'; import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'; import useShowTransition from '../../hooks/useShowTransition'; -import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps'; +import useLayoutEffectWithPrevDeps from '../../hooks/useLayoutEffectWithPrevDeps'; import useLang from '../../hooks/useLang'; import useHistoryBack from '../../hooks/useHistoryBack'; @@ -88,7 +88,7 @@ const Modal: FC = ({ onBack: onClose, }); - useEffectWithPrevDeps(([prevIsOpen]) => { + useLayoutEffectWithPrevDeps(([prevIsOpen]) => { document.body.classList.toggle('has-open-dialog', Boolean(isOpen)); if (isOpen || (!isOpen && prevIsOpen !== undefined)) { diff --git a/src/components/ui/ProgressSpinner.tsx b/src/components/ui/ProgressSpinner.tsx index 7c65df0e1..9c4a3a51d 100644 --- a/src/components/ui/ProgressSpinner.tsx +++ b/src/components/ui/ProgressSpinner.tsx @@ -1,5 +1,5 @@ import type { FC } from '../../lib/teact/teact'; -import React, { useEffect, useRef, memo } from '../../lib/teact/teact'; +import React, { useRef, memo, useLayoutEffect } from '../../lib/teact/teact'; import buildClassName from '../../util/buildClassName'; @@ -32,18 +32,15 @@ const ProgressSpinner: FC<{ const borderRadius = radius - 1; const circumference = circleRadius * 2 * Math.PI; // eslint-disable-next-line no-null/no-null - const container = useRef(null); + const containerRef = useRef(null); - useEffect(() => { - if (!container.current) { - return; - } - - const svg = container.current.firstElementChild; + useLayoutEffect(() => { + const container = containerRef.current!; + const svg = container.firstElementChild; const strokeDashOffset = circumference - Math.min(Math.max(MIN_PROGRESS, progress), MAX_PROGRESS) * circumference; if (!svg) { - container.current.innerHTML = ` diff --git a/src/components/ui/RippleEffect.tsx b/src/components/ui/RippleEffect.tsx index 6da3bc0b0..7c659c0cb 100644 --- a/src/components/ui/RippleEffect.tsx +++ b/src/components/ui/RippleEffect.tsx @@ -28,9 +28,8 @@ const RippleEffect: FC = () => { return; } - const container = e.currentTarget as HTMLDivElement; - const position = container.getBoundingClientRect() as DOMRect; - + const container = e.currentTarget; + const position = container.getBoundingClientRect(); const rippleSize = container.offsetWidth / 2; setRipples([ @@ -42,9 +41,7 @@ const RippleEffect: FC = () => { }, ]); - requestAnimationFrame(() => { - cleanUpDebounced(); - }); + cleanUpDebounced(); }, [ripples, cleanUpDebounced]); return ( diff --git a/src/components/ui/Tab.tsx b/src/components/ui/Tab.tsx index 35abb9351..0b5002497 100644 --- a/src/components/ui/Tab.tsx +++ b/src/components/ui/Tab.tsx @@ -1,8 +1,15 @@ import type { FC } from '../../lib/teact/teact'; -import React, { useRef, memo, useEffect } from '../../lib/teact/teact'; +import React, { + useRef, + memo, + useEffect, + useLayoutEffect, useCallback, +} from '../../lib/teact/teact'; +import { requestForcedReflow, requestMutation } from '../../lib/fasterdom/fasterdom'; -import buildClassName from '../../util/buildClassName'; +import { IS_TOUCH_ENV, MouseButton } from '../../util/windowEnvironment'; import forceReflow from '../../util/forceReflow'; +import buildClassName from '../../util/buildClassName'; import renderText from '../common/helpers/renderText'; import './Tab.scss'; @@ -38,12 +45,14 @@ const Tab: FC = ({ // eslint-disable-next-line no-null/no-null const tabRef = useRef(null); - useEffect(() => { + useLayoutEffect(() => { // Set initial active state if (isActive && previousActiveTab === undefined && tabRef.current) { - tabRef.current.classList.add(classNames.active); + tabRef.current!.classList.add(classNames.active); } + }, [isActive, previousActiveTab]); + useEffect(() => { if (!isActive || previousActiveTab === undefined) { return; } @@ -53,7 +62,9 @@ const Tab: FC = ({ if (!prevTabEl) { // The number of tabs in the parent component has decreased. It is necessary to add the active tab class name. if (isActive && !tabEl.classList.contains(classNames.active)) { - tabEl.classList.add(classNames.active); + requestMutation(() => { + tabEl.classList.add(classNames.active); + }); } return; } @@ -65,21 +76,38 @@ const Tab: FC = ({ const shiftLeft = prevPlatformEl.parentElement!.offsetLeft - platformEl.parentElement!.offsetLeft; const scaleFactor = prevPlatformEl.clientWidth / platformEl.clientWidth; - prevPlatformEl.classList.remove('animate'); - platformEl.classList.remove('animate'); - platformEl.style.transform = `translate3d(${shiftLeft}px, 0, 0) scale3d(${scaleFactor}, 1, 1)`; - forceReflow(platformEl); - platformEl.classList.add('animate'); - platformEl.style.transform = 'none'; + requestMutation(() => { + prevPlatformEl.classList.remove('animate'); + platformEl.classList.remove('animate'); + platformEl.style.transform = `translate3d(${shiftLeft}px, 0, 0) scale3d(${scaleFactor}, 1, 1)`; - prevTabEl.classList.remove(classNames.active); - tabEl.classList.add(classNames.active); + requestForcedReflow(() => { + forceReflow(platformEl); + + return () => { + platformEl.classList.add('animate'); + platformEl.style.transform = 'none'; + + prevTabEl.classList.remove(classNames.active); + tabEl.classList.add(classNames.active); + }; + }); + }); }, [isActive, previousActiveTab]); + const handleClick = useCallback((e: React.MouseEvent) => { + if (e.type === 'mousedown' && e.button !== MouseButton.Main) { + return; + } + + onClick(clickArg); + }, [clickArg, onClick]); + return (
onClick(clickArg)} + onClick={IS_TOUCH_ENV ? handleClick : undefined} + onMouseDown={!IS_TOUCH_ENV ? handleClick : undefined} ref={tabRef} > diff --git a/src/components/ui/Transition.tsx b/src/components/ui/Transition.tsx index 7338dee88..af781e8c0 100644 --- a/src/components/ui/Transition.tsx +++ b/src/components/ui/Transition.tsx @@ -1,7 +1,10 @@ import type { RefObject } from 'react'; -import React, { useLayoutEffect, useRef } from '../../lib/teact/teact'; +import React, { useEffect, useLayoutEffect, useRef } from '../../lib/teact/teact'; import { addExtraClass, removeExtraClass, toggleExtraClass } from '../../lib/teact/teact-dom'; +import { requestMutation, requestForcedReflow } from '../../lib/fasterdom/fasterdom'; + import { getGlobal } from '../../global'; + import type { GlobalState } from '../../global/types'; import { ANIMATION_LEVEL_MIN } from '../../config'; @@ -169,9 +172,6 @@ function Transition({ return; } - removeExtraClass(container, 'animating'); - toggleExtraClass(container, 'backwards', isBackwards); - if (name === 'none' || animationLevel === ANIMATION_LEVEL_MIN) { childNodes.forEach((node, i) => { if (node instanceof HTMLElement) { @@ -199,57 +199,57 @@ function Transition({ }); const dispatchHeavyAnimationStop = dispatchHeavyAnimationEvent(); + onStart?.(); - requestAnimationFrame(() => { - addExtraClass(container, 'animating'); + addExtraClass(container, 'animating'); + toggleExtraClass(container, 'backwards', isBackwards); - onStart?.(); + function onAnimationEnd() { + const activeElement = container.querySelector(`.${CLASSES.active}`); + const { clientHeight } = activeElement || {}; - function onAnimationEnd() { - requestAnimationFrame(() => { - if (activeKey !== currentKeyRef.current) { - return; + requestMutation(() => { + if (activeKey !== currentKeyRef.current) { + return; + } + + removeExtraClass(container, 'animating'); + removeExtraClass(container, 'backwards'); + + childNodes.forEach((node, i) => { + if (node instanceof HTMLElement) { + removeExtraClass(node, 'from'); + removeExtraClass(node, 'through'); + removeExtraClass(node, 'to'); + toggleExtraClass(node, CLASSES.active, i === activeIndex); } - - removeExtraClass(container, 'animating'); - removeExtraClass(container, 'backwards'); - - childNodes.forEach((node, i) => { - if (node instanceof HTMLElement) { - removeExtraClass(node, 'from'); - removeExtraClass(node, 'through'); - removeExtraClass(node, 'to'); - toggleExtraClass(node, CLASSES.active, i === activeIndex); - } - }); - - if (shouldRestoreHeight) { - const activeElement = container.querySelector(`.${CLASSES.active}`); - - if (activeElement) { - activeElement.style.height = 'auto'; - container.style.height = `${activeElement.clientHeight}px`; - } - } - - onStop?.(); - dispatchHeavyAnimationStop(); - cleanup(); }); - } - const watchedNode = name === 'mv-slide' - ? childNodes[activeIndex]?.firstChild - : name === 'reveal' && isBackwards - ? childNodes[prevActiveIndex] - : childNodes[activeIndex]; + if (shouldRestoreHeight) { + if (activeElement) { + activeElement.style.height = 'auto'; + container.style.height = `${clientHeight}px`; + } + } - if (watchedNode) { - waitForAnimationEnd(watchedNode, onAnimationEnd, undefined, FALLBACK_ANIMATION_END); - } else { - onAnimationEnd(); - } - }); + onStop?.(); + dispatchHeavyAnimationStop(); + + cleanup(); + }); + } + + const watchedNode = name === 'mv-slide' + ? childNodes[activeIndex]?.firstChild + : name === 'reveal' && isBackwards + ? childNodes[prevActiveIndex] + : childNodes[activeIndex]; + + if (watchedNode) { + waitForAnimationEnd(watchedNode, onAnimationEnd, undefined, FALLBACK_ANIMATION_END); + } else { + onAnimationEnd(); + } }, [ activeKey, nextKey, @@ -268,20 +268,28 @@ function Transition({ forceUpdate, ]); - useLayoutEffect(() => { - if (shouldRestoreHeight) { - const container = containerRef.current!; - const activeElement = container.querySelector(`.${CLASSES.active}`) - || container.querySelector('.from'); - const clientHeight = activeElement?.clientHeight; - if (!clientHeight) { - return; - } + useEffect(() => { + if (!shouldRestoreHeight) { + return; + } + const container = containerRef.current!; + const activeElement = container.querySelector(`.${CLASSES.active}`) + || container.querySelector('.from'); + if (!activeElement) { + return; + } + + const { clientHeight } = activeElement || {}; + if (!clientHeight) { + return; + } + + requestMutation(() => { activeElement.style.height = 'auto'; container.style.height = `${clientHeight}px`; container.style.flexBasis = `${clientHeight}px`; - } + }); }, [shouldRestoreHeight, children]); const asFastList = !renderCount; @@ -360,27 +368,33 @@ function performSlideOptimized( const dispatchHeavyAnimationStop = dispatchHeavyAnimationEvent(); - requestAnimationFrame(() => { - onStart?.(); + onStart?.(); - fromSlide.style.transition = 'none'; - fromSlide.style.transform = 'translate3d(0, 0, 0)'; + fromSlide.style.transition = 'none'; + fromSlide.style.transform = 'translate3d(0, 0, 0)'; - toSlide.style.transition = 'none'; - toSlide.style.transform = `translate3d(${isBackwards ? '-' : ''}100%, 0, 0)`; + toSlide.style.transition = 'none'; + toSlide.style.transform = `translate3d(${isBackwards ? '-' : ''}100%, 0, 0)`; + requestForcedReflow(() => { forceReflow(toSlide); - fromSlide.style.transition = ''; - fromSlide.style.transform = `translate3d(${isBackwards ? '' : '-'}100%, 0, 0)`; + return () => { + fromSlide.style.transition = ''; + fromSlide.style.transform = `translate3d(${isBackwards ? '' : '-'}100%, 0, 0)`; - toSlide.style.transition = ''; - toSlide.style.transform = 'translate3d(0, 0, 0)'; + toSlide.style.transition = ''; + toSlide.style.transform = 'translate3d(0, 0, 0)'; - removeExtraClass(fromSlide, CLASSES.active); - addExtraClass(toSlide, CLASSES.active); + removeExtraClass(fromSlide, CLASSES.active); + addExtraClass(toSlide, CLASSES.active); + }; + }); - waitForTransitionEnd(fromSlide, () => { + waitForTransitionEnd(fromSlide, () => { + const { clientHeight } = toSlide; + + requestMutation(() => { if (activeKey !== currentKeyRef.current) { return; } @@ -390,7 +404,7 @@ function performSlideOptimized( if (shouldRestoreHeight) { toSlide.style.height = 'auto'; - container.style.height = `${toSlide.clientHeight}px`; + container.style.height = `${clientHeight}px`; } onStop?.(); diff --git a/src/config.ts b/src/config.ts index 9110b12c6..a47cda175 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,7 @@ export const PRODUCTION_HOSTNAME = 'web.telegram.org'; export const DEBUG = process.env.APP_ENV !== 'production'; export const DEBUG_MORE = false; +export const STRICTERDOM_ENABLED = DEBUG; export const IS_MOCKED_CLIENT = process.env.APP_MOCKED_CLIENT === '1'; export const IS_TEST = process.env.APP_ENV === 'test'; @@ -143,10 +144,10 @@ export const TMP_CHAT_ID = '0'; export const ANIMATION_END_DELAY = 100; -export const FAST_SMOOTH_MAX_DISTANCE = 1500; export const FAST_SMOOTH_MIN_DURATION = 250; export const FAST_SMOOTH_MAX_DURATION = 600; -export const FAST_SMOOTH_SHORT_TRANSITION_MAX_DISTANCE = 750; // px +export const FAST_SMOOTH_MAX_DISTANCE = 750; +export const FAST_SMOOTH_SHORT_TRANSITION_MAX_DISTANCE = 300; // px // Average duration of message sending animation export const API_UPDATE_THROTTLE = Math.round((FAST_SMOOTH_MIN_DURATION + FAST_SMOOTH_MAX_DURATION) / 2); diff --git a/src/global/actions/ui/calls.ts b/src/global/actions/ui/calls.ts index e1e35b67e..23b61909c 100644 --- a/src/global/actions/ui/calls.ts +++ b/src/global/actions/ui/calls.ts @@ -1,4 +1,5 @@ import type { RequiredGlobalActions } from '../../index'; +import { requestNextMutation } from '../../../lib/fasterdom/fasterdom'; import { addActionHandler, getGlobal, setGlobal, @@ -52,7 +53,7 @@ export function initializeSoundsForSafari() { sound.currentTime = 0; sound.muted = false; - requestAnimationFrame(() => { + requestNextMutation(() => { sound.src = prevSrc; }); }); diff --git a/src/global/actions/ui/initial.ts b/src/global/actions/ui/initial.ts index 92a53e46a..f4df994e1 100644 --- a/src/global/actions/ui/initial.ts +++ b/src/global/actions/ui/initial.ts @@ -1,5 +1,7 @@ -import { addActionHandler, getGlobal, setGlobal } from '../../index'; +import { addCallback } from '../../../lib/teact/teactn'; +import { requestMutation } from '../../../lib/fasterdom/fasterdom'; +import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { ANIMATION_LEVEL_MAX } from '../../../config'; import { IS_ANDROID, IS_IOS, IS_MAC_OS, IS_SAFARI, IS_TOUCH_ENV, @@ -16,7 +18,6 @@ import { callApi } from '../../../api/gramjs'; import type { ActionReturnType, GlobalState } from '../../types'; import { updateTabState } from '../../reducers/tabs'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; -import { addCallback } from '../../../lib/teact/teactn'; const HISTORY_ANIMATION_DURATION = 450; @@ -103,31 +104,33 @@ addCallback((global: GlobalState) => { void setLanguage(language, undefined, true); - document.documentElement.style.setProperty( - '--composer-text-size', `${Math.max(messageTextSize, IS_IOS ? 16 : 15)}px`, - ); - document.documentElement.style.setProperty('--message-meta-height', `${Math.floor(messageTextSize * 1.3125)}px`); - document.documentElement.style.setProperty('--message-text-size', `${messageTextSize}px`); - document.documentElement.setAttribute('data-message-text-size', messageTextSize.toString()); - document.body.classList.add('initial'); - document.body.classList.add(`animation-level-${animationLevel}`); - document.body.classList.add(IS_TOUCH_ENV ? 'is-touch-env' : 'is-pointer-env'); + requestMutation(() => { + document.documentElement.style.setProperty( + '--composer-text-size', `${Math.max(messageTextSize, IS_IOS ? 16 : 15)}px`, + ); + document.documentElement.style.setProperty('--message-meta-height', `${Math.floor(messageTextSize * 1.3125)}px`); + document.documentElement.style.setProperty('--message-text-size', `${messageTextSize}px`); + document.documentElement.setAttribute('data-message-text-size', messageTextSize.toString()); + document.body.classList.add('initial'); + document.body.classList.add(`animation-level-${animationLevel}`); + document.body.classList.add(IS_TOUCH_ENV ? 'is-touch-env' : 'is-pointer-env'); + + if (IS_IOS) { + document.body.classList.add('is-ios'); + } else if (IS_ANDROID) { + document.body.classList.add('is-android'); + } else if (IS_MAC_OS) { + document.body.classList.add('is-macos'); + } + if (IS_SAFARI) { + document.body.classList.add('is-safari'); + } + }); switchTheme(theme, animationLevel === ANIMATION_LEVEL_MAX); startWebsync(); - if (IS_IOS) { - document.body.classList.add('is-ios'); - } else if (IS_ANDROID) { - document.body.classList.add('is-android'); - } else if (IS_MAC_OS) { - document.body.classList.add('is-macos'); - } - if (IS_SAFARI) { - document.body.classList.add('is-safari'); - } - isUpdated = true; if (isUpdated) setGlobal(global); @@ -144,7 +147,9 @@ addActionHandler('setIsUiReady', (global, actions, payload): ActionReturnType => const { uiReadyState, tabId = getCurrentTabId() } = payload!; if (uiReadyState === 2) { - document.body.classList.remove('initial'); + requestMutation(() => { + document.body.classList.remove('initial'); + }); } return updateTabState(global, { @@ -184,7 +189,10 @@ addActionHandler('disableHistoryAnimations', (global, actions, payload): ActionR shouldSkipHistoryAnimations: false, }, tabId); setGlobal(global); - document.body.classList.remove('no-animate'); + + requestMutation(() => { + document.body.classList.remove('no-animate'); + }); }, HISTORY_ANIMATION_DURATION); global = updateTabState(global, { diff --git a/src/hooks/useAsyncResolvers.ts b/src/hooks/useAsyncResolvers.ts index d7c9d568b..88c50a987 100644 --- a/src/hooks/useAsyncResolvers.ts +++ b/src/hooks/useAsyncResolvers.ts @@ -1,5 +1,8 @@ +import type { Signal } from '../util/signals'; + import useThrottledCallback from './useThrottledCallback'; import useDebouncedCallback from './useDebouncedCallback'; +import useDerivedSignal from './useDerivedSignal'; export function useThrottledResolver(resolver: () => T, deps: any[], ms: number, noFirst = false) { return useThrottledCallback((setValue: (newValue: T) => void) => { @@ -14,3 +17,16 @@ export function useDebouncedResolver(resolver: () => T, deps: any[], ms: numb // eslint-disable-next-line react-hooks-static-deps/exhaustive-deps }, deps, ms, noFirst, noLast); } + +export function useDebouncedSignal( + getValue: Signal, + ms: number, + noFirst = false, + noLast = false, +): Signal { + const debouncedResolver = useDebouncedResolver(() => getValue(), [getValue], ms, noFirst, noLast); + + return useDerivedSignal( + debouncedResolver, [debouncedResolver, getValue], true, + ); +} diff --git a/src/hooks/useBoundsInSharedCanvas.ts b/src/hooks/useBoundsInSharedCanvas.ts index ab0ae032d..4ce1bd5b3 100644 --- a/src/hooks/useBoundsInSharedCanvas.ts +++ b/src/hooks/useBoundsInSharedCanvas.ts @@ -1,6 +1,7 @@ import { - useCallback, useLayoutEffect, useMemo, useState, + useCallback, useEffect, useMemo, useState, } from '../lib/teact/teact'; +import { requestMeasure } from '../lib/fasterdom/fasterdom'; import { round } from '../util/math'; @@ -27,7 +28,8 @@ export default function useBoundsInSharedCanvas( // Wait until elements are properly mounted if (!container.offsetParent || !canvas.offsetParent) { - setTimeout(recalculate, ANIMATION_END_TIMEOUT); + // `requestMeasure` is useful as timeouts are run in parallel with image loadings and thus causing reflow + setTimeout(() => requestMeasure(recalculate), ANIMATION_END_TIMEOUT); return; } @@ -38,15 +40,14 @@ export default function useBoundsInSharedCanvas( const canvasBounds = canvas.getBoundingClientRect(); // Factor coords are used to support rendering while being rescaled (e.g. message appearance animation) - setX(round((targetBounds.left - canvasBounds.left) / canvasBounds.width, 4)); - setY(round((targetBounds.top - canvasBounds.top) / canvasBounds.height, 4)); + setX(round((targetBounds.left - canvasBounds.left) / canvasBounds.width, 4) || 0); + setY(round((targetBounds.top - canvasBounds.top) / canvasBounds.height, 4) || 0); setSize(Math.round(targetBounds.width)); }, [containerRef, sharedCanvasRef]); - const throttledRecalculate = useThrottledCallback(recalculate, [recalculate], THROTTLE_MS, false); - - useLayoutEffect(recalculate, [recalculate]); + useEffect(recalculate, [recalculate]); + const throttledRecalculate = useThrottledCallback(recalculate, [recalculate], THROTTLE_MS); useResizeObserver(sharedCanvasRef, throttledRecalculate); const coords = useMemo(() => (x !== undefined && y !== undefined ? { x, y } : undefined), [x, y]); diff --git a/src/hooks/useCanvasBlur.ts b/src/hooks/useCanvasBlur.ts index d0c4136c7..9027d8003 100644 --- a/src/hooks/useCanvasBlur.ts +++ b/src/hooks/useCanvasBlur.ts @@ -1,4 +1,5 @@ import { useEffect, useRef } from '../lib/teact/teact'; +import { requestMeasure, requestMutation } from '../lib/fasterdom/fasterdom'; import { IS_CANVAS_FILTER_SUPPORTED } from '../util/windowEnvironment'; import fastBlur from '../lib/fastBlur'; @@ -37,25 +38,29 @@ export default function useCanvasBlur( const img = new Image(); const processBlur = () => { - canvas.width = preferredWidth || img.width; - canvas.height = preferredHeight || img.height; - + const width = preferredWidth || img.width; + const height = preferredHeight || img.height; const ctx = canvas.getContext('2d', { alpha: false })!; - if (IS_CANVAS_FILTER_SUPPORTED) { - ctx.filter = `blur(${radius}px)`; - } + requestMutation(() => { + canvas.width = width; + canvas.height = height; - ctx.drawImage(img, -radius * 2, -radius * 2, canvas.width + radius * 4, canvas.height + radius * 4); + if (IS_CANVAS_FILTER_SUPPORTED) { + ctx.filter = `blur(${radius}px)`; + } - if (!IS_CANVAS_FILTER_SUPPORTED) { - fastBlur(ctx, 0, 0, canvas.width, canvas.height, radius, ITERATIONS); - } + ctx.drawImage(img, -radius * 2, -radius * 2, width + radius * 4, height + radius * 4); + + if (!IS_CANVAS_FILTER_SUPPORTED) { + fastBlur(ctx, 0, 0, width, height, radius, ITERATIONS); + } + }); }; img.onload = () => { if (withRaf) { - requestAnimationFrame(processBlur); + requestMeasure(processBlur); } else { processBlur(); } diff --git a/src/hooks/useContextMenuHandlers.ts b/src/hooks/useContextMenuHandlers.ts index d0f082ee6..da4b6f977 100644 --- a/src/hooks/useContextMenuHandlers.ts +++ b/src/hooks/useContextMenuHandlers.ts @@ -1,5 +1,7 @@ import type { RefObject } from 'react'; import { useState, useEffect, useCallback } from '../lib/teact/teact'; +import { addExtraClass, removeExtraClass } from '../lib/teact/teact-dom'; +import { requestMutation } from '../lib/fasterdom/fasterdom'; import type { IAnchorPosition } from '../types'; import { @@ -26,12 +28,16 @@ const useContextMenuHandlers = ( const handleBeforeContextMenu = useCallback((e: React.MouseEvent) => { if (!isMenuDisabled && e.button === 2) { - (e.target as HTMLElement).classList.add('no-selection'); + requestMutation(() => { + addExtraClass(e.target as HTMLElement, 'no-selection'); + }); } }, [isMenuDisabled]); const handleContextMenu = useCallback((e: React.MouseEvent) => { - (e.target as HTMLElement).classList.remove('no-selection'); + requestMutation(() => { + removeExtraClass(e.target as HTMLElement, 'no-selection'); + }); if (isMenuDisabled || (shouldDisableOnLink && (e.target as HTMLElement).matches('a[href]'))) { return; diff --git a/src/hooks/useDynamicColorListener.ts b/src/hooks/useDynamicColorListener.ts index 0eb88561f..5160581fa 100644 --- a/src/hooks/useDynamicColorListener.ts +++ b/src/hooks/useDynamicColorListener.ts @@ -1,5 +1,5 @@ import { - useCallback, useEffect, useRef, useState, + useCallback, useEffect, useLayoutEffect, useRef, useState, } from '../lib/teact/teact'; import { hexToRgb } from '../util/switchTheme'; import { getPropertyHexColor } from '../util/themeStyle'; @@ -41,8 +41,22 @@ export default function useDynamicColorListener(ref?: React.RefObject { + const el = ref?.current; + if (!el || isDisabled) { + return undefined; + } + + el.style.setProperty('transition', TRANSITION_STYLE, 'important'); + + return () => { + el.style.removeProperty('transition'); + }; + }, [isDisabled, ref]); + useEffect(() => { - if (!ref?.current) { + const el = ref?.current; + if (!el) { return undefined; } @@ -57,12 +71,10 @@ export default function useDynamicColorListener(ref?: React.RefObject { el.removeEventListener('transitionend', handleTransitionEnd); - el.style.removeProperty('transition'); }; }, [isDisabled, ref, updateColor]); diff --git a/src/hooks/useFocusAfterAnimation.tsx b/src/hooks/useFocusAfterAnimation.tsx index 8979d636f..b7925ceba 100644 --- a/src/hooks/useFocusAfterAnimation.tsx +++ b/src/hooks/useFocusAfterAnimation.tsx @@ -1,7 +1,7 @@ import type { RefObject } from 'react'; +import { requestMutation } from '../lib/fasterdom/fasterdom'; import { IS_TOUCH_ENV } from '../util/windowEnvironment'; -import { fastRaf } from '../util/schedulers'; import { useEffect } from '../lib/teact/teact'; const DEFAULT_DURATION = 400; @@ -15,10 +15,8 @@ export default function useFocusAfterAnimation( } setTimeout(() => { - fastRaf(() => { - if (ref.current) { - ref.current.focus(); - } + requestMutation(() => { + ref.current?.focus(); }); }, animationDuration); }, [ref, animationDuration]); diff --git a/src/hooks/useHistoryBack.ts b/src/hooks/useHistoryBack.ts index 8fe4611e6..4d0f79e38 100644 --- a/src/hooks/useHistoryBack.ts +++ b/src/hooks/useHistoryBack.ts @@ -1,8 +1,8 @@ import { useCallback, useRef } from '../lib/teact/teact'; import { getActions } from '../lib/teact/teactn'; +import { requestMeasure } from '../lib/fasterdom/fasterdom'; import { IS_TEST } from '../config'; -import { fastRaf } from '../util/schedulers'; import { IS_IOS } from '../util/windowEnvironment'; import useSyncEffect from './useSyncEffect'; @@ -109,7 +109,10 @@ function processStateOperations(stateOperations: HistoryOperationState[]) { } function deferHistoryOperation(historyOperation: HistoryOperation) { - if (!deferredHistoryOperations.length) fastRaf(applyDeferredHistoryOperations); + if (!deferredHistoryOperations.length) { + requestMeasure(applyDeferredHistoryOperations); + } + deferredHistoryOperations.push(historyOperation); } diff --git a/src/hooks/useInputFocusOnOpen.ts b/src/hooks/useInputFocusOnOpen.ts index f56575c5b..48d047a6c 100644 --- a/src/hooks/useInputFocusOnOpen.ts +++ b/src/hooks/useInputFocusOnOpen.ts @@ -1,5 +1,7 @@ import type { RefObject } from 'react'; import { useEffect } from '../lib/teact/teact'; +import { requestMutation } from '../lib/fasterdom/fasterdom'; + import useAppLayout from './useAppLayout'; // Focus slows down animation, also it breaks transition layout in Chrome @@ -17,7 +19,7 @@ export default function useInputFocusOnOpen( if (isOpen) { if (!isMobile) { setTimeout(() => { - requestAnimationFrame(() => { + requestMutation(() => { if (inputRef.current?.isConnected) { inputRef.current.focus(); } diff --git a/src/hooks/useIntersectionObserver.ts b/src/hooks/useIntersectionObserver.ts index 89cd8832f..ea10a87be 100644 --- a/src/hooks/useIntersectionObserver.ts +++ b/src/hooks/useIntersectionObserver.ts @@ -3,7 +3,11 @@ import { useEffect, useRef, useCallback, useState, } from '../lib/teact/teact'; -import { throttle, debounce } from '../util/schedulers'; +import type { Scheduler } from '../util/schedulers'; + +import { + throttle, debounce, throttleWith, +} from '../util/schedulers'; import useEffectOnce from './useEffectOnce'; import useHeavyAnimationCheck from './useHeavyAnimationCheck'; @@ -26,6 +30,7 @@ interface Response { export function useIntersectionObserver({ rootRef, throttleMs, + throttleScheduler, debounceMs, shouldSkipFirst, margin, @@ -34,6 +39,7 @@ export function useIntersectionObserver({ }: { rootRef: RefObject; throttleMs?: number; + throttleScheduler?: Scheduler; debounceMs?: number; shouldSkipFirst?: boolean; margin?: number; @@ -99,10 +105,18 @@ export function useIntersectionObserver({ entriesAccumulator.clear(); }; - const scheduler = throttleMs ? throttle : debounceMs ? debounce : undefined; - const observerCallback = scheduler - ? scheduler(observerCallbackSync, (throttleMs || debounceMs)!, !shouldSkipFirst) - : observerCallbackSync; + + let observerCallback: typeof observerCallbackSync; + if (typeof throttleScheduler === 'function') { + observerCallback = throttleWith(throttleScheduler, observerCallbackSync); + } else if (throttleMs) { + observerCallback = throttle(observerCallbackSync, throttleMs, !shouldSkipFirst); + } else if (debounceMs) { + observerCallback = debounce(observerCallbackSync, debounceMs, !shouldSkipFirst); + } else { + observerCallback = observerCallbackSync; + } + const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { diff --git a/src/hooks/useResizeObserver.ts b/src/hooks/useResizeObserver.ts index ed06aa628..d5e58dc26 100644 --- a/src/hooks/useResizeObserver.ts +++ b/src/hooks/useResizeObserver.ts @@ -16,8 +16,8 @@ export default function useResizeObserver( } const el = ref.current; const callback: ResizeObserverCallback = ([entry]) => { - // During animation - if (!(entry.target as HTMLElement).offsetParent) { + // Ignore updates when element is not properly mounted (`display: none`) + if (entry.contentRect.width === 0 && entry.contentRect.height === 0) { return; } diff --git a/src/hooks/useThrottledCallback.ts b/src/hooks/useThrottledCallback.ts index 54fad98ab..129c07b6d 100644 --- a/src/hooks/useThrottledCallback.ts +++ b/src/hooks/useThrottledCallback.ts @@ -1,22 +1,22 @@ import { useCallback, useMemo } from '../lib/teact/teact'; -import type { fastRaf } from '../util/schedulers'; -import { throttle, throttleWithRaf } from '../util/schedulers'; +import type { Scheduler } from '../util/schedulers'; +import { throttle, throttleWith } from '../util/schedulers'; export default function useThrottledCallback( fn: T, deps: any[], - msOrRaf: number | typeof fastRaf, + msOrSchedulerFn: number | Scheduler, noFirst = false, ) { // eslint-disable-next-line react-hooks-static-deps/exhaustive-deps const fnMemo = useCallback(fn, deps); return useMemo(() => { - if (typeof msOrRaf === 'number') { - return throttle(fnMemo, msOrRaf, !noFirst); + if (typeof msOrSchedulerFn === 'number') { + return throttle(fnMemo, msOrSchedulerFn, !noFirst); } else { - return throttleWithRaf(fnMemo); + return throttleWith(msOrSchedulerFn, fnMemo); } - }, [fnMemo, msOrRaf, noFirst]); + }, [fnMemo, msOrSchedulerFn, noFirst]); } diff --git a/src/hooks/useVideoCleanup.ts b/src/hooks/useVideoCleanup.ts index 4e587a44e..a87a3a5a8 100644 --- a/src/hooks/useVideoCleanup.ts +++ b/src/hooks/useVideoCleanup.ts @@ -1,6 +1,6 @@ import type { RefObject } from 'react'; import { useEffect } from '../lib/teact/teact'; -import { fastRaf } from '../util/schedulers'; +import { requestNextMutation } from '../lib/fasterdom/fasterdom'; // Fix for memory leak when unmounting video element export default function useVideoCleanup(videoRef: RefObject, dependencies: any[]) { @@ -9,7 +9,8 @@ export default function useVideoCleanup(videoRef: RefObject, d return () => { if (videoEl) { - fastRaf(() => { + // It may be slow (specifically on iOS), so we postpone it after unmounting + requestNextMutation(() => { videoEl.pause(); videoEl.src = ''; videoEl.load(); diff --git a/src/index.tsx b/src/index.tsx index d2ba7816f..2b4cfe751 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,6 +3,7 @@ import './util/setupServiceWorker'; import React from './lib/teact/teact'; import TeactDOM from './lib/teact/teact-dom'; +import { enableStrict, requestMutation } from './lib/fasterdom/fasterdom'; import { getActions, getGlobal, @@ -11,7 +12,9 @@ import updateWebmanifest from './util/updateWebmanifest'; import { IS_MULTITAB_SUPPORTED } from './util/windowEnvironment'; import './global/init'; -import { APP_VERSION, DEBUG, MULTITAB_LOCALSTORAGE_KEY } from './config'; +import { + APP_VERSION, DEBUG, MULTITAB_LOCALSTORAGE_KEY, STRICTERDOM_ENABLED, +} from './config'; import { establishMultitabRole, subscribeToMasterChange } from './util/establishMultitabRole'; import { requestGlobal, subscribeToMultitabBroadcastChannel } from './util/multitab'; import { onBeforeUnload } from './util/schedulers'; @@ -21,6 +24,12 @@ import App from './components/App'; import './styles/index.scss'; +if (STRICTERDOM_ENABLED) { + enableStrict(); +} + +init(); + async function init() { if (DEBUG) { // eslint-disable-next-line no-console @@ -56,12 +65,14 @@ async function init() { console.log('>>> START INITIAL RENDER'); } - updateWebmanifest(); + requestMutation(() => { + updateWebmanifest(); - TeactDOM.render( - , - document.getElementById('root')!, - ); + TeactDOM.render( + , + document.getElementById('root')!, + ); + }); if (DEBUG) { // eslint-disable-next-line no-console @@ -77,5 +88,3 @@ async function init() { }); } } - -init(); diff --git a/src/lib/fasterdom/fasterdom.ts b/src/lib/fasterdom/fasterdom.ts new file mode 100644 index 000000000..b3389b4a3 --- /dev/null +++ b/src/lib/fasterdom/fasterdom.ts @@ -0,0 +1,86 @@ +import { fastRaf, throttleWith } from '../../util/schedulers'; +import safeExec from '../../util/safeExec'; + +import { setPhase } from './stricterdom'; + +let pendingMeasureTasks: NoneToVoidFunction[] = []; +let pendingMutationTasks: NoneToVoidFunction[] = []; +let pendingForceReflowTasks: (() => NoneToVoidFunction | void)[] = []; + +const runUpdatePassOnRaf = throttleWithRafFallback(() => { + const currentMeasureTasks = pendingMeasureTasks; + pendingMeasureTasks = []; + currentMeasureTasks.forEach((task) => { + safeExec(task); + }); + + // We use promises to provide correct order for Mutation Observer callback microtasks + Promise.resolve() + .then(() => { + setPhase('mutate'); + + const currentMutationTasks = pendingMutationTasks; + pendingMutationTasks = []; + + currentMutationTasks.forEach((task) => { + safeExec(task); + }); + }) + .then(() => { + setPhase('measure'); + + const pendingForceReflowMutationTasks: NoneToVoidFunction[] = []; + // Will include tasks created during the loop + for (const task of pendingForceReflowTasks) { + safeExec(() => { + const mutationTask = task(); + if (mutationTask) { + pendingForceReflowMutationTasks.push(mutationTask); + } + }); + } + pendingForceReflowTasks = []; + + return pendingForceReflowMutationTasks; + }) + .then((pendingForceReflowMutationTasks) => { + setPhase('mutate'); + + // Will include tasks created during the loop + for (const task of pendingForceReflowMutationTasks) { + safeExec(task); + } + }) + .then(() => { + setPhase('measure'); + }); +}); + +export function requestMeasure(cb: NoneToVoidFunction) { + pendingMeasureTasks.push(cb); + runUpdatePassOnRaf(); +} + +export function requestMutation(cb: NoneToVoidFunction) { + pendingMutationTasks.push(cb); + runUpdatePassOnRaf(); +} + +export function requestNextMutation(cb: () => (NoneToVoidFunction | void)) { + requestMeasure(() => { + requestMutation(cb); + }); +} + +export function requestForcedReflow(cb: () => (NoneToVoidFunction | void)) { + pendingForceReflowTasks.push(cb); + runUpdatePassOnRaf(); +} + +function throttleWithRafFallback(fn: F) { + return throttleWith((throttledFn: NoneToVoidFunction) => { + fastRaf(throttledFn, true); + }, fn); +} + +export * from './stricterdom'; diff --git a/src/lib/fasterdom/layoutCauses.ts b/src/lib/fasterdom/layoutCauses.ts new file mode 100644 index 000000000..7758b09ac --- /dev/null +++ b/src/lib/fasterdom/layoutCauses.ts @@ -0,0 +1,46 @@ +// https://gist.github.com/paulirish/5d52fb081b3570c81e3a + +export default { + Element: { + props: [ + 'clientLeft', 'clientTop', 'clientWidth', 'clientHeight', + 'scrollWidth', 'scrollHeight', 'scrollLeft', 'scrollTop', + ] as const, + methods: [ + 'getClientRects', 'getBoundingClientRect', + 'scrollBy', 'scrollTo', 'scrollIntoView', 'scrollIntoViewIfNeeded', + ] as const, + }, + HTMLElement: { + props: [ + 'offsetLeft', 'offsetTop', 'offsetWidth', 'offsetHeight', 'offsetParent', + 'innerText', + ] as const, + methods: ['focus'] as const, + }, + window: { + props: [ + 'scrollX', 'scrollY', + 'innerHeight', 'innerWidth', + ] as const, + methods: ['getComputedStyle'] as const, + }, + VisualViewport: { + props: [ + 'height', 'width', 'offsetTop', 'offsetLeft', + ] as const, + }, + Document: { + props: ['scrollingElement'] as const, + methods: ['elementFromPoint'] as const, + }, + HTMLInputElement: { + methods: ['select'] as const, + }, + MouseEvent: { + props: ['layerX', 'layerY', 'offsetX', 'offsetY'] as const, + }, + Range: { + methods: ['getClientRects', 'getBoundingClientRect'] as const, + }, +}; diff --git a/src/lib/fasterdom/stricterdom.ts b/src/lib/fasterdom/stricterdom.ts new file mode 100644 index 000000000..882867c63 --- /dev/null +++ b/src/lib/fasterdom/stricterdom.ts @@ -0,0 +1,165 @@ +import LAYOUT_CAUSES from './layoutCauses'; + +type Entities = keyof typeof LAYOUT_CAUSES; +type Phase = + 'measure' + | 'mutate'; +type ErrorHandler = (error: Error) => any; + +// eslint-disable-next-line no-console +const DEFAULT_ERROR_HANDLER = console.error; + +let onError: ErrorHandler = DEFAULT_ERROR_HANDLER; + +const nativeMethods = new Map(); + +let phase: Phase = 'measure'; +let isStrict = false; +let observer: MutationObserver | undefined; + +export function setPhase(newPhase: Phase) { + phase = newPhase; +} + +export function getPhase() { + return phase; +} + +export function enableStrict() { + if (isStrict) return; + + isStrict = true; + setupLayoutDetectors(); + setupMutationObserver(); +} + +export function disableStrict() { + if (!isStrict) return; + + clearMutationObserver(); + clearLayoutDetectors(); + isStrict = false; +} + +export function forceMeasure(cb: () => any) { + if (phase !== 'mutate') { + throw new Error('The current phase is \'measure\''); + } + + phase = 'measure'; + const result = cb(); + phase = 'mutate'; + + return result; +} + +export function setHandler(handler?: ErrorHandler) { + onError = handler || DEFAULT_ERROR_HANDLER; +} + +function setupLayoutDetectors() { + Object.entries(LAYOUT_CAUSES).forEach(([name, causes]) => { + const entity = window[name as Entities]; + const prototype = typeof entity === 'object' ? entity : entity.prototype; + + if ('props' in causes) { + causes.props.forEach((prop) => { + const nativeGetter = Object.getOwnPropertyDescriptor(prototype, prop)?.get; + if (!nativeGetter) { + return; + } + + nativeMethods.set(`${name}#${prop}`, nativeGetter); + + Object.defineProperty(prototype, prop, { + get() { + onMeasure(prop); + + return nativeGetter.call(this); + }, + }); + }); + } + + if ('methods' in causes) { + causes.methods.forEach((method) => { + const nativeMethod = (prototype as any)[method]!; + nativeMethods.set(`${name}#${method}`, nativeMethod); + + // eslint-disable-next-line func-names + (prototype as any)[method] = function (...args: any[]) { + onMeasure(method); + + return nativeMethod.apply(this, args); + }; + }); + } + }); +} + +function clearLayoutDetectors() { + Object.entries(LAYOUT_CAUSES).forEach(([name, causes]) => { + const entity = window[name as Entities]; + const prototype = typeof entity === 'object' ? entity : entity.prototype; + + if ('props' in causes) { + causes.props.forEach((prop) => { + const nativeGetter = nativeMethods.get(`${name}#${prop}`); + if (!nativeGetter) { + return; + } + + Object.defineProperty(prototype, prop, { get: nativeGetter }); + }); + } + + if ('methods' in causes) { + causes.methods.forEach((method) => { + (prototype as any)[method] = nativeMethods.get(`${name}#${method}`)!; + }); + } + }); + + nativeMethods.clear(); +} + +function setupMutationObserver() { + observer = new MutationObserver((mutations) => { + if (phase !== 'mutate') { + mutations.forEach(({ target, type, attributeName }) => { + if (!document.contains(target)) { + return; + } + + if (type === 'childList' && target instanceof HTMLElement && target.contentEditable) { + return; + } + + if (attributeName?.startsWith('data-')) { + return; + } + + // eslint-disable-next-line no-console + onError(new Error(`Unexpected mutation detected: \`${type === 'attributes' ? attributeName : type}\``)); + }); + } + }); + + observer.observe(document.body, { + childList: true, + attributes: true, + subtree: true, + characterData: false, + }); +} + +function clearMutationObserver() { + observer?.disconnect(); + observer = undefined; +} + +function onMeasure(propName: string) { + if (phase !== 'measure') { + onError(new Error(`Unexpected measurement detected: \`${propName}\``)); + } +} diff --git a/src/lib/rlottie/RLottie.ts b/src/lib/rlottie/RLottie.ts index 0facc02b5..bb3234220 100644 --- a/src/lib/rlottie/RLottie.ts +++ b/src/lib/rlottie/RLottie.ts @@ -1,3 +1,5 @@ +import { requestMeasure, requestMutation } from '../fasterdom/fasterdom'; + import type { RLottieApi } from './rlottie.worker'; import { @@ -6,7 +8,6 @@ import { import { createConnector } from '../../util/PostMessageConnector'; import { animate } from '../../util/animation'; import cycleRestrict from '../../util/cycleRestrict'; -import { fastRaf } from '../../util/schedulers'; import generateIdFor from '../../util/generateIdFor'; interface Params { @@ -222,25 +223,29 @@ class RLottie { canvas, ctx, } = containerInfo; + let [canvasWidth, canvasHeight] = [canvas.width, canvas.height]; + if (!canvas.dataset.isJustCleaned || canvas.dataset.isJustCleaned === 'false') { const sizeFactor = this.calcSizeFactor(); - ensureCanvasSize(canvas, sizeFactor); - ctx.clearRect(0, 0, canvas.width, canvas.height); + ([canvasWidth, canvasHeight] = ensureCanvasSize(canvas, sizeFactor)); + ctx.clearRect(0, 0, canvasWidth, canvasHeight); canvas.dataset.isJustCleaned = 'true'; - fastRaf(() => { + requestMeasure(() => { canvas.dataset.isJustCleaned = 'false'; }); } containerInfo.coords = { - x: Math.round((newCoords?.x || 0) * canvas.width), - y: Math.round((newCoords?.y || 0) * canvas.height), + x: Math.round((newCoords?.x || 0) * canvasWidth), + y: Math.round((newCoords?.y || 0) * canvasHeight), }; const frame = this.getFrame(this.prevFrameIndex) || this.getFrame(Math.round(this.approxFrameIndex)); if (frame && frame !== WAITING) { - ctx.drawImage(frame, containerInfo.coords.x, containerInfo.coords.y); + requestMutation(() => { + ctx.drawImage(frame, containerInfo.coords!.x, containerInfo.coords!.y); + }); } } @@ -273,21 +278,28 @@ class RLottie { } } - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d')!; - - canvas.style.width = `${size}px`; - canvas.style.height = `${size}px`; - imgSize = Math.round(size * sizeFactor); - canvas.width = imgSize; - canvas.height = imgSize; + if (!this.imgSize) { + this.imgSize = imgSize; + this.imageData = new ImageData(imgSize, imgSize); + } - container.appendChild(canvas); + requestMutation(() => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; - this.views.set(viewId, { - canvas, ctx, onLoad, + canvas.style.width = `${size}px`; + canvas.style.height = `${size}px`; + + canvas.width = imgSize; + canvas.height = imgSize; + + container.appendChild(canvas); + + this.views.set(viewId, { + canvas, ctx, onLoad, + }); }); } else { if (!container.isConnected) { @@ -297,27 +309,27 @@ class RLottie { const canvas = container; const ctx = canvas.getContext('2d')!; - ensureCanvasSize(canvas, sizeFactor); - imgSize = Math.round(this.params.size! * sizeFactor); + if (!this.imgSize) { + this.imgSize = imgSize; + this.imageData = new ImageData(imgSize, imgSize); + } + + const [canvasWidth, canvasHeight] = ensureCanvasSize(canvas, sizeFactor); + this.views.set(viewId, { canvas, ctx, isSharedCanvas: true, coords: { - x: Math.round((coords?.x || 0) * canvas.width), - y: Math.round((coords?.y || 0) * canvas.height), + x: Math.round((coords?.x || 0) * canvasWidth), + y: Math.round((coords?.y || 0) * canvasHeight), }, onLoad, }); } - if (!this.imgSize) { - this.imgSize = imgSize; - this.imageData = new ImageData(imgSize, imgSize); - } - if (this.isRendererInited) { this.doPlay(); } @@ -556,7 +568,7 @@ class RLottie { } return true; - }); + }, requestMutation); } private getFrame(frameIndex: number) { @@ -597,10 +609,15 @@ class RLottie { function ensureCanvasSize(canvas: HTMLCanvasElement, sizeFactor: number) { const expectedWidth = Math.round(canvas.offsetWidth * sizeFactor); const expectedHeight = Math.round(canvas.offsetHeight * sizeFactor); + if (canvas.width !== expectedWidth || canvas.height !== expectedHeight) { - canvas.width = expectedWidth; - canvas.height = expectedHeight; + requestMutation(() => { + canvas.width = expectedWidth; + canvas.height = expectedHeight; + }); } + + return [expectedWidth, expectedHeight]; } export default RLottie; diff --git a/src/lib/teact/teact.ts b/src/lib/teact/teact.ts index b83b4c989..947f8dd6b 100644 --- a/src/lib/teact/teact.ts +++ b/src/lib/teact/teact.ts @@ -1,11 +1,13 @@ import type { ReactElement } from 'react'; +import { requestMeasure, requestMutation } from '../fasterdom/fasterdom'; + import { DEBUG, DEBUG_MORE } from '../../config'; -import { throttleWithRafFallback } from '../../util/schedulers'; +import { throttleWith } from '../../util/schedulers'; import { orderBy } from '../../util/iteratees'; import { getUnequalProps } from '../../util/arePropsShallowEqual'; -import { handleError } from '../../util/handleError'; 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; @@ -309,15 +311,26 @@ let pendingLayoutEffects = new Map(); let pendingLayoutCleanups = new Map(); let areImmediateEffectsPending = false; -const runUpdatePassOnRaf = throttleWithRafFallback(() => { +/* + 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, () => { areImmediateEffectsPending = true; idsToExcludeFromUpdate = new Set(); - const instancesToUpdate = Array .from(instancesPendingUpdate) .sort((a, b) => a.id - b.id); - instancesPendingUpdate = new Set(); const currentCleanups = pendingCleanups; @@ -328,18 +341,19 @@ const runUpdatePassOnRaf = throttleWithRafFallback(() => { pendingEffects = new Map(); currentEffects.forEach((cb) => cb()); - instancesToUpdate.forEach(prepareComponentForFrame); + requestMutation(() => { + instancesToUpdate.forEach(prepareComponentForFrame); + instancesToUpdate.forEach((instance) => { + if (idsToExcludeFromUpdate!.has(instance.id)) { + return; + } - instancesToUpdate.forEach((instance) => { - if (idsToExcludeFromUpdate!.has(instance.id)) { - return; - } + forceUpdateComponent(instance); + }); - forceUpdateComponent(instance); + areImmediateEffectsPending = false; + runImmediateEffects(); }); - - areImmediateEffectsPending = false; - runImmediateEffects(); }); export function willRunImmediateEffects() { @@ -356,18 +370,13 @@ export function runImmediateEffects() { currentLayoutEffects.forEach((cb) => cb()); } -function scheduleUpdate(componentInstance: ComponentInstance) { - instancesPendingUpdate.add(componentInstance); - runUpdatePassOnRaf(); -} - export function renderComponent(componentInstance: ComponentInstance) { idsToExcludeFromUpdate.add(componentInstance.id); const { Component, props } = componentInstance; - let newRenderedValue; + let newRenderedValue: any; - try { + safeExec(() => { renderingInstance = componentInstance; componentInstance.hooks.state.cursor = 0; componentInstance.hooks.effects.cursor = 0; @@ -414,13 +423,12 @@ export function renderComponent(componentInstance: ComponentInstance) { incrementOverlayCounter(`${componentName} duration`, duration); } } - } catch (err: any) { + }, () => { // eslint-disable-next-line no-console console.error(`[Teact] Error while rendering component ${componentInstance.name}`); - handleError(err); newRenderedValue = componentInstance.renderedValue; - } + }); if (componentInstance.isMounted && newRenderedValue === componentInstance.renderedValue) { return componentInstance.$element; @@ -468,10 +476,8 @@ export function unmountComponent(componentInstance: ComponentInstance) { idsToExcludeFromUpdate.add(componentInstance.id); componentInstance.hooks.effects.byCursor.forEach((effect) => { - try { - effect.cleanup?.(); - } catch (err: any) { - handleError(err); + if (effect.cleanup) { + safeExec(effect.cleanup); } effect.cleanup = undefined; @@ -559,7 +565,8 @@ export function useState(initial?: T, debugKey?: string): [T, StateHookSetter byCursor[cursor].nextValue = newValue; - scheduleUpdate(componentInstance); + instancesPendingUpdate.add(componentInstance); + runUpdatePassOnRaf(); if (DEBUG_MORE) { if (componentInstance.name !== 'TeactNContainer') { @@ -597,81 +604,75 @@ function useEffectBase( const { cursor, byCursor } = renderingInstance.hooks.effects; const componentInstance = renderingInstance; - function execCleanup() { + const runEffectCleanup = () => safeExec(() => { const { cleanup } = byCursor[cursor]; if (!cleanup) { return; } - try { - // 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 = componentInstance.name; - 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`, - ); - } - } - } catch (err: any) { - // eslint-disable-next-line no-console - console.error(`[Teact] Error in effect cleanup at cursor #${cursor} in ${componentInstance.name}`); - handleError(err); + // eslint-disable-next-line @typescript-eslint/naming-convention + let DEBUG_startAt: number | undefined; + if (DEBUG) { + DEBUG_startAt = performance.now(); } - byCursor[cursor].cleanup = undefined; - } + cleanup(); - function exec() { + if (DEBUG) { + const duration = performance.now() - DEBUG_startAt!; + const componentName = componentInstance.name; + 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.isMounted) { return; } - try { - // 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 = componentInstance.name; - if (duration > DEBUG_EFFECT_THRESHOLD) { - // eslint-disable-next-line no-console - console.warn(`[Teact] Slow effect at cursor #${cursor}: ${componentName}, ${Math.round(duration)} ms`); - } - } - } catch (err: any) { - // eslint-disable-next-line no-console - console.error(`[Teact] Error in effect at cursor #${cursor} in ${componentInstance.name}`); - handleError(err); + // 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 = componentInstance.name; + 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, execCleanup); - pendingLayoutEffects.set(effectId, exec); + pendingLayoutCleanups.set(effectId, runEffectCleanup); + pendingLayoutEffects.set(effectId, runEffect); } else { - pendingCleanups.set(effectId, execCleanup); - pendingEffects.set(effectId, exec); + pendingCleanups.set(effectId, runEffectCleanup); + pendingEffects.set(effectId, runEffect); } runUpdatePassOnRaf(); diff --git a/src/lib/teact/teactn.tsx b/src/lib/teact/teactn.tsx index ecfcff61a..6fcd598e8 100644 --- a/src/lib/teact/teactn.tsx +++ b/src/lib/teact/teactn.tsx @@ -1,11 +1,12 @@ /* eslint-disable eslint-multitab-tt/set-global-only-variable */ import type { FC, FC_withDebug, Props } from './teact'; import React, { useEffect, useState } from './teact'; +import { requestMeasure } from '../fasterdom/fasterdom'; import { DEBUG, DEBUG_MORE } from '../../config'; import useForceUpdate from '../../hooks/useForceUpdate'; import generateIdFor from '../../util/generateIdFor'; -import { fastRafWithFallback, throttleWithTickEnd } from '../../util/schedulers'; +import { throttleWithTickEnd } from '../../util/schedulers'; import arePropsShallowEqual, { getUnequalProps } from '../../util/arePropsShallowEqual'; import { orderBy } from '../../util/iteratees'; import { handleError } from '../../util/handleError'; @@ -76,7 +77,7 @@ function runCallbacks() { if (forceOnHeavyAnimation) { forceOnHeavyAnimation = false; } else if (isHeavyAnimating()) { - fastRafWithFallback(runCallbacksThrottled); + requestMeasure(runCallbacksThrottled); return; } diff --git a/src/util/animation.ts b/src/util/animation.ts index 2889e360e..fb3efb9e0 100644 --- a/src/util/animation.ts +++ b/src/util/animation.ts @@ -1,4 +1,5 @@ -import { fastRaf } from './schedulers'; +import type { Scheduler } from './schedulers'; +import { requestMeasure } from '../lib/fasterdom/fasterdom'; interface AnimationInstance { isCancelled: boolean; @@ -6,7 +7,7 @@ interface AnimationInstance { let currentInstance: AnimationInstance | undefined; -export function animateSingle(tick: Function, instance?: AnimationInstance) { +export function animateSingle(tick: Function, schedulerFn: Scheduler, instance?: AnimationInstance) { if (!instance) { if (currentInstance && !currentInstance.isCancelled) { currentInstance.isCancelled = true; @@ -17,24 +18,24 @@ export function animateSingle(tick: Function, instance?: AnimationInstance) { } if (!instance!.isCancelled && tick()) { - fastRaf(() => { - animateSingle(tick, instance); + schedulerFn(() => { + animateSingle(tick, schedulerFn, instance); }); } } -export function animate(tick: Function) { - fastRaf(() => { +export function animate(tick: Function, schedulerFn: Scheduler) { + schedulerFn(() => { if (tick()) { - animate(tick); + animate(tick, schedulerFn); } }); } -export function animateInstantly(tick: Function) { +export function animateInstantly(tick: Function, schedulerFn: Scheduler) { if (tick()) { - fastRaf(() => { - animateInstantly(tick); + schedulerFn(() => { + animateInstantly(tick, schedulerFn); }); } } @@ -94,7 +95,7 @@ export function animateNumber({ } if (t === 1 && onEnd) onEnd(); return t < 1; - }); + }, requestMeasure); return () => { canceled = true; diff --git a/src/util/audioPlayer.ts b/src/util/audioPlayer.ts index 4511e0b9b..56ad6937c 100644 --- a/src/util/audioPlayer.ts +++ b/src/util/audioPlayer.ts @@ -1,4 +1,5 @@ import { getActions, getGlobal } from '../global'; +import { requestNextMutation } from '../lib/fasterdom/fasterdom'; import { AudioOrigin, GlobalSearchContent } from '../types'; import type { ApiMessage } from '../api/types'; @@ -8,7 +9,6 @@ import safePlay from './safePlay'; import { patchSafariProgressiveAudio, isSafariPatchInProgress } from './patchSafariProgressiveAudio'; import type { MessageKey } from '../global/helpers'; import { getMessageKey, parseMessageKey } from '../global/helpers'; -import { fastRaf } from './schedulers'; import { selectCurrentMessageList, selectTabState } from '../global/selectors'; type Handler = (eventName: string, e: Event) => void; @@ -178,11 +178,11 @@ export function register( stop() { if (currentTrackId === trackId) { - // Hack, reset src to remove default media session notification + // Hack, reset `src` to remove default media session notification const prevSrc = audio.src; audio.pause(); - // onPause not called otherwise, but required to sync UI - fastRaf(() => { + // `onPause` not called otherwise, but required to sync UI + requestNextMutation(() => { audio.src = ''; audio.src = prevSrc; }); diff --git a/src/util/captureEvents.ts b/src/util/captureEvents.ts index 669686600..0af6d389a 100644 --- a/src/util/captureEvents.ts +++ b/src/util/captureEvents.ts @@ -2,6 +2,7 @@ import { IS_IOS } from './windowEnvironment'; import { Lethargy } from './lethargy'; import { clamp, round } from './math'; import { debounce } from './schedulers'; +import windowSize from './windowSize'; export enum SwipeDirection { Up, @@ -107,9 +108,10 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) { x: false, y: false, }; + const currentWindowSize = windowSize.get(); let initialTouchCenter = { - x: window.innerWidth / 2, - y: window.innerHeight / 2, + x: currentWindowSize.width / 2, + y: currentWindowSize.height / 2, }; let initialSwipeAxis: TSwipeAxis | undefined; const minZoom = options.minZoom ?? 1; @@ -217,9 +219,10 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) { x: false, y: false, }; + const newWindowSize = windowSize.get(); initialTouchCenter = { - x: window.innerWidth / 2, - y: window.innerHeight / 2, + x: newWindowSize.width / 2, + y: newWindowSize.height / 2, }; captureEvent = undefined; } @@ -289,7 +292,7 @@ export function captureEvents(element: HTMLElement, options: CaptureOptions) { // Avoid conflicts with swipe-to-back gestures if (IS_IOS) { const x = (e as RealTouchEvent).touches[0].pageX; - if (x <= IOS_SCREEN_EDGE_THRESHOLD || x >= window.innerWidth - IOS_SCREEN_EDGE_THRESHOLD) { + if (x <= IOS_SCREEN_EDGE_THRESHOLD || x >= windowSize.get().width - IOS_SCREEN_EDGE_THRESHOLD) { return false; } } diff --git a/src/util/emoji.ts b/src/util/emoji.ts index fcc126089..34ec73f3e 100644 --- a/src/util/emoji.ts +++ b/src/util/emoji.ts @@ -1,4 +1,6 @@ +import { requestMutation } from '../lib/fasterdom/fasterdom'; import EMOJI_REGEX, { removeVS16s } from '../lib/twemojiRegex'; + import withCache from './withCache'; // Due to the fact that emoji from Apple do not contain some characters, it is necessary to remove them from emoji-data @@ -32,8 +34,13 @@ function unifiedToNative(unified: string) { export const LOADED_EMOJIS = new Set(); export function handleEmojiLoad(event: React.SyntheticEvent) { - event.currentTarget.classList.add('open'); + const emoji = event.currentTarget; + LOADED_EMOJIS.add(event.currentTarget.dataset.path!); + + requestMutation(() => { + emoji.classList.add('open'); + }); } export function fixNonStandardEmoji(text: string) { diff --git a/src/util/fastSmoothScroll.ts b/src/util/fastSmoothScroll.ts index b1395baeb..3372bfe04 100644 --- a/src/util/fastSmoothScroll.ts +++ b/src/util/fastSmoothScroll.ts @@ -4,13 +4,15 @@ import { FocusDirection } from '../types'; import { ANIMATION_LEVEL_MIN, - FAST_SMOOTH_MAX_DISTANCE, FAST_SMOOTH_MAX_DURATION, FAST_SMOOTH_MIN_DURATION, + FAST_SMOOTH_MIN_DURATION, + FAST_SMOOTH_MAX_DURATION, + FAST_SMOOTH_MAX_DISTANCE, FAST_SMOOTH_SHORT_TRANSITION_MAX_DISTANCE, } from '../config'; import { IS_ANDROID } from './windowEnvironment'; import { dispatchHeavyAnimationEvent } from '../hooks/useHeavyAnimationCheck'; import { animateSingle } from './animation'; -import { fastRaf } from './schedulers'; +import { requestForcedReflow, requestMutation } from '../lib/fasterdom/fasterdom'; let isAnimating = false; @@ -23,6 +25,35 @@ export default function fastSmoothScroll( forceDirection?: FocusDirection, forceDuration?: number, forceNormalContainerHeight?: boolean, + withForcedReflow = false, +) { + const args = [ + container, + element, + position, + margin, + maxDistance, + forceDirection, + forceDuration, + forceNormalContainerHeight, + ] as const; + + if (withForcedReflow) { + requestForcedReflow(() => measure(...args)); + } else { + requestMutation(measure(...args)); + } +} + +function measure( + container: HTMLElement, + element: HTMLElement, + position: ScrollLogicalPosition | 'centerOrTop', + margin = 0, + maxDistance = FAST_SMOOTH_MAX_DISTANCE, + forceDirection?: FocusDirection, + forceDuration?: number, + forceNormalContainerHeight?: boolean, ) { if ( forceDirection === FocusDirection.Static @@ -71,29 +102,33 @@ export default function fastSmoothScroll( path = Math.min(path, remainingPath); } - if (path === 0) { - return; - } + return () => { + if (currentScrollTop !== scrollFrom) { + container.scrollTop = scrollFrom; + } - const target = scrollFrom + path; + if (path === 0) { + return; + } - if (forceDuration === 0) { - container.scrollTop = target; - return; - } + const target = scrollFrom + path; - isAnimating = true; + if (forceDuration === 0) { + container.scrollTop = target; + return; + } - const absPath = Math.abs(path); - const transition = absPath <= FAST_SMOOTH_SHORT_TRANSITION_MAX_DISTANCE ? shortTransition : longTransition; - const duration = forceDuration || ( - FAST_SMOOTH_MIN_DURATION - + (absPath / FAST_SMOOTH_MAX_DISTANCE) * (FAST_SMOOTH_MAX_DURATION - FAST_SMOOTH_MIN_DURATION) - ); - const startAt = Date.now(); - const onHeavyAnimationStop = dispatchHeavyAnimationEvent(); + isAnimating = true; + + const absPath = Math.abs(path); + const transition = absPath <= FAST_SMOOTH_SHORT_TRANSITION_MAX_DISTANCE ? shortTransition : longTransition; + const duration = forceDuration || ( + FAST_SMOOTH_MIN_DURATION + + (absPath / FAST_SMOOTH_MAX_DISTANCE) * (FAST_SMOOTH_MAX_DURATION - FAST_SMOOTH_MIN_DURATION) + ); + const startAt = Date.now(); + const onHeavyAnimationStop = dispatchHeavyAnimationEvent(); - fastRaf(() => { animateSingle(() => { const t = Math.min((Date.now() - startAt) / duration, 1); const currentPath = path * (1 - transition(t)); @@ -107,8 +142,8 @@ export default function fastSmoothScroll( } return isAnimating; - }); - }); + }, requestMutation); + }; } export function isAnimatingScroll() { @@ -140,10 +175,10 @@ function calculateScrollFrom( return scrollTop; } -function longTransition(t: number) { - return 1 - ((1 - t) ** 5); +function shortTransition(t: number) { + return 1 - ((1 - t) ** 3); } -function shortTransition(t: number) { - return 1 - ((1 - t) ** 3.5); +function longTransition(t: number) { + return 1 - ((1 - t) ** 6.5); } diff --git a/src/util/fastSmoothScrollHorizontal.ts b/src/util/fastSmoothScrollHorizontal.ts index cc818cffc..2783bc97d 100644 --- a/src/util/fastSmoothScrollHorizontal.ts +++ b/src/util/fastSmoothScrollHorizontal.ts @@ -2,6 +2,7 @@ import { getGlobal } from '../global'; import { ANIMATION_LEVEL_MIN } from '../config'; import { animate } from './animation'; +import { requestMutation } from '../lib/fasterdom/fasterdom'; const DEFAULT_DURATION = 300; @@ -12,10 +13,6 @@ export default function fastSmoothScrollHorizontal(container: HTMLElement, left: duration = 0; } - return scrollWithJs(container, left, duration); -} - -function scrollWithJs(container: HTMLElement, left: number, duration: number) { const isRtl = container.getAttribute('dir') === 'rtl'; const { scrollLeft, offsetWidth: containerWidth, scrollWidth, dataset: { scrollId }, @@ -41,44 +38,44 @@ function scrollWithJs(container: HTMLElement, left: number, duration: number) { const target = scrollLeft + path; - if (duration === 0) { - container.scrollLeft = target; - return Promise.resolve(); - } + return new Promise((resolve) => { + requestMutation(() => { + if (duration === 0) { + container.scrollLeft = target; + resolve(); + return; + } - let isStopped = false; - const id = Math.random().toString(); - container.dataset.scrollId = id; - stopById.set(id, () => { - isStopped = true; + let isStopped = false; + const id = Math.random().toString(); + container.dataset.scrollId = id; + stopById.set(id, () => { + isStopped = true; + }); + + container.style.scrollSnapType = 'none'; + + const startAt = Date.now(); + + animate(() => { + if (isStopped) return false; + + const t = Math.min((Date.now() - startAt) / duration, 1); + + const currentPath = path * (1 - transition(t)); + container.scrollLeft = Math.round(target - currentPath); + + if (t >= 1) { + container.style.scrollSnapType = ''; + delete container.dataset.scrollId; + stopById.delete(id); + resolve(); + } + + return t < 1; + }, requestMutation); + }); }); - - container.style.scrollSnapType = 'none'; - - let resolve: VoidFunction; - const promise = new Promise((r) => { - resolve = r; - }); - const startAt = Date.now(); - - animate(() => { - if (isStopped) return false; - - const t = Math.min((Date.now() - startAt) / duration, 1); - - const currentPath = path * (1 - transition(t)); - container.scrollLeft = Math.round(target - currentPath); - - if (t >= 1) { - container.style.scrollSnapType = ''; - delete container.dataset.scrollId; - stopById.delete(id); - resolve(); - } - return t < 1; - }); - - return promise; } function transition(t: number) { diff --git a/src/util/handleError.ts b/src/util/handleError.ts index 5c7af4db7..e9cf0830d 100644 --- a/src/util/handleError.ts +++ b/src/util/handleError.ts @@ -3,12 +3,12 @@ import { throttle } from './schedulers'; import { getAllMultitabTokens } from './establishMultitabRole'; import { IS_MULTITAB_SUPPORTED } from './windowEnvironment'; -window.addEventListener('error', handleErrorEvent); -window.addEventListener('unhandledrejection', handleErrorEvent); - // eslint-disable-next-line prefer-destructuring const APP_ENV = process.env.APP_ENV; +window.addEventListener('error', handleErrorEvent); +window.addEventListener('unhandledrejection', handleErrorEvent); + function handleErrorEvent(e: ErrorEvent | PromiseRejectionEvent) { // https://stackoverflow.com/questions/49384120/resizeobserver-loop-limit-exceeded if (e instanceof ErrorEvent && e.message === 'ResizeObserver loop limit exceeded') { diff --git a/src/util/safeExec.ts b/src/util/safeExec.ts new file mode 100644 index 000000000..a03fe59d2 --- /dev/null +++ b/src/util/safeExec.ts @@ -0,0 +1,20 @@ +import { DEBUG_MORE } from '../config'; +import { handleError } from './handleError'; + +const SAFE_EXEC_ENABLED = !DEBUG_MORE; + +export default function safeExec(cb: Function, rescue?: (err: Error) => void, always?: NoneToVoidFunction) { + if (!SAFE_EXEC_ENABLED) { + return cb(); + } + + try { + return cb(); + } catch (err: any) { + rescue?.(err); + handleError(err); + return undefined; + } finally { + always?.(); + } +} diff --git a/src/util/schedulers.ts b/src/util/schedulers.ts index fe21031ba..ee988e9e8 100644 --- a/src/util/schedulers.ts +++ b/src/util/schedulers.ts @@ -1,6 +1,4 @@ -type Scheduler = - typeof requestAnimationFrame - | typeof onTickEnd; +export type Scheduler = typeof requestAnimationFrame | typeof onTickEnd; export function debounce( fn: F, @@ -64,18 +62,6 @@ export function throttle( }; } -export function fastRafWithFallback(fn: F) { - return fastRaf(fn, true); -} - -export function throttleWithRafFallback(fn: F) { - return throttleWith(fastRafWithFallback, fn); -} - -export function throttleWithRaf(fn: F) { - return throttleWith(fastRaf, fn); -} - export function throttleWithTickEnd(fn: F) { return throttleWith(onTickEnd, fn); } diff --git a/src/util/switchTheme.ts b/src/util/switchTheme.ts index a4f263877..62c32744c 100644 --- a/src/util/switchTheme.ts +++ b/src/util/switchTheme.ts @@ -1,9 +1,11 @@ +import { requestMutation } from '../lib/fasterdom/fasterdom'; + import type { ISettings } from '../types'; import { animate } from './animation'; +import { lerp } from './math'; import themeColors from '../styles/themes.json'; -import { lerp } from './math'; type RGBAColor = { r: number; @@ -39,32 +41,36 @@ const switchTheme = (theme: ISettings['theme'], withAnimation: boolean) => { const startAt = Date.now(); const themeColorTag = document.querySelector('meta[name="theme-color"]'); - document.documentElement.classList.remove(`theme-${isDarkTheme ? 'light' : 'dark'}`); - if (isInitialized) { - document.documentElement.classList.add('no-animations'); - } - document.documentElement.classList.add(themeClassName); - if (themeColorTag) { - themeColorTag.setAttribute('content', isDarkTheme ? '#212121' : '#fff'); - } + requestMutation(() => { + document.documentElement.classList.remove(`theme-${isDarkTheme ? 'light' : 'dark'}`); + if (isInitialized) { + document.documentElement.classList.add('no-animations'); + } + document.documentElement.classList.add(themeClassName); + if (themeColorTag) { + themeColorTag.setAttribute('content', isDarkTheme ? '#212121' : '#fff'); + } - setTimeout(() => { - document.documentElement.classList.remove('no-animations'); - }, ENABLE_ANIMATION_DELAY_MS); + setTimeout(() => { + requestMutation(() => { + document.documentElement.classList.remove('no-animations'); + }); + }, ENABLE_ANIMATION_DELAY_MS); - isInitialized = true; + isInitialized = true; - if (shouldAnimate) { - animate(() => { - const t = Math.min((Date.now() - startAt) / DURATION_MS, 1); + if (shouldAnimate) { + animate(() => { + const t = Math.min((Date.now() - startAt) / DURATION_MS, 1); - applyColorAnimationStep(startIndex, endIndex, transition(t)); + applyColorAnimationStep(startIndex, endIndex, transition(t)); - return t < 1; - }); - } else { - applyColorAnimationStep(startIndex, endIndex); - } + return t < 1; + }, requestMutation); + } else { + applyColorAnimationStep(startIndex, endIndex); + } + }); }; function transition(t: number) { diff --git a/src/util/voiceRecording.ts b/src/util/voiceRecording.ts index d0834a4cf..f59f0a50c 100644 --- a/src/util/voiceRecording.ts +++ b/src/util/voiceRecording.ts @@ -1,4 +1,5 @@ import type { IOpusRecorder } from 'opus-recorder'; +import { requestMeasure } from '../lib/fasterdom/fasterdom'; export type Result = { blob: Blob; duration: number; waveform: number[] }; @@ -96,7 +97,7 @@ function subscribeToAnalyzer(recorder: IOpusRecorder, cb: Function) { cb(volume < MIN_VOLUME ? 0 : volume); - requestAnimationFrame(tick); + requestMeasure(tick); } tick(); diff --git a/src/util/windowSize.ts b/src/util/windowSize.ts index 85b89cb88..a76075b8d 100644 --- a/src/util/windowSize.ts +++ b/src/util/windowSize.ts @@ -1,5 +1,6 @@ import { throttle } from './schedulers'; import { IS_IOS } from './windowEnvironment'; +import { requestMutation } from '../lib/fasterdom/fasterdom'; type IDimensions = { width: number; @@ -29,9 +30,11 @@ export function updateSizes(): IDimensions { } else { height = window.innerHeight; } - const vh = height * 0.01; - document.documentElement.style.setProperty('--vh', `${vh}px`); + requestMutation(() => { + const vh = height * 0.01; + document.documentElement.style.setProperty('--vh', `${vh}px`); + }); return { width: window.innerWidth,