Reanimated и gesture-handler: как это читать

движение≈ 17 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Прошлая статья отправила решение свайпа в настоящий второй поток — Web Worker — и оставила три долга: почему сам Reanimated не попадает в предпросмотр, зачем ему плагин сборки и что такое shared value. Здесь они закрываются. Сама библиотека в этой статье не запускается: её поведение показано записями — выводом сборщика и Babel-плагина на пакетах из приложения автора — и строками её исходников. Исполняется одно: миниатюра shared value поверх второго потока из прошлой статьи. Цель — читать код на Reanimated: видеть, какая функция уедет во второй рантайм, что поедет вместе с ней и почему строка подхода на нём не отстаёт от пальца. Этаж — сторонние библиотеки react-native-reanimated 4.5, react-native-worklets 0.10 и react-native-gesture-handler 2.32: в Expo их ставят отдельно, а плагин сборки Expo подключает сам.

Разделы темы:·

Первая причина — та же, что у пакетов Expo: пример — один файл без npm и сборщика, и у него есть только глобали ReactNative и React. Но это причина песочницы, а не библиотеки: react-native-web — тоже npm-пакет, и в песочницу он попал, потому что его собрали заранее. Попробуем так же собрать Reanimated — тем же esbuild, которым собран рантайм предпросмотра, с той же подменой react-native на react-native-web. Файл app.js — одна строка import "react-native-reanimated";, пакеты — из мобильного приложения автора: Reanimated 4.5.1 и react-native-worklets 0.10.1. Сначала соберём так, как собирают для телефона, — без платформенных расширений:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, esbuild 0.28.2, сборка из папки мобильного приложения автора; хвосты путей pnpm сокращены до «…»
✘ [ERROR] No matching export in "../../node_modules/.pnpm/react-native-web@0.21.2_…/node_modules/react-native-web/dist/index.js" for import "TurboModuleRegistry"
../../node_modules/.pnpm/react-native-reanimated@4.5.1_…/node_modules/react-native-reanimated/src/specs/NativeReanimatedModule.ts:3:9:
3 │ import { TurboModuleRegistry } from 'react-native';
╵ ~~~~~~~~~~~~~~~~~~~
1 error

Сборка встала на строке, знакомой по главе 1: TurboModuleRegistry.get('ReanimatedModule') из src/specs/NativeReanimatedModule.ts. Это нативная половина Reanimated — модуль на C++, который на телефоне поднимает второй рантайм и отдаёт JS ручки к нему. Реестра турбомодулей в react-native-web нет вовсе — это показала probe в статье «Что в этой песочнице настоящее». Теперь соберём так, как собирает веб-версию Expo: с платформенными расширениями, когда файл .web.ts берётся раньше обычного, — тот же резолвер, что в статье главы 2 про файлы платформ:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал, esbuild 0.28.2, сборка из папки мобильного приложения автора
../../../../../../../dev/null 119.8kb
⚡ Done in 1200ms

Собралось. Для веба у Reanimated свои файлы, и в одном из них прямо написано, зачем он заведён:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Значит, точнее так: собранный как для телефона, Reanimated падает на TurboModuleRegistry, а собранный для веба собирается — но это другой Reanimated. В нём нет ровно того, ради чего библиотеку берут и о чём эта статья:

ТелефонВеб-сборка Reanimated
МодульC++, найден через TurboModuleRegistrycreateJSReanimatedModule() — реализация на JS
Второй рантаймUI-рантайм: второй экземпляр JS-движка в главном потокенет: scheduleOnUI вызывает worklet в том же потоке, в ближайшем кадре (листинг threads.ts в прошлой статье)
Shared valueхозяин в UI-рантайме, в JS-потоке — гость (раздел «Shared value» ниже)makeMutableWeb: обычный объект с полем value в том же потоке
Жест gesture-handlerраспознаёт нативный код, колбэки — в UI-рантаймераспознаёт JS-реализация gesture-handler по событиям браузера
Babel-плагинобязателенможно без него, если каждому хуку дать массив зависимостей

