fix(app): pause native status rings in hidden panels (#7423)

Retained panels keep their children mounted, so mount-only registration kept hidden rings subscribed to the shared UI clock. Detach inactive rings and retain a local phase so hidden animated styles also stop receiving updates while visible rings continue.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Pete Keen <pete@petekeen.net>
This commit is contained in:
paseo-bot[bot]
2026-10-19 23:26:01 +02:00
committed by GitHub
co-authored by Pete Keen
parent 90f3cab585
commit 69ef741a73
4 changed files with 208 additions and 7 deletions
@@ -0,0 +1,45 @@
# Native retained status-ring regression
This runs the production native ring and clock inside real retained panels with
Reanimated on Android. A probe consumes the same public rotation hook and counts
UI-runtime value changes. No Reanimated mocks or provider processes are used.
Use an Android development client compatible with the checkout's native dependencies.
In a disposable checkout, temporarily replace `packages/app/index.ts` with:
```tsx
import "./src/polyfills/install-early";
import "./src/styles/unistyles";
import { registerRootComponent } from "expo";
import NativeRingRegression from "./e2e/mobile/status-ring/native";
registerRootComponent(NativeRingRegression);
```
Start Metro in `packages/app`:
```sh
APP_VARIANT=development npx expo start --localhost --port 8096
```
With one Android emulator running, open that bundle:
```sh
agent-device open sh.paseo.debug 'exp+voice-mobile://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A8096' --platform android --session status-ring --metro-port 8096
```
From the repository root:
```sh
node packages/app/e2e/mobile/status-ring/android.mjs status-ring http://127.0.0.1:8096
```
The test checks visible rotation, hidden-panel inactivity while another ring is
visible, inactivity with both panels hidden, and synchronized resumption. Each
sample reads counters on the UI runtime; elapsed time includes device-command
latency. The baseline fails at the first hidden-panel assertion.
Restore the original `packages/app/index.ts`, close the agent-device session,
and stop Metro afterward. The fixture substitutes running rings for a live agent
and toggles retained panels directly; it does not exercise workspace navigation
or measure whole-app CPU usage. It is excluded from routine unit tests because
it requires a native runtime.
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
const [, , session, metroUrl] = process.argv;
assert(session && metroUrl, "Pass an agent-device session and Metro URL serving native.tsx");
function ad(...args) {
return execFileSync("agent-device", ["--session", session, ...args], {
encoding: "utf8",
timeout: 60_000,
});
}
function evaluate(expression) {
const value = ad("cdp", "runtime", "eval", "--expr", expression, "--json").trim();
return value === "undefined" ? undefined : JSON.parse(value);
}
let target;
const deadline = Date.now() + 30_000;
do {
target = ad("cdp", "target", "list", "--url", metroUrl)
.split("\n")
.find((line) => line.includes("sh.paseo.debug"));
if (!target) await delay(250);
} while (!target && Date.now() < deadline);
assert(target, "Open the Android dev client on this Metro bundle first");
ad("cdp", "target", "select", target.split(/\s+/)[0]);
let ready = false;
do {
ready = evaluate("Object.keys(globalThis.__ringReaders ?? {}).length === 2");
if (!ready) await delay(250);
} while (!ready && Date.now() < deadline);
assert(ready, "The two native probes must mount before sampling");
async function sample() {
evaluate("Object.values(globalThis.__ringReaders).forEach(read => read())");
await delay(100);
return JSON.parse(evaluate("JSON.stringify(globalThis.__ringSamples)"));
}
async function observe(label) {
const before = await sample();
await delay(600);
const after = await sample();
const deltas = Object.fromEntries(
Object.keys(before).map((key) => [key, after[key].updates - before[key].updates]),
);
console.log(JSON.stringify({ label, deltas, after }));
return { deltas, after };
}
const visible = await observe("both visible");
assert(visible.deltas.first > 0 && visible.deltas.second > 0);
assert.equal(visible.after.first.rotation, visible.after.second.rotation);
ad("press", 'label="HIDE FIRST"');
await delay(200);
const hidden = await observe("first hidden, second visible");
assert.equal(hidden.deltas.first, 0, "A retained hidden ring must stop UI-thread rotation updates");
assert(hidden.deltas.second > 0, "Visible rings must continue rotating");
ad("press", 'label="HIDE SECOND"');
await delay(200);
const allHidden = await observe("both hidden");
assert.equal(allHidden.deltas.first, 0);
assert.equal(allHidden.deltas.second, 0);
ad("press", 'label="SHOW FIRST"');
ad("press", 'label="SHOW SECOND"');
await delay(200);
const restored = await observe("both shown again");
assert(restored.deltas.first > 0 && restored.deltas.second > 0);
assert.equal(
restored.after.first.rotation,
restored.after.second.rotation,
"Restored rings must share the current phase",
);
console.log("PASS native retained-ring lifecycle");
@@ -0,0 +1,69 @@
import { useCallback, useLayoutEffect, useState } from "react";
import { Button, Text, View } from "react-native";
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
import { RetainedPanel } from "@/components/retained-panel";
import { useStatusRingRotation } from "@/components/status-ring/clock";
import { StatusRing } from "@/components/status-ring";
interface Sample {
updates: number;
rotation: number;
}
declare global {
var __ringSamples: Record<string, Sample>;
var __ringReaders: Record<string, () => void>;
}
function recordSample(name: string, value: { updates: number; rotation: number }) {
globalThis.__ringSamples[name] = value;
}
function Probe({ name }: { name: string }) {
const rotation = useStatusRingRotation();
const updates = useSharedValue(0);
useAnimatedReaction(
() => rotation.value,
() => {
updates.value += 1;
},
);
// Read the real UI runtime, without scheduling a continuous JS observer.
useLayoutEffect(() => {
globalThis.__ringSamples ??= {};
globalThis.__ringReaders ??= {};
globalThis.__ringReaders[name] = () =>
scheduleOnUI(() => {
"worklet";
const sample = { updates: updates.value, rotation: rotation.value };
scheduleOnRN(recordSample, name, sample);
});
return () => {
delete globalThis.__ringReaders[name];
};
}, [name, rotation, updates]);
return <StatusRing />;
}
const containerStyle = { flex: 1, padding: 60, backgroundColor: "white" };
export default function NativeRingRegression() {
const [first, setFirst] = useState(true);
const [second, setSecond] = useState(true);
const toggleFirst = useCallback(() => setFirst((value) => !value), []);
const toggleSecond = useCallback(() => setSecond((value) => !value), []);
return (
<View style={containerStyle}>
<Text>Native retained status rings</Text>
<Button title={first ? "Hide first" : "Show first"} onPress={toggleFirst} />
<Button title={second ? "Hide second" : "Show second"} onPress={toggleSecond} />
<RetainedPanel active={first}>
<Probe name="first" />
</RetainedPanel>
<RetainedPanel active={second}>
<Probe name="second" />
</RetainedPanel>
</View>
);
}
@@ -1,11 +1,13 @@
import { useLayoutEffect } from "react";
import { useLayoutEffect, useState } from "react";
import { makeMutable, type SharedValue, useSharedValue } from "react-native-reanimated";
import { scheduleOnUI } from "react-native-worklets";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { getStatusRingRotation } from "@/components/status-ring/geometry";
const sharedRotation = makeMutable(getStatusRingRotation(Date.now()));
const activeRingCount = makeMutable(0);
const clockRunning = makeMutable(false);
let nextListenerId = 1;
function advanceSharedRotation(): void {
"worklet";
@@ -18,13 +20,21 @@ function advanceSharedRotation(): void {
requestAnimationFrame(advanceSharedRotation);
}
function registerStatusRing(registered: SharedValue<boolean>): void {
function registerStatusRing(
rotation: SharedValue<number>,
registered: SharedValue<boolean>,
listenerId: number,
): void {
"worklet";
if (registered.value) {
return;
}
registered.value = true;
rotation.value = getStatusRingRotation(Date.now());
sharedRotation.addListener(listenerId, (value) => {
rotation.value = value;
});
activeRingCount.value += 1;
if (!clockRunning.value) {
@@ -34,25 +44,31 @@ function registerStatusRing(registered: SharedValue<boolean>): void {
}
}
function unregisterStatusRing(registered: SharedValue<boolean>): void {
function unregisterStatusRing(registered: SharedValue<boolean>, listenerId: number): void {
"worklet";
if (!registered.value) {
return;
}
registered.value = false;
sharedRotation.removeListener(listenerId);
activeRingCount.value -= 1;
}
export function useStatusRingRotation(): SharedValue<number> {
const active = useRetainedPanelActive();
// Hidden retained views must not subscribe their animated styles to the shared clock.
const rotation = useSharedValue(getStatusRingRotation(Date.now()));
const registered = useSharedValue(false);
const [listenerId] = useState(() => nextListenerId++);
useLayoutEffect(() => {
scheduleOnUI(registerStatusRing, registered);
if (!active) return;
scheduleOnUI(registerStatusRing, rotation, registered, listenerId);
return () => {
scheduleOnUI(unregisterStatusRing, registered);
scheduleOnUI(unregisterStatusRing, registered, listenerId);
};
}, [registered]);
}, [active, listenerId, registered, rotation]);
return sharedRotation;
return rotation;
}