Последняя строка таблицы — третья стена. Предпросмотр компилирует пример TypeScript'ом, Babel с плагином worklet-ов в нём не участвует. Веб-сборка Reanimated это переживает, но только если хуку дать массив зависимостей вручную; иначе отладочная сборка бросает прямо в useAnimatedStyle:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Здесь видна вторая работа замыкания: __closure от плагина служит хуку и списком зависимостей. В вебе второго потока нет, и хватает перечислить зависимости руками, как у эффекта из части IV. На телефоне руками не обойтись: без плагина функция не станет worklet-ом и не уедет вовсе. А второго JS-рантайма в том потоке, где рисуется экран, браузер не даёт совсем: там живёт сама страница, а второй поток у неё один — Worker, который рисовать не умеет. Поэтому Reanimated здесь не исполняется: собранный для телефона — не собирается, собранный для веба — показал бы библиотеку без второго рантайма, то есть без предмета статьи.

В своём проекте на Expo всё это ставится одной командой, а плагин сборки подключается сам:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Пресет Expo ищет react-native-worklets среди установленных пакетов и, если нашёл, ставит его плагин в сборку. Поэтому в babel.config.js проекта на Expo плагина обычно не видно, а работает он всегда. И ещё одно требование записано в исходнике Reanimated строкой ошибки: «Reanimated 4 supports only the React Native New Architecture and web.» — старой архитектуры четвёртая версия не поддерживает.

Вот строка подхода из статьи про жесты, переписанная на Reanimated и gesture-handler. Поведение то же: жест берётся только горизонтальный, после 10 точек; строка не уезжает правее нуля; после отпускания пружина везёт её назад или за край, и тогда подход удаляется. Прочитайте её сначала сами: какие функции, по-вашему, будут выполняться не в JS-потоке?

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Директивы "worklet" здесь нет ни одной, а плагин нашёл четыре worklet-а. Вот его настоящий вывод для этого файла — сначала тексты, которые он кладёт в сборку, потом то, во что превратился колбэк onUpdate:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Четыре текста — четыре функции, которые уедут в UI-рантайм: колбэк onEnd (swipeRowTsx1), колбэк onUpdate (swipeRowTsx2), колбэк пружины внутри onEnd (swipeRowTsx3) и функция стиля (swipeRowTsx4). Устройство знакомо по прошлой статье: __closure с внешними именами и __initData.code с текстом, где эти имена читаются из this.__closure. Новое — как плагин их нашёл. Директивы нет, и он узнал функции по месту, куда их передали. Таких мест у плагина список:

Где стоит функцияЧто плагин делает worklet-ом
хуки Reanimatedпервый аргумент useAnimatedStyle, useAnimatedProps, useDerivedValue, useFrameCallback, useAnimatedScrollHandler; оба первых — у useAnimatedReaction
анимацииколбэк окончания: третий аргумент withTiming и withSpring, второй — у withDecay, четвёртый — у withRepeat
переходы между рантаймамипервый аргумент scheduleOnUI, runOnUISync, runOnUIAsync и прежнего runOnUI
жесты gesture-handlerаргумент методов onBegin, onStart, onUpdate, onChange, onEnd, onFinalize, onTouchesDown, onTouchesMove, onTouchesUp, onTouchesCancelled, вызванных у объекта жеста
где угоднофункцию, у которой первой строкой тела стоит "worklet"

Отсюда правило чтения: во втором рантайме выполняется функция, переданная в одно из этих мест, вместе со всем, что она вызывает изнутри. Остальной компонент — вызовы хуков, сборка объекта жеста, разметка — обычный React в JS-потоке. Место решает всё: та же стрелка, переданная в useEffect, осталась бы обычной функцией.

Теперь прочитаем замыкания — что поедет с каждой функцией. У onUpdate одно имя, x. У onEnd пять: WIDTH, x, withSpring, scheduleOnRN и onDelete. Число WIDTH уедет копией, снятой однажды, как порог в прошлой статье. withSpring в исходнике Reanimated сам помечен директивой "worklet" и переезжает целиком. x — shared value, о нём следующий раздел. А onDelete — обычная функция JS из родителя, и перенести её нельзя по той же причине, по которой postMessage не принял функцию: текста в сборке у неё нет, окружение — в чужой куче. Исключения при переносе не будет: react-native-worklets кладёт на её место заглушку — «удалённую функцию». Но вызвать заглушку в UI-рантайме нельзя, она бросает «[Worklets] Tried to synchronously call a Remote Function…» — это вторая задача. Её можно только отдать scheduleOnRN, и тот поставит вызов в очередь JS-потока — как второй поток нашей миниатюры ставил в очередь свой ответ.

И последнее, что видно из вывода: объект жеста собирается в каждой отрисовке, а с ним и worklet-ы. Значит, onDelete в их замыкании — из этой отрисовки. Ловушки второй задачи статьи про жесты — колбэков, навсегда видящих первую отрисовку, — здесь нет: она держалась на PanResponder, созданном однажды.

В замыкании onUpdate лежит x, и x.value = … пишет в него из UI-рантайма, а функция стиля каждый кадр читает оттуда же. Прошлая статья кончилась тем, что копия замыкания снимается однажды: порог, поменянный после первой отправки, до второго потока не доходил. Будь x числом, функция стиля так и видела бы ноль. Shared value устроен иначе: у значения один хозяин, живёт он во втором рантайме, а в замыкание уезжает не значение, а то, по чему хозяина найти.

Соберём это поверх второго потока из прошлой статьи — он лежит в подложке вместе с runOnWorker и worklet. Хозяева живут в памяти второго потока, в таблице hosts; этот поток её не видит, поэтому она только объявлена. put и take уезжают туда текстом и трогают хозяина на месте. Возьмём задачу про порог из настроек из прошлой статьи — теперь порог будет shared value. Прогноз: что ответит второй поток после writeShared, ответа от которой никто не ждал, и что прочтёт этот поток?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Второй ответ — «назад»: второй поток решил по новому порогу. Копия замыкания по-прежнему снята однажды, но в ней { key: "swipe" } — имя хозяина, а число worklet берёт у хозяина при каждом вызове. И запись без await успела раньше решения: сообщения одному потоку выполняются в том порядке, в каком отправлены, и запись встала в очередь первой. Прочитать значение отсюда можно только вопросом хозяину — и ответ пришёл сообщением.

Настоящий shared value в Reanimated 4.5 устроен так же, только хозяин живёт в UI-рантайме, а в JS-потоке — гость. makeMutableNative создаёт его через createShareable(UIRuntimeId, …) из react-native-worklets, а свойство value у гостя описано так:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

__RUNTIME_KIND === 1 — это JS-поток React Native. Запись оттуда — scheduleOnUI: присваивание уезжает к хозяину и выполнится, когда до него дойдёт очередь UI-рантайма, — как наш writeShared. Чтение — единственное место, где Reanimated расходится с миниатюрой. getSync и runOnUISync синхронные: JS-поток останавливается и ждёт, пока UI-рантайм выполнит функцию-читателя и вернёт число (с флагом USE_SYNCHRONIZABLE_FOR_MUTABLES — только когда значение помечено изменённым, иначе отдаётся последнее прочитанное). Миниатюре так нельзя: синхронного ответа у Worker нет, поэтому readShared отдаёт промис.

МиниатюраReanimated 4.5 на телефоне
Где хозяинв памяти Worker, в таблице hostsв UI-рантайме: createShareable(UIRuntimeId, …)
Что в замыкании worklet-а{ key } — имя хозяинагость; в UI-рантайме по нему находится сам хозяин
Запись из JS-потокаwriteShared: сообщение, ответа не ждёмx.value = …: scheduleOnUI, в очередь UI-рантайма
Чтение из JS-потокаreadShared: вопрос и ответ промисомx.value: синхронный вызов, JS-поток ждёт UI-рантайм
Запись во втором рантаймеhosts[key] = … — сразуx.value = … в worklet-е — сразу, на хозяине
ПодписканетaddListener только в UI-рантайме; гость в JS бросает «Adding listeners is only possible on the UI runtime.»

Для чтения кода отсюда три следствия. x.value в JS-потоке — не чтение поля, а синхронная поездка в другой рантайм; поэтому его не читают при отрисовке, и отладочная сборка это ловит — третья задача. Изменение shared value отрисовку не вызывает: React о нём не узнаёт, как не узнавал об Animated.Value. И x.value = withSpring(…) присваивает не число, а анимацию: объект, который хозяин сам выполняет кадр за кадром прямо в UI-рантайме. Прошлую анимацию того же значения присваивание отменяет само — поэтому в строке нет stopAnimation, который версия на PanResponder звала при захвате:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Колбэк отменённой анимации получает false. Для строки подхода это значит: схватили строку, пока пружина везла её за край, — onUpdate присвоил новое значение, пружина отменена, её колбэк пришёл с finished, равным false, и подход не удалится.

Теперь можно ответить на вопрос, которым кончилась статья про жесты. Там строку вёл setValue из колбэка PanResponder, а колбэк выполнялся в JS-потоке: занят поток — строка стоит. Нативный драйвер не выручал: он увозит только описание из своего словаря, а жест на ответчике рождается в JS. Проследим касание в строке на Reanimated.

ШагГдеКто делает
палец сдвинулся; жест проверяет пороги: 10 точек по горизонтали — взять, 10 по вертикали — отказатьсяглавный потокнативный код gesture-handler: RNPanHandler.m на основе UIPanGestureRecognizer на iOS, PanGestureHandler.kt под управлением GestureHandlerOrchestrator.kt на Android
событие жеста приходит колбэкуUI-рантаймобработчик, который GestureDetector получил от Reanimated.useEvent для событий onGestureHandlerEvent и onGestureHandlerStateChange
x.value = Math.min(0, e.translationX)UI-рантаймworklet onUpdate; запись сразу на хозяине
пересчёт стиляUI-рантаймфункция из useAnimatedStyle: Reanimated выполняет её снова, когда меняется shared value, которое она читает
новый transform попадает в видUI-рантаймglobal._updateProps(…) в src/updateProps/updateProps.ts — мимо рендера React

JS-потока в этой цепочке нет ни на одном шаге. Пусть он занят разбором ответа — строка всё равно едет за пальцем. В него попадает только то, что вы туда отправили сами: scheduleOnRN(onDelete) в конце пружины. Если JS-поток занят, подход исчезнет из списка позже, но строка к этому моменту уже за краем, и палец этого не заметит.

Второй шаг видно в исходнике gesture-handler. Раздаёт события колбэкам функция, которая сама — worklet:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Колбэк, который не стал worklet-ом, отсюда не вызвать — он получит только предупреждение. Если нужно наоборот, у жеста есть модификатор .runOnJS(true): тогда колбэки выполняются в JS-потоке, и строка снова ждёт его, как на PanResponder. А если колбэки случайно смешались — часть worklet-ы, часть нет, — отладочная сборка печатает ошибку «Some of the callbacks in the gesture are worklets and some are not…».

PanResponder и Animatedнативный драйверgesture-handler и Reanimated
кто распознаёт жестсистема ответчика в JSжест остаётся на ответчике в JSнативный код в главном потоке
где выполняется код кадраJS-потокнигде: в натив уехало описаниеUI-рантайм
что можно сделать в кадречто угоднотолько узлы словаря: interpolate, сложение, пружина…любой код, который плагин сделал worklet-ом
занятый JS-потокстрока стоитанимация идёт, строка под пальцем стоитстрока едет
как вернуться в JSвсё и так в JSсобытия к слушателямscheduleOnRN

Первая статья главы начиналась с провала: CSS-переход transition в стиле React Native на телефоне не двигает ничего — ключа нет в списке атрибутов, и телефон молча его отбрасывает, а едет полоса только в предпросмотре, силами браузера. В Reanimated 4 этот стиль вернулся. Компоненты Reanimated — Animated.View и всё, что сделано createAnimatedComponent, — понимают в стиле свойства CSS-переходов, и полосу отдыха можно написать почти как в вебе:

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Имена — из src/css/types/transition.ts: transitionProperty, transitionDuration, transitionTimingFunction, transitionDelay, есть и сокращение transition; длительность — число миллисекунд или строка вроде "300ms". Обычному View из react-native эти ключи по-прежнему ничего не говорят. И переход тянет ширину — то, чего не умел нативный драйвер: его список держал только свойства, которые не трогают раскладку. У переходов Reanimated список свой, в C++ (Common/cpp/reanimated/CSS/InterpolatorRegistry.cpp), и width с height в нём есть. Кадры такого перехода считает C++ Reanimated, а не ваш код: читается он так же, как описание нативного драйвера, — данные, отданные один раз.

Осталась сводка: три среды, в которых может оказаться строка подхода.

Предпросмотр учебникаВеб-версия на ExpoТелефон
Reanimatedнет: пример — один файл без сборщикавеб-сборка: модуль на JS вместо C++модуль на C++ и UI-рантайм
workletминиатюра: текст и копия замыкания в Workerобычная функция в том же потоке, в ближайшем кадретекст из сборки и копия замыкания в UI-рантайме
shared valueминиатюра: хозяин в Worker, чтение отсюда промисомобъект с полем value в том же потокехозяин в UI-рантайме, чтение из JS синхронное
жестPanResponder и палец-мышьgesture-handler по событиям браузеранативный распознаватель, колбэки в UI-рантайме
занятый JS-потокстоит всё, кроме Workerстоит всё: поток одинстрока едет, ждёт только scheduleOnRN

На телефоне иначе: колбэки жеста и функция стиля выполняются в UI-рантайме в главном потоке, и занятый JS-поток строку не останавливает. В веб-версии на Expo второго рантайма нет: жест, стиль и ваш код делят один поток. Плавность Reanimated проверяют только на телефоне.

Shared value поверх второго потока — в том виде, в каком он лежит в подложке первой задачи, сразу за вторым потоком из прошлой статьи. Глава на этом кончается, и дальше миниатюра не едет: следующая глава — про нативные модули.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

  • Reanimated в предпросмотре не исполняется: у примера нет сборщика и Babel, собранный как для телефона пакет останавливается на TurboModuleRegistry, а веб-сборка обходится модулем на JS без второго рантайма — worklet там обычная функция, shared value — обычный объект.
  • Директива "worklet" в коде на Reanimated редкость: плагин делает worklet-ами функции по месту — первый аргумент хуков Reanimated, колбэки окончания анимаций, методы жеста вроде onUpdate и onEnd, функции переходов между рантаймами. В Expo SDK 57 плагин подключает babel-preset-expo, если пакет установлен.
  • Обычную функцию JS в UI-рантайм не перенести: в замыкании worklet-а она становится удалённой функцией, синхронный вызов которой бросает. Её отдают scheduleOnRN — вызов встаёт в очередь JS-потока.
  • Shared value — значение с одним хозяином в UI-рантайме; в замыкание уезжает гость, по которому хозяина находят. Запись из JS — scheduleOnUI, чтение из JS — синхронный вызов в UI-рантайм. Отрисовки изменение не вызывает, а новое присваивание отменяет прошлую анимацию.
  • Жест не проседает, потому что JS-потока нет в его цепочке: распознаёт нативный код gesture-handler, событие получает worklet в UI-рантайме, функция стиля пересчитывает transform и отдаёт его виду через _updateProps. В веб-версии на Expo всё это делит один поток.
  • В Reanimated 4 в стиль его компонентов вернулись CSS-переходы — transitionProperty, transitionDuration, — и они тянут даже width, которую нативный драйвер не брал.

1/4Какие функции в строке подхода на Reanimated плагин делает worklet-ами, хотя директивы "worklet" в них нет?

2/4Почему Reanimated не исполняется в предпросмотре учебника?

3/4В JS-потоке выполнили x.value = 5, где x — shared value. Что произошло?

4/4JS-поток на 400 мс занят разбором ответа, а палец тянет строку, написанную на gesture-handler и Reanimated. Что со строкой на телефоне?

Счёт удалённых — у второго потока

В последней задаче прошлой статьи счёт удалённых подходов вернули в этот поток, а второй поток только решал. В Reanimated так не делают: worklet отпускания и решает, и считает сам, записывая shared value, а JS-поток счёт только читает. Сделайте так же. В подложке — второй поток и shared value из статьи: sharedValue и readShared, а hosts — память второго потока. Сейчас счёт скачет, а прочитанный отсюда — ноль.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Число в замыкании едет копией: второй поток прибавляет единицу к своей копии, и она пропадает вместе с вызовом, а переменная этого потока так и остаётся нулём. Счёту нужен один хозяин во втором потоке, а в замыкание — его адрес:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод: 1, 1, 2 и 2. Прибавляет хозяин на месте, поэтому второй вызов видит то, что оставил первый. В Reanimated это const deleted = useSharedValue(0) и deleted.value += 1 внутри onEnd: и чтение, и запись выполняются в UI-рантайме, у хозяина. А deleted.value в JS-потоке прочитается синхронно — без промиса, который понадобился миниатюре.

Строка ушла за край, а подход остался

В строке подхода на Reanimated удаление вызвали прямо в колбэке пружины, без scheduleOnRN. На телефоне длинный свайп увозит строку за край, но подход из списка не исчезает, и в отладочной сборке приходит ошибка react-native-worklets. Найдите причину по выводу плагина из статьи — что лежит в замыкании колбэка — и почините.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Колбэк пружины — worklet: плагин делает им третий аргумент withSpring. В его замыкании onDelete, а это обычная функция JS из родителя. Перенести её в UI-рантайм нельзя, и react-native-worklets переносит заглушку — «удалённую функцию» (в исходнике это cloneNonWorkletFunction в serializable.native.ts). Вызов заглушки в UI-рантайме бросает:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В кавычках — имя функции: в отладочной сборке оно известно, в выпускной там «anonymous». Строка к этому моменту уже за краем — её увезла пружина, а до удаления из списка дело не дошло. Функцию JS из worklet-а не вызывают, а отдают JS-потоку:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Аргументы идут следом: scheduleOnRN(setSets, next). В коде постарше встретится runOnJS(onDelete)() — то же самое в прежней форме; в react-native-worklets 0.10 он помечен «@deprecated Use `scheduleOnRN` instead.».

Секунды отдыха стоят на месте

Таймер отдыха перевели на Reanimated. Отсчёт идёт — кольцо, которое читает left в useAnimatedStyle, убывает, — а текст всё время показывает «90 с». Почему текст стоит и что напечатает отладочная сборка, если компонент перерисуется по другой причине? Сделайте так, чтобы текст менялся раз в секунду, а кольцо осталось в UI-рантайме.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Изменение shared value отрисовку не вызывает: withTiming меняет хозяина в UI-рантайме, и React об этом не узнаёт. Текст отрисован один раз, с тем, что прочиталось при первой отрисовке, — 90. Если компонент перерисуется по другой причине, left.value прочитается снова — синхронной поездкой в UI-рантайм посреди отрисовки, — и Reanimated предупредит: «[Reanimated] Reading from `value` during component render. Please ensure that you don't access the `value` property nor use `get` method of a shared value while React is rendering a component.» На первой отрисовке он молчит, а строгий режим, в котором печатается это предупреждение, включён по умолчанию. Текст держат в состоянии и обновляют, только когда меняется целая секунда:

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Первая функция выполняется в UI-рантайме на каждом изменении left и отдаёт целые секунды, вторая получает новое и прошлое значения и зовёт JS-поток только на смене числа — 90 отрисовок за полторы минуты вместо одной на кадр. Кольцо по-прежнему считает UI-рантайм.