diff --git a/.claude/skills/run-and-profile/SKILL.md b/.claude/skills/run-and-profile/SKILL.md index 90b6d07..96af251 100644 --- a/.claude/skills/run-and-profile/SKILL.md +++ b/.claude/skills/run-and-profile/SKILL.md @@ -70,13 +70,31 @@ A/B protocol: Commit/stash first. 2. **Screenshot the baseline arm.** If the old `src/` throws, you're timing a red error screen — cheap to render, looks like a huge win. -3. Relaunch between arms, settle ~20s. Use the default Line tab where possible: - it survives a reload with no navigation, removing a variance source. -4. 3 runs/arm. Within-arm spread should be 2-3 points; much wider means the host +3. Relaunch between arms, settle **~50s**, not 20 — a swapped `src/` makes Metro + re-bundle, and a window opened too early reads 1-5% (the app is still + loading, not cheap). Any run that implausibly low is a settle artifact, not + data. Use the default Line tab where possible: it survives a reload with no + navigation, removing a variance source. +4. **Relaunching resets in-app toggles — check them per arm.** Burned a full + A/B on 2026-07-30: the FPS counter was ON for arm 1 (set in an earlier + session) and OFF for every later arm, because relaunch resets React state + and it defaults off. That is an always-on frame callback present in one arm + and absent in the others — it cost ~15 points and looked exactly like host + drift. The arm screenshots are the check: the control reads `56 fps` when on + and a blank `fps` when off. Confirm every arm matches before comparing. +5. 3 runs/arm. Within-arm spread should be 2-3 points; much wider means the host is too busy to measure on. -5. Re-measure the first arm at the end as a drift control. -6. Log `uptime` per arm — shared 8-core box, has run at load 24. Relative deltas - survive moderate load; absolute numbers don't. +6. Re-measure the first arm at the end as a drift control. +7. Log `uptime` per arm, **at the start AND end of each arm** — a mid-arm climb + is invisible if you only sample once. Shared 8-core box, has run at load 24; + observed 50.7 immediately after a build/install/bundle, draining over + several minutes. Relative deltas survive moderate load; absolute numbers + don't, and a *moving* load voids the comparison outright. +8. **Know your effect size before trusting this method.** Whole-process CPU + cannot resolve a change worth ~1-2k JS allocations/sec — that is inside the + noise on this box (measured null, and slightly negative, for the timeAxis + memoization on 2026-07-30 despite unit tests proving the work was removed). + For allocation-shaped changes, prefer symbol-level sampling over total CPU. Reference (2026-07-27, iPhone 17 Pro sim, Debug, load ~8-11), `main` 60e493e → `perf-hardening` 2fb7ff2: Line 127.8% → 85.4%, Candles 108.5% → 63.5%. diff --git a/CHANGELOG.md b/CHANGELOG.md index 646d360..ef20955 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,164 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Fixed + +- **Native `SkPicture`s are now disposed, not left to GC.** The screen picture + is replaced every recorded frame (~60/sec) and the grid/scroll layer + pictures on every rebuild; each replacement dropped a native picture whose + tiny JS wrapper gave the garbage collector no reason to hurry. Every swap + site now retires the outgoing picture and `dispose()`s it one frame later, + once the tree has composited its replacement — bounding native memory on + exactly the long-running live charts the library targets. +- **Suspended-time credit is race-free.** The wall-clock interval accrued + while the frame loop was suspended was added from the JS thread while the + UI thread drained the same shared value — a fast background/foreground flap + could double-credit an interval and leave a paused chart further in the + past than it ever was. Both writes now run serialized on the UI runtime. +- **Removed series no longer haunt the toggle state.** A series removed from + `series` and later re-added came back hidden, and its dead id still counted + against the "don't hide the last visible series" guard, which could + therefore be defeated into a blank chart. Hidden ids are now pruned when + the series list changes. +- The unrecognized-color dev warning no longer mutates a module-level + binding from the UI runtime (worklet-captured bindings are per-runtime + copies); the dedupe flag lives on the runtime's own global instead. + +### Changed + +- **`formatValue` / `formatTime` must be pure functions of their input.** + Axis and grid label text is cached per tick and re-evaluated when the + formatter's identity changes, not every frame (a per-frame `Date` + + string-format per label was measurable UI-thread churn). Formatters that + read ambient state — relative time, a captured mutable locale — now render + stale text; pass a new function instance to invalidate. Documented in the + README. + +### Added + +- **Accessibility.** The chart is a Skia surface, so a screen reader previously + found an unlabelled blank region. `Liveline` now announces itself as an + `image` (React Native has no chart role) with a default label of + `'Live chart'`, overridable via the new **`accessibilityLabel`** prop, and + exposes the live number through `accessibilityValue` — run through the + consumer's `formatValue`, with the momentum direction appended + (`"64,201.55, rising"`). + + This costs nothing when no screen reader is running. The value is gated on + `AccessibilityInfo.isScreenReaderEnabled()` plus its `screenReaderChanged` + listener; with no reader active there is no sampling timer, no state and no + accessibility value — one boolean test per render, and no UI→JS traffic. The + reading is taken from the props already on the JS thread rather than bridged + from the engine's UI-thread shared value, so the per-frame render path is + untouched and the chart still does not re-render on tick. When a reader *is* + active the value is sampled once a second, and readings that format + identically are skipped so a still chart stays quiet. `formatValue` is + therefore also called on the JS thread, roughly 1Hz, while a reader runs. + + The built-in controls gained labels and selected/checked state — most + importantly the icon-only line/candle toggle, which had no text for a reader + to fall back on. The live-value overlay (`showValue`) is hidden from + assistive tech, since it is a `TextInput` driven at frame rate. + +- **`testID`** prop on the chart container. The built-in controls derive their + own ids from it (`-window-`, `-mode-line`, `-mode-candle`, + `-series-`), so Detox and Maestro can target the chart and drive its + controls. + +### Fixed + +- **`parseColorRgb` (used to derive the palette from the `color` prop) no + longer produces `NaN` channels for 4-digit hex shorthand** (`#rgba`, the + CSS Color 4 shorthand-with-alpha form, e.g. `"#38fc"`). It also now handles + 8-digit hex (`#rrggbbaa`) correctly, and rejects any hex length other than + 3/4/6/8 instead of silently mangling it — both now fall back to the + existing grey default. In development, an unparseable `color` (including + named CSS colors like `"red"`, which were never supported) logs a warning + naming the offending value instead of failing silently into gradients and + paints. See the `color` prop docs in the README for the full list of + supported formats. +- **`LivelineTransition` now warns in development when `active` matches no + child's `key`.** Previously a typo'd or conditionally-absent `active` value + produced a blank chart area with no signal until `active` later changed to + a key that does exist. The warning names both the bad value and the keys + that are actually available. +- **`react-native-gesture-handler` peer range widened to `>=2.30.0`** (was + `>=3.0.0`). The v3 floor existed only because the engine imported a + v3-only type name for a union it never used — the hook always returns a + single `Gesture.Pan()`, so the return type is now just `GestureType`, + which exists in both majors. Expo SDK 55 pins `~2.30.0`, so every SDK 55 + consumer previously hit a peer conflict and an `expo-doctor` failure on + install. Verified by installing 2.30.0 and running the real typecheck, not + by inspection. + +### Changed + +- **Declarative render shell** — the chart now renders as a fixed four-node + Skia tree (`` → `` → ``, plus a sibling + ``) instead of a single picture. The structure never changes after + mount, so the Reanimated mapper that drives it stays entirely on the UI + thread and the "keeps animating while the JS thread is blocked" guarantee + is preserved. This is an architecture change, not a performance one; no + speedup is claimed for it. +- **The single-series line's prefix stroke moved into that scroll layer** — + the spline through all but the last data point is recorded once into its + own `SkPicture` and composited at a horizontal offset via + ``, re-recorded only when the line path cache misses; the + per-frame screen picture strokes only the tail. The fill polygon is + deliberately **not** split (it is one semi-transparent closed shape; every + way of cutting it leaves a seam or a double-darkened column) and is still + drawn whole every frame. Any frame that can't composite the layer at + alpha 1 — the reveal morph, the loading/empty crossfade, scrub dimming, + the degen shake — falls back to drawing the whole line live, because + `drawPicture` ignores `globalAlpha`. Multi-series and candle mode are + untouched and keep drawing the combined path. In accounting terms this + removes roughly one draw call per frame; it was not measurable on device + and no speedup is claimed for it. +- **Time-axis labels no longer re-format every frame** — `formatTime` is a + pure function of a label's key, but the axis called it for every label on + every frame and overwrote each label's text with a byte-identical string + (~360 calls/sec at a 30s window, each allocating a `Date` and three + `padStart` results with the default formatter). It now runs only for a key + seen for the first time; a formatter swap is caught by reference identity + and re-texts live labels in place without disturbing their fades. Also + fixes a churn bug found by the new tests: labels one interval beyond each + edge — deliberately targeted, but at alpha 0 — were deleted and re-created + every single frame forever. Purely internal; per-label alpha, crossfades + and edge fades are unchanged. Worth ~35% of that function's JS time in + isolation, and undetectable on device (the whole time axis is 4.2% of a + core). + +- **The scroll layer's transform now runs at the display's full refresh + rate.** Picture re-recording stays paced at ~60fps, but translating an + already-recorded picture is nearly free, so the `` advances + on every vsync — 120fps on a ProMotion display — while recording cost is + unchanged. On a vsync that pacing skips there is no layout to recompute `dx` + from, so it is linearly extrapolated from the last two recorded frames and + overwritten with the exact value on the next one; a quiescence resume, a + return from background or a JS stall leaves the transform untouched rather + than flinging it. Note the consequence: the prefix moves at 120Hz while the + tail is re-recorded at 60Hz, so on skipped vsyncs they shear by one frame of + scroll — roughly 0.25px on a 10s window, 0.08px on 30s. Sub-pixel, but + unverified: the iOS simulator renders at 60Hz and cannot show it either way. +- **`MIN_FRAME_INTERVAL_MS` and `MAX_SCROLL_EXTRAPOLATION_MS` are coupled** by + two inequalities that are now documented in `engine/constants.ts` and + asserted in `engine/__tests__/constants.test.ts`. Tuning either alone can + silently disable high-refresh scrolling in a way only 120Hz hardware would + reveal. + +### Removed + +- **`src/draw/timeAxisLayer.ts` and its tests** — pure label-selection logic + built for a time-axis scroll layer that was measured, priced and parked + rather than adopted, and which nothing imported. Recoverable from + `302df29` / `82d8f6b` on `perf/scroll-layer-architecture` if the axis ever + moves into a scroll layer. No API change; the module was never exported. +- **Non-existent `android`, `ios`, `cpp`, `*.podspec` and + `react-native.config.js` entries from `files[]`** — this is a JS-only + library. `npm pack` output is unchanged (255 files, 1.3 MB). + ## [0.2.1] - 2026-07-28 ### Changed diff --git a/README.md b/README.md index 96178c9..5f17135 100644 --- a/README.md +++ b/README.md @@ -34,16 +34,21 @@ npm install @shopify/react-native-skia react-native-reanimated react-native-work | `@shopify/react-native-skia` | `>=2.0.0` | | `react-native-reanimated` | `>=4.0.0` | | `react-native-worklets` | `>=0.3.0` | -| `react-native-gesture-handler` | `>=3.0.0` | +| `react-native-gesture-handler` | `>=2.30.0` | Reanimated 4 split its worklets runtime out into the separate `react-native-worklets` package (Reanimated itself declares this as its own peer dependency as of 4.0.0), so it's required alongside Reanimated here too. -This library hasn't been tested against Reanimated 3.x. It's also not tested -against gesture-handler 2.x anymore — this library still uses the classic -`Gesture.Pan()` builder API (unchanged since v2), which gesture-handler 3.x -keeps working but marks deprecated in favor of a new hook-based API; a -migration to the new API is deferred to a future release. +This library hasn't been tested against Reanimated 3.x. + +**gesture-handler 2.30 and 3.x are both supported.** The scrub gesture uses +the classic `Gesture.Pan()` builder API, which exists unchanged in both majors +— 3.x keeps it working but marks it deprecated in favour of a new hook-based +API, and migrating is deferred to a future release. The `>=2.30.0` floor +matters for Expo: SDK 55 pins `~2.30.0`, so a narrower range would put every +SDK 55 app into a peer conflict and an `expo-doctor` failure. Compatibility is +verified by installing 2.30.0 and running the typecheck against it, not by +inspection. ### Reanimated Babel plugin @@ -133,7 +138,7 @@ The component fills its parent container — set a height on the parent. Pass | Prop | Type | Default | Description | |------|------|---------|-------------| | `theme` | `'light' \| 'dark'` | `'dark'` | Color scheme | -| `color` | `string` | `'#3b82f6'` | Accent color — all palette colors derived from this | +| `color` | `string` | `'#3b82f6'` | Accent color — all palette colors derived from this. Accepts `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`, or `rgba()`. Named CSS colors (e.g. `"red"`) are not supported and fall back to grey. | | `grid` | `boolean` | `true` | Y-axis grid lines + labels | | `badge` | `boolean` | `true` | Value pill tracking chart tip | | `badgeVariant` | `'default' \| 'minimal'` | `'default'` | Badge style: accent-colored or white with grey text | @@ -244,6 +249,52 @@ data" empty state is shown. | `onHover` | `(point: HoverPoint \| null) => void` | — | Hover callback with `{ time, value, x, y }` | | `style` | `StyleProp` | — | Container style | +**Accessibility** + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `accessibilityLabel` | `string` | `'Live chart'` | Label read by VoiceOver/TalkBack for the chart | +| `testID` | `string` | — | Test id on the chart container; controls derive ids from it (see below) | + +### Accessibility + +The chart is drawn with Skia, so without help a screen reader finds nothing but +an unlabelled blank region. `Liveline` announces itself as an image (React +Native has no chart role) with `accessibilityLabel`, and exposes the live +number through `accessibilityValue` — formatted with your `formatValue`, with +the momentum direction appended: + +``` +"BTC/USD, image. 64,201.55, rising" +``` + +This costs nothing when no screen reader is running. `Liveline` checks +`AccessibilityInfo.isScreenReaderEnabled()` and subscribes to +`screenReaderChanged`; while no reader is active there is no sampling timer, no +state, and no accessibility value at all. When a reader *is* active the value +is sampled once a second — a screen reader cannot follow 60 updates a second, +and restarts its utterance on every change, so a per-frame feed would read as +an endless stutter. Readings that format identically are skipped, so a still +chart stays quiet. + +Note that `formatValue` is called on the JS thread (about once a second) while +a screen reader is running, in addition to its usual per-frame call on the UI +thread. Keep it free of UI-thread-only dependencies. + +The window pills, line/candle toggle and series chips carry their own labels +and selected/checked state. The icon-only mode toggle reads as "Line chart" and +"Candlestick chart". + +**Test ids.** Give the chart a `testID` and the built-in controls derive theirs +from it, so Detox and Maestro can drive them: + +| Element | Test id | +|---------|---------| +| Chart container | `${testID}` | +| Window pill | `${testID}-window-${secs}` | +| Mode toggle | `${testID}-mode-line`, `${testID}-mode-candle` | +| Series chip | `${testID}-series-${id}` | + ### Charts in lists Each `Liveline` runs its own 60fps UI-thread frame loop, so a long list of @@ -281,10 +332,52 @@ function TickerList({ rows }: { rows: Ticker[] }) { } ``` +#### Hoist object and function props + +`Liveline` is wrapped in `React.memo`, and its props are compared shallowly. A +new object or function identity on every parent render defeats that — and it +costs more than a wasted render. The engine mirrors its config into a shared +value on every commit, and the frame loop's idle detection keys off that +config's object identity, so a chart that receives fresh props each render can +never go idle even when nothing about it has changed. + +This matters most in exactly the list above, where one row's tick re-renders the +whole list. + +```tsx +// ✗ new identity every render — memo always misses, chart never idles + `$${v.toFixed(2)}`} +/> + +// ✓ hoisted to module scope (or useMemo / useCallback if they depend on props) +const PADDING = { top: 8, right: 60, bottom: 24, left: 8 }; +const WINDOWS = [{ secs: 30, label: '30s' }, { secs: 60, label: '1m' }]; +const formatUsd = (v: number) => { + 'worklet'; + return `$${v.toFixed(2)}`; +}; + + +``` + +`data` and `value` are expected to change — that is the live feed, and the +engine diffs `data` rather than re-sending it. It is the *configuration* props +that should be stable. + +Note that `formatValue` / `formatTime` run on the UI thread and need the +`'worklet'` directive, which also makes hoisting them the natural choice. + ### `LivelineTransition` Cross-fades between chart components (e.g. line ↔ candlestick). Children must -have unique `key` props matching possible `active` values. +have unique `key` props matching possible `active` values. If `active` doesn't +match any child's key, nothing renders (there is no visible chart until +`active` changes to a valid key) — in development this logs a warning naming +the bad value and the keys that are available. | Prop | Type | Default | Description | |------|------|---------|-------------| @@ -396,9 +489,9 @@ have unique `key` props matching possible `active` values. This port keeps the same SDK shape as [liveline](https://github.com/benjitaylor/liveline) but a few props differ because of the native/worklet environment: -- **`formatValue` / `formatTime` must be worklets.** They run every frame on - the UI thread, not the JS thread. Add the `'worklet'` directive as the - first line of the function: +- **`formatValue` / `formatTime` must be worklets.** They run on the UI + thread, not the JS thread. Add the `'worklet'` directive as the first line + of the function: ```tsx framestats`. **On this app it +does not measure the thing you care about** — see §3. It is documented here so +the next person does not have to rediscover that, but the primary metric is +per-thread CPU (§4) and the per-frame timeline comes from Perfetto (§5). + +--- + +## 1. Preconditions — check both, every session + +Android measurement on this project has been invalidated twice. Both causes are +invisible in the numbers: you get a plausible-looking result that is simply +wrong. Run both checks and record the output alongside every number. + +### 1a. GPU acceleration is actually on + +The `liveline_test` AVD has **`hw.gpu.enabled=no`** persisted in its config +(still true as of 2026-07-30), which software-renders everything. + +```bash +grep hw.gpu ~/.android/avd/liveline_test.avd/config.ini +``` + +**The launch flag overrides the config file** — verified — so the cheap fix is +to always pass `-gpu host`: + +```bash +emulator -avd liveline_test -gpu host -no-snapshot -no-boot-anim & +``` + +Permanent fix, if you prefer not to remember the flag — edit +`~/.android/avd/liveline_test.avd/config.ini` while the emulator is **not** +running: set `hw.gpu.enabled=yes` and `hw.gpu.mode=host`. + +Either way, **verify from the guest** rather than trusting the flag. Do not use +`getprop ro.hardware.egl` — it reads `emulation` in both the accelerated and the +software case and will happily confirm a broken setup. The renderer string is +the real signal: + +```bash +adb shell dumpsys SurfaceFlinger | grep GLES +``` + +Good (host GPU) — names real hardware. Actual output from a correct boot: + +``` +GLES: Google (Apple), Android Emulator OpenGL ES Translator (Apple M2), OpenGL ES 3.0 (4.1 Metal - 90.5) +``` + +Bad — if the string contains **SwiftShader**, or otherwise names no real GPU, +you are software-rendering. Stop; do not record a number. + +### 1c. Make sure the app is talking to the Metro you think it is + +Cost roughly an hour on 2026-08-01. Symptoms looked exactly like a code bug: a +red screen reading `Unable to resolve module +@babel/runtime/helpers/createForOfIteratorHelperLoose from src/engine/state.ts`, +naming a file edited that same day and a `for...of` construct added that same +day. It was neither. + +Two things conspire here: + +- **`adb reverse` does not apply.** The app connects to `10.0.2.2:`, + which is the emulator's NAT route to the *host*. `adb reverse` maps *device* + localhost to the host, so it is bypassed entirely. Mapping `tcp:8082` did + nothing. +- **A stale Metro can hold the port.** There was a second, older Metro on host + 8082 whose module map had been corrupted earlier (its own error was + `Unable to resolve module expo`). Android hits 8082; iOS was on 8083 and + perfectly healthy. Same repo, same files, one platform broken. + +The diagnostic that settles it in one command — ask each server directly, +which removes the emulator, the app's cached error screen and your assumptions +from the picture: + +```bash +for port in 8081 8082 8083; do + printf "%s: " $port + curl -s -o /tmp/b.txt -w "%{http_code}\n" -m 240 \ + "http://localhost:$port/.expo/.virtual-metro-entry.bundle?platform=android&dev=true&lazy=true&app=liveline.example" +done +``` + +200 with ~10MB means that server is healthy. A 500 prints a JSON body naming +the real failure. Do this BEFORE reading anything into a red screen. And check +for strays: `lsof -nP -iTCP:8082 -sTCP:LISTEN`. + +Generalised: an error naming a file you just edited is not evidence that your +edit caused it. + +### 1b. Host load + +This is a **shared** machine and has been measured at load average 24. Relative +deltas survive moderate load; absolute numbers do not. + +```bash +uptime +``` + +Log it **per arm**. Under ~8 on the 8-core box is workable; above that, +within-arm spread widens and the comparison stops meaning anything. If the two +arms ran at meaningfully different load the comparison is void — re-run, don't +try to correct for it. + +--- + +## 2. Build and install the release APK + +Release matters: Debug inflates the Hermes share and adds dev-mode overhead, so +a Debug A/B overstates JS cost and understates a Skia win. Release also embeds +the JS bundle, so **no Metro server and no `adb reverse`** — one less variable +between arms. + +`example/android/app/build.gradle` reads `android.enableMinifyInReleaseBuilds` +(defaults **false**) and the `release` buildType is already signed with the +**debug keystore**, so this needs no new signing setup. + +```bash +cd example +npm run build:android:release # gradlew assembleRelease +``` + +Verified: `BUILD SUCCESSFUL in 1m 16s` (warm Gradle cache), producing +`example/android/app/build/outputs/apk/release/app-release.apk` (~115 MB, +unminified). + +Two setup traps, both hit on first run: + +- **`example/android/` is generated by `expo prebuild` and is not tracked by + git**, so it is missing in a fresh worktree. Build from the main checkout, or + run `npx expo prebuild -p android` first. +- **`Unable to locate a Java Runtime`** — the Homebrew JDK is installed but not + symlinked into `/Library/Java/JavaVirtualMachines`, so `/usr/libexec/java_home` + cannot see it and Gradle dies. Export it explicitly: + + ```bash + export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home + ``` + +Install and launch: + +```bash +adb install -r example/android/app/build/outputs/apk/release/app-release.apk +adb shell am start -n liveline.example/.MainActivity +``` + +Use `am start`. The `adb shell monkey -p liveline.example ...` form quoted in +the iOS skill **fails on this release build** (exits 251, no process starts). + +Let it settle ~20s before measuring. Use the default **Line** tab where +possible: it survives a relaunch with no navigation, removing a variance source. + +**Screenshot the baseline arm.** If the old `src/` throws, you are timing a red +error screen — cheap to render, looks like a huge win. + +--- + +## 3. `gfxinfo framestats` — measured, and blind to this app + +Recorded here as a negative result. **Do not use it as the A/B metric.** + +```bash +adb shell dumpsys gfxinfo liveline.example reset # counters are cumulative +# ... let the chart animate ~10s ... +adb shell dumpsys gfxinfo liveline.example framestats > /tmp/fs.csv +``` + +Actual measurement, release build, chart animating, 103 clean frames: + +``` +total n=103 p50= 138.46ms p95= 302.42ms +ui_record n=103 p50= 0.10ms p95= 1.39ms +render n=103 p50= 23.00ms p95= 47.97ms +gpu n=103 p50= 22.51ms p95= 47.97ms +``` + +**`ui_record` (`SyncQueued - DrawStart`) is 0.10ms.** That is the HWUI +display-list record pass — and it is empty. Liveline's Skia rendering does not +go through it. `dumpsys SurfaceFlinger --list` shows no separate SurfaceView +layer for the app, so react-native-skia is presenting an externally-updated +texture into the window: HWUI composites one quad (0.1ms) while all the engine +work happens on a thread framestats never samples. + +The other columns are equally untrustworthy here. `total` is measured from +`IntendedVsync`, so for a *paced* app it mostly counts time the app deliberately +spent not drawing — 138ms is the pacing, not the cost. The summary histogram +reports `95th gpu percentile: 4950ms`, an obvious emulator artifact. And +`Janky frames: 85.92%` is a consequence of pacing to ~60 while HWUI expects +every vsync, not evidence of a problem. + +If you ever need to check whether framestats has become useful (e.g. after a +react-native-skia rendering-path change), the test is: capture with the chart +animating and again with it idle. **If `ui_record` p50 does not differ, it is +still blind.** Parse by column *name* — this Android 34 build emits 23 columns +(`FrameTimelineVsyncId`, `FrameDeadline`, `FrameInterval`, `FrameStartTime`, +`CommandSubmissionCompleted` … are interleaved into the classic layout), so +fixed indices silently read the wrong fields: + +```python +import sys, statistics +hdr, rows = None, [] +for line in open(sys.argv[1]): + line = line.strip() + if line.startswith("Flags,"): + hdr = [c for c in line.split(",") if c]; continue + if hdr is None or not line[:1].isdigit(): continue + p = line.split(",") + if len(p) < len(hdr): continue + r = dict(zip(hdr, [int(x) for x in p[:len(hdr)]])) + if r["Flags"] != 0: continue # non-zero = atypical frame, drop + rows.append(r) +m = {"total": lambda r: r["FrameCompleted"] - r["IntendedVsync"], + "ui_record": lambda r: r["SyncQueued"] - r["DrawStart"], + "render": lambda r: r["FrameCompleted"] - r["IssueDrawCommandsStart"], + "gpu": lambda r: r["GpuCompleted"] - r["IssueDrawCommandsStart"]} +for name, fn in m.items(): + xs = sorted(fn(r) / 1e6 for r in rows) + p95 = xs[min(len(xs) - 1, int(.95 * len(xs)))] + print(f"{name:10s} n={len(xs):3d} p50={statistics.median(xs):7.2f}ms p95={p95:7.2f}ms") +``` + +All values are absolute nanosecond timestamps on `CLOCK_MONOTONIC` — you +subtract to get durations. `DequeueBufferDuration` / `QueueBufferDuration` are +the exceptions, already durations. framestats keeps only the **last 120 frames**, +so sample close to the activity you care about. + +--- + +## 4. Primary metric — per-thread CPU + +This is where Liveline's cost actually shows up. Ports directly from Part 2 of +the iOS skill; read `utime + stime` (fields 14 and 15 of `/proc//stat`, in +clock ticks, 100/sec) instead of `ps -o cputime`. + +```bash +PID=$(adb shell pidof liveline.example | tr -d '\r') +cpu(){ adb shell cat /proc/$PID/stat | awk '{print $14+$15}'; } +A=$(cpu); sleep 20; B=$(cpu) +echo "$A $B" | awk '{printf "%.1f%%\n", ($2-$1)/100/20*100}' # % of one core +``` + +\>100% is normal (main + RenderThread + JS threads). + +**Per-thread is more diagnostic than the process total**, because it separates +the engine from the example app's own data generation: + +```bash +adb shell 'for t in /proc/'$PID'/task/*; do + echo "$(cat $t/comm) $(awk "{print \$14+\$15}" $t/stat)"; done' | sort -k2 -rn | head +``` + +Measured shortly after launch (cumulative ticks, release build): + +| thread | ticks | what it is | +|---|---|---| +| `iveline.example` | 7989 | **main** — the Reanimated UI runtime + Skia draw. This is the engine. | +| `mqt_v_js` | 3836 | RN JS thread — largely the example app's data generation, not library cost. | +| `RenderThread` | 2053 | HWUI composite. | +| `hades` (x2) | 749 | Hermes GC. | + +Note `comm` is truncated to 15 chars, so the main thread reads +`iveline.example` — match on that, not on the full package name. + +This split corroborates the simpleperf reference already recorded in the iOS +skill (main 44.8%, `mqt_v_js` 35.6%, RenderThread 14.0%, hades 4.5%). +**A rendering change should move the main thread**; if your delta shows up in +`mqt_v_js` instead, you changed the example app's workload, not the library. + +### Derived per-frame cost + +`gfxinfo` is blind to the Skia work but its frame *counter* is still a valid +count of presented frames, so you can turn the CPU number into a per-frame one: + +```bash +adb shell dumpsys gfxinfo liveline.example reset +A=$(cpu); sleep 20; B=$(cpu) +FRAMES=$(adb shell dumpsys gfxinfo liveline.example | awk '/Total frames rendered/{print $4}') +echo "$A $B $FRAMES" | awk '{printf "%.2f ms cpu/frame\n", ($2-$1)*10/$3}' +``` + +Because the frame rate is pinned by pacing, this is close to a pure ratio of +the CPU numbers — treat it as a readability convenience, not independent evidence. + +### Symbol-level attribution + +Use **simpleperf** — already documented and verified in +`.claude/skills/run-and-profile/SKILL.md` §"Android — simpleperf", including the +three traps (`-e cpu-clock` required on emulators, `perf_harden` needs +`adb root`, sort key is `comm` not `thread`). + +--- + +## 5. Perfetto — the per-frame timeline + +Use when you need real per-frame durations rather than an averaged CPU rate. +Unlike gfxinfo it samples **every thread**, so it sees the Skia work. + +```bash +adb shell perfetto -o /data/misc/perfetto-traces/trace.pftrace -t 15s \ + sched freq idle am wm gfx view binder_driver +adb pull /data/misc/perfetto-traces/trace.pftrace /tmp/ +``` + +Verified working on this emulator (8s run → 13 MB trace). Two notes: pass +`adb shell -t` if you want Ctrl+C to stop the trace gracefully, and drop `hal` +from the category list — the run above used the categories exactly as shown. + +Open at . What to read, in priority order: + +1. **The main thread's slices** during the capture — this is the engine, per §4. + Its per-frame slice duration is the number a rendering refactor should move. +2. The **Frame Timeline** track (`ActualFrameTiming` /` ExpectedFrameTiming`) + for per-frame actual vs expected duration with jank classification. Interpret + its jank flags with the pacing caveat from §3 in mind. + +Report **p50 as the headline** (typical per-frame cost) and **p95 alongside** +(catches GC pauses and spike regressions). **Never report the mean** — +frame-time distributions are long-tailed and one GC pause moves the mean while +telling you nothing about typical cost. + +--- + +## 6. A/B protocol + +Mirrors the iOS protocol. The discipline is the point — skipping a step is how +the two earlier invalidated results happened. + +1. **Swap only `src/`.** `git checkout -- src/`, restore with + `git checkout HEAD -- src/`. Holding `example/` constant isolates the + library. Commit or stash first. +2. **Rebuild the release APK for each arm.** Unlike the iOS Debug flow there is + no Metro to pick up `src/` changes — the bundle is embedded, so an unbuilt + arm silently measures the *other* arm's code. This is the easiest way to + produce a fake null result. (Only the JS bundle changes, so rebuilds are + ~1min warm, not a full native build.) +3. **Screenshot the baseline arm** before measuring (§2). +4. Relaunch between arms, settle ~20s. Prefer the default Line tab. +5. **N = 3 runs per arm.** Within-arm spread should be a few percent; much wider + means the host is too busy to measure on — stop and come back later. +6. **Re-measure the first arm at the end** as a drift control. If arm-A-final + does not land within the spread of arm-A-initial, the run is void: something + drifted (thermal, host load, emulator state) and your "delta" is that drift. +7. **Log `uptime` per arm** (§1b), and the `dumpsys SurfaceFlinger | grep GLES` + line once per emulator boot (§1a). Record both with the numbers. + +### Reporting template + +``` +date, commit-A -> commit-B, device (emulator liveline_test / hardware model), +build type (release), GLES renderer string, load per arm + +arm n main-thread CPU % process CPU % p50 frame ms (perfetto) +A (base) 3 +B (change) 3 +A (drift) 3 +``` + +State what the measurement does **not** establish. For the emulator: it is a +gfxstream/Metal translation layer, not a real Adreno/Mali GL driver, so GPU-side +numbers do not transfer to hardware; and it measures the whole pipeline, so a +delta cannot be attributed to one change without simpleperf's breakdown. +**Release on physical Android hardware remains the standing open item.** diff --git a/eslint.config.mjs b/eslint.config.mjs index 16b00bb..80ab553 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,6 +24,10 @@ export default defineConfig([ }, }, { - ignores: ['node_modules/', 'lib/'], + // `.claude/` holds agent worktrees, each a full copy of the repo. Without + // this, `yarn lint` reports errors from files that are not in the working + // tree at all — the same trap `jest.modulePathIgnorePatterns` covers in + // package.json. Git already excludes the path via .git/info/exclude. + ignores: ['node_modules/', 'lib/', '.claude/'], }, ]); diff --git a/example/eas.json b/example/eas.json new file mode 100644 index 0000000..0a1f2b0 --- /dev/null +++ b/example/eas.json @@ -0,0 +1,30 @@ +{ + "cli": { + "version": ">= 21.0.0", + "appVersionSource": "remote" + }, + "build": { + "preview": { + "distribution": "internal", + "channel": "preview", + "android": { + "buildType": "apk" + }, + "ios": { + "simulator": false + } + }, + "preview-sim": { + "distribution": "internal", + "ios": { + "simulator": true + } + }, + "production": { + "autoIncrement": true + } + }, + "submit": { + "production": {} + } +} diff --git a/example/package.json b/example/package.json index 2b95a00..6dffc4c 100644 --- a/example/package.json +++ b/example/package.json @@ -9,6 +9,7 @@ "web": "expo start --web", "build:ios": "xcodebuild ONLY_ACTIVE_ARCH=YES -workspace ios/LivelineExample.xcworkspace -UseNewBuildSystem=YES -scheme LivelineExample -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build -quiet", "build:android": "cd android && ./gradlew assembleDebug -DtestBuildType=debug -Dorg.gradle.jvmargs=-Xmx4g", + "build:android:release": "cd android && ./gradlew assembleRelease -Dorg.gradle.jvmargs=-Xmx4g", "build:web": "expo export --platform web" }, "dependencies": { diff --git a/package.json b/package.json index 09fa39d..d149d6a 100644 --- a/package.json +++ b/package.json @@ -16,17 +16,6 @@ "files": [ "src", "lib", - "android", - "ios", - "cpp", - "*.podspec", - "react-native.config.js", - "!ios/build", - "!android/build", - "!android/gradle", - "!android/gradlew", - "!android/gradlew.bat", - "!android/local.properties", "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", @@ -44,7 +33,8 @@ "preset": "react-native", "modulePathIgnorePatterns": [ "/example/node_modules", - "/lib/" + "/lib/", + "/.claude/" ] }, "keywords": [ @@ -96,7 +86,7 @@ "@shopify/react-native-skia": ">=2.0.0", "react": "*", "react-native": "*", - "react-native-gesture-handler": ">=3.0.0", + "react-native-gesture-handler": ">=2.30.0", "react-native-reanimated": ">=4.0.0", "react-native-worklets": ">=0.3.0" }, diff --git a/src/Liveline.tsx b/src/Liveline.tsx index b461593..664869e 100644 --- a/src/Liveline.tsx +++ b/src/Liveline.tsx @@ -1,5 +1,12 @@ -/* eslint-disable react-native/no-inline-styles -- control styles are theme/prop-derived */ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; import { Platform, Pressable, @@ -10,6 +17,7 @@ import { } from 'react-native'; import { Canvas, + Group, Picture, Path, Line, @@ -24,10 +32,20 @@ import Animated, { withTiming, } from 'react-native-reanimated'; import { GestureDetector } from 'react-native-gesture-handler'; -import type { LivelineProps, Momentum, DegenOptions } from './types'; +import type { + LivelineProps, + Momentum, + DegenOptions, + WindowOption, +} from './types'; import { resolveTheme, resolveSeriesPalettes, SERIES_COLORS } from './theme'; import { makeDefaultFonts } from './draw/fonts'; import { useLivelineEngine } from './useLivelineEngine'; +import { resolveLiveValue } from './a11y/announce'; +import { + useAccessibleValue, + useScreenReaderEnabled, +} from './a11y/useAccessibleValue'; const AnimatedTextInput = Animated.createAnimatedComponent(TextInput); @@ -55,6 +73,181 @@ interface BtnLayout { width: number; } +/** + * Per-button on-screen frames, keyed by the button's own selector value + * (window seconds, mode name, …), for a `PillBar`'s sliding indicator to + * track. Shared by `WindowBar` and `ModeBar` — only the key type differs. + */ +function useBtnLayouts() { + const [layouts, setLayouts] = useState>( + {} as Record + ); + const onBtnLayout = useCallback((key: K, e: LayoutChangeEvent) => { + const { x, width } = e.nativeEvent.layout; + setLayouts((prev) => { + const cur = prev[key]; + if (cur && cur.x === x && cur.width === width) return prev; + return { ...prev, [key]: { x, width } }; + }); + }, []); + return [layouts, onBtnLayout] as const; +} + +/** + * `useMemo` keyed by an explicit signature instead of a dependency array. + * + * A live chart re-renders on every tick, and consumers build most array props + * inline, so those arrays are a new *identity* every tick even when nothing in + * them changed. Projecting them through here gives the control bars props that + * are referentially stable, which is what lets their `memo` actually bail out. + */ +function useStableValue(signature: string, build: () => T): T { + const ref = useRef<{ signature: string; value: T } | null>(null); + if (ref.current === null || ref.current.signature !== signature) { + ref.current = { signature, value: build() }; + } + return ref.current.value; +} + +/** + * Signature string for `useStableValue`, computed from an array's contents. + * Skips recomputing (and the per-render `.map`/`.join` allocation) unless + * the array's reference — or, as a mutate-in-place guard, its length — + * actually changed. Uses ' ' as both the field and entry delimiter so no + * label/id value can forge a collision with an adjacent field. + */ +function useArraySignature( + arr: readonly T[] | undefined, + fields: (item: T) => Array +): string { + const cache = useRef<{ + arr: readonly T[]; + length: number; + sig: string; + } | null>(null); + if (!arr) return ''; + if ( + cache.current && + cache.current.arr === arr && + cache.current.length === arr.length + ) { + return cache.current.sig; + } + // \x00 / \x01 delimiters, NOT spaces: labels are consumer strings and can + // contain any printable character, so only control bytes make entry and + // field boundaries unforgeable — `[{label: 'a b'}]` must never collide + // with `[{label: 'a'}, {label: 'b'}]`. + const sig = `${arr.length}|${arr + .map((item) => fields(item).join('\x00')) + .join('\x01')}`; + cache.current = { arr, length: arr.length, sig }; + return sig; +} + +/** + * A referentially stable wrapper around an optional consumer callback. Same + * reasoning as `useStableValue`: `onModeChange` &co. are typically inline + * arrows, so they are a new function on every consumer render. The wrapper is + * created once and always dispatches to the latest prop. + */ +function useStableHandler( + fn: ((...args: A) => void) | undefined +) { + const ref = useRef(fn); + ref.current = fn; + return useCallback((...args: A) => ref.current?.(...args), []); +} + +/** + * State for the built-in window selector. Only meaningful when `windows` is + * supplied; otherwise the `window` prop stays authoritative and this state + * sits inert (kept mounted so it survives `windows` appearing later). + */ +function useActiveWindow( + windows: WindowOption[] | undefined, + windowSecs: number, + onWindowChange: ((secs: number) => void) | undefined +) { + const [activeWindowSecs, setActiveWindowSecs] = useState( + windows && windows.length > 0 ? windows[0]!.secs : windowSecs + ); + const notify = useStableHandler(onWindowChange); + const selectWindow = useCallback( + (secs: number) => { + setActiveWindowSecs(secs); + notify(secs); + }, + [notify] + ); + return { + activeWindowSecs, + effectiveWindowSecs: windows ? activeWindowSecs : windowSecs, + selectWindow, + }; +} + +/** Which series the user has toggled off, plus the engine-facing id list. */ +function useHiddenSeries( + seriesIds: string[], + onSeriesToggle: ((id: string, visible: boolean) => void) | undefined +) { + const [hiddenSeries, setHiddenSeries] = useState>(new Set()); + const notify = useStableHandler(onSeriesToggle); + // Read through a ref so the toggle handler stays referentially stable as + // series come and go — it crosses the `memo` boundary into the chip bar. + const idsRef = useRef(seriesIds); + idsRef.current = seriesIds; + + // Drop hidden ids for series that left the `series` prop. Without this a + // removed-then-re-added series comes back hidden, and stale ids would + // otherwise inflate `next.size` below, letting the last-visible guard be + // defeated by ids that no longer count as series at all. + // \x00 delimiter for the same reason as useArraySignature: ids are + // consumer strings, so any printable delimiter can be forged into a + // collision that would suppress the prune. + const idsSignature = seriesIds.join('\x00'); + useEffect(() => { + const live = new Set(idsRef.current); + setHiddenSeries((prev) => { + let changed = false; + const next = new Set(); + for (const id of prev) { + if (live.has(id)) next.add(id); + else changed = true; + } + return changed ? next : prev; + }); + }, [idsSignature]); + + const toggleSeries = useCallback( + (id: string) => { + setHiddenSeries((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + notify(id, true); + } else { + // Count only live ids — don't hide the last visible series + const liveHiddenCount = idsRef.current.reduce( + (n, sid) => n + (next.has(sid) ? 1 : 0), + 0 + ); + const visibleCount = idsRef.current.length - liveHiddenCount; + if (visibleCount <= 1) return prev; + next.add(id); + notify(id, false); + } + return next; + }); + }, + [notify] + ); + + const hiddenSeriesIds = useMemo(() => [...hiddenSeries], [hiddenSeries]); + + return { hiddenSeries, hiddenSeriesIds, toggleSeries }; +} + /** Sliding indicator behind the active button in a pill bar. */ function SlidingIndicator({ layout, @@ -87,23 +280,168 @@ function SlidingIndicator({ opacity: ready.value, })); - const inset = rounded ? 3 : 2; + const chrome = useMemo(() => { + const inset = rounded ? 3 : 2; + return { + top: inset, + bottom: inset, + borderRadius: rounded ? 999 : 4, + backgroundColor: isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.035)', + }; + }, [rounded, isDark]); + return ( + ); +} + +type WindowStyle = NonNullable; + +/** + * The style scalars every control bar shares, derived from `windowStyle` and + * the theme. Memoized on those two inputs so the style objects handed to the + * bars are referentially stable across renders (a live chart re-renders on + * every tick; these do not change with it). + */ +function useBarStyle(ws: WindowStyle, isDark: boolean) { + return useMemo(() => { + const isText = ws === 'text'; + const isRounded = ws === 'rounded'; + const activeColor = isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.55)'; + const inactiveColor = isDark + ? 'rgba(255,255,255,0.25)' + : 'rgba(0,0,0,0.22)'; + const btnRadius = isRounded ? 999 : 4; + return { + isText, + isRounded, + activeColor, + inactiveColor, + btnRadius, + /** The computed half of the bar `` style (see `styles.bar`). */ + chrome: { + gap: isText ? 4 : 2, + backgroundColor: isText + ? 'transparent' + : isDark + ? 'rgba(255,255,255,0.03)' + : 'rgba(0,0,0,0.02)', + borderRadius: isRounded ? 999 : 6, + padding: isText ? 0 : isRounded ? 3 : 2, + }, + /** Window-selector pill hit area. */ + pill: { + paddingVertical: isText ? 2 : 3, + paddingHorizontal: isText ? 6 : 10, + borderRadius: btnRadius, + }, + /** Mode-toggle icon button (pairs with `styles.iconBtn`). */ + iconBtn: { borderRadius: btnRadius }, + labelActive: { + fontSize: 11, + lineHeight: 16, + fontWeight: '600' as const, + color: activeColor, + }, + labelInactive: { + fontSize: 11, + lineHeight: 16, + fontWeight: '400' as const, + color: inactiveColor, + }, + }; + }, [ws, isDark]); +} + +type BarStyle = ReturnType; + +/** + * Series-chip styles. Separate from `useBarStyle` because they additionally + * branch on `seriesToggleCompact`, which the other two bars know nothing about. + */ +function useChipStyle(bar: BarStyle, isDark: boolean, compact: boolean) { + return useMemo(() => { + const { isText, btnRadius, activeColor, inactiveColor } = bar; + const labelBase = { + fontSize: 11, + lineHeight: 16, + fontWeight: '500' as const, + }; + return { + base: { + paddingVertical: compact ? (isText ? 2 : 5) : isText ? 2 : 3, + paddingHorizontal: compact ? (isText ? 4 : 7) : isText ? 6 : 8, + borderRadius: btnRadius, + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: compact ? 0 : 4, + backgroundColor: isText + ? 'transparent' + : isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.035)', - }, - animStyle, - ]} - /> + }, + /** Applied on top of `base` for a toggled-off series. */ + off: { backgroundColor: 'transparent', opacity: 0.4 }, + dot: { + width: compact ? 8 : 6, + height: compact ? 8 : 6, + borderRadius: 999, + }, + label: { ...labelBase, color: activeColor }, + labelOff: { ...labelBase, color: inactiveColor }, + }; + }, [bar, isDark, compact]); +} + +/** + * The chrome shared by all three control bars: a self-sizing row with the + * theme/`windowStyle`-derived background, radius, padding and gap, optionally + * hosting the sliding indicator that tracks the active button. + * + * Note this lives entirely *above* the `` — see the comment on the + * Skia subtree below, whose node structure must stay fixed. + */ +function PillBar({ + bar, + isDark, + indicator = false, + indicatorLayout, + faded = false, + children, +}: { + bar: BarStyle; + isDark: boolean; + /** Render a sliding indicator behind the active button. */ + indicator?: boolean; + /** Layout of the currently active button; `undefined` until first measured. */ + indicatorLayout?: BtnLayout; + /** Keep the bar mounted but invisible and untappable. */ + faded?: boolean; + children: ReactNode; +}) { + return ( + + {indicator && !bar.isText && ( + + )} + {children} + ); } @@ -134,6 +472,277 @@ function CandleIcon({ color }: { color: string }) { ); } +/** Time-window selector. Owns the button layouts its indicator tracks. */ +const WindowBar = memo(function WindowBarComponent({ + bar, + isDark, + windows, + activeSecs, + onSelect, + testID, +}: { + bar: BarStyle; + isDark: boolean; + windows: WindowOption[]; + activeSecs: number; + onSelect: (secs: number) => void; + testID: string | undefined; +}) { + const [layouts, onBtnLayout] = useBtnLayouts(); + + return ( + + {windows.map((w) => { + const isActive = w.secs === activeSecs; + return ( + onBtnLayout(w.secs, e)} + onPress={() => onSelect(w.secs)} + accessibilityRole="button" + accessibilityLabel={`${w.label} time window`} + accessibilityState={{ selected: isActive }} + testID={testID ? `${testID}-window-${w.secs}` : undefined} + style={bar.pill} + > + + {w.label} + + + ); + })} + + ); +}); + +/** Line/candle mode toggle — its own bar with its own sliding indicator. */ +const ModeBar = memo(function ModeBarComponent({ + bar, + isDark, + activeMode, + onSelect, + testID, +}: { + bar: BarStyle; + isDark: boolean; + activeMode: 'line' | 'candle'; + onSelect: (mode: 'line' | 'candle') => void; + testID: string | undefined; +}) { + const [layouts, onBtnLayout] = useBtnLayouts(); + + return ( + + {/* + Icon-only buttons: there is no text child for a screen reader to fall + back on, so an explicit label is the only thing standing between a + reader user and an unnamed button. + */} + onBtnLayout('line', e)} + onPress={() => onSelect('line')} + accessibilityRole="button" + accessibilityLabel="Line chart" + accessibilityState={{ selected: activeMode === 'line' }} + testID={testID ? `${testID}-mode-line` : undefined} + style={[styles.iconBtn, bar.iconBtn]} + > + + + onBtnLayout('candle', e)} + onPress={() => onSelect('candle')} + accessibilityRole="button" + accessibilityLabel="Candlestick chart" + accessibilityState={{ selected: activeMode === 'candle' }} + testID={testID ? `${testID}-mode-candle` : undefined} + style={[styles.iconBtn, bar.iconBtn]} + > + + + + ); +}); + +/** + * What a series chip needs to draw itself — deliberately *not* the series + * itself, whose `data`/`value` change on every tick. + */ +interface SeriesChip { + id: string; + label: string; + color: string; +} + +/** + * Series toggle chips. No sliding indicator — chips toggle independently, so + * there is no single "active" one to track. + */ +const SeriesChipBar = memo(function SeriesChipBarComponent({ + bar, + chip, + isDark, + chips, + hidden, + compact, + faded, + onToggle, + testID, +}: { + bar: BarStyle; + chip: ReturnType; + isDark: boolean; + chips: SeriesChip[]; + hidden: Set; + compact: boolean; + /** Keep the chips mounted but invisible (chart left multi-series mode). */ + faded: boolean; + onToggle: (id: string) => void; + testID: string | undefined; +}) { + return ( + + {chips.map((s) => { + const isHidden = hidden.has(s.id); + return ( + onToggle(s.id)} + // A chip is a toggle, and in compact mode it is a bare coloured + // dot with no text at all — so it always carries its own label. + accessibilityRole="checkbox" + accessibilityLabel={s.label} + accessibilityState={{ checked: !isHidden }} + testID={testID ? `${testID}-series-${s.id}` : undefined} + style={[chip.base, isHidden && chip.off]} + > + + {!compact && ( + + {s.label} + + )} + + ); + })} + + ); +}); + +/** + * The whole controls row, above the chart. + * + * Memoized, and this is the point of the whole file's shape: a live tick hands + * `Liveline` a new `data` array every frame, so `Liveline` itself re-renders + * constantly. None of the props below change on a tick, so React bails out + * here and the three bars, their style hooks and their `` icons are + * skipped entirely. Nothing tick-varying may be added to this prop list. + */ +const LivelineControls = memo(function LivelineControlsComponent({ + windowStyle, + isDark, + padLeft, + windows, + activeWindowSecs, + onWindowSelect, + showModeToggle, + activeMode, + onModeSelect, + showSeriesToggle, + seriesChips, + seriesFaded, + hiddenSeries, + seriesToggleCompact, + onSeriesToggle, + testID, +}: { + windowStyle: WindowStyle; + isDark: boolean; + padLeft: number; + windows: WindowOption[] | undefined; + activeWindowSecs: number; + onWindowSelect: (secs: number) => void; + showModeToggle: boolean; + activeMode: 'line' | 'candle'; + onModeSelect: (mode: 'line' | 'candle') => void; + showSeriesToggle: boolean; + seriesChips: SeriesChip[]; + seriesFaded: boolean; + hiddenSeries: Set; + seriesToggleCompact: boolean; + onSeriesToggle: (id: string) => void; + /** Base id the per-control test ids are derived from (see `Liveline`). */ + testID: string | undefined; +}) { + const bar = useBarStyle(windowStyle, isDark); + const chip = useChipStyle(bar, isDark, seriesToggleCompact); + + const showWindows = windows != null && windows.length > 0; + if (!showWindows && !showModeToggle && !showSeriesToggle) return null; + + return ( + + {showWindows && ( + + )} + + {showModeToggle && ( + + )} + + {showSeriesToggle && ( + + ); +}); + /** * Memoized: in a list of charts (the `active`-prop scenario), unrelated * parent re-renders must not re-render every row. Live ticks still pass @@ -188,15 +797,10 @@ export const Liveline = memo(function LivelineComponent({ seriesToggleCompact = false, lineWidth, fonts: fontsOverride, + accessibilityLabel, + testID, style, }: LivelineProps) { - const [windowBtnLayouts, setWindowBtnLayouts] = useState< - Record - >({}); - const [modeBtnLayouts, setModeBtnLayouts] = useState< - Record - >({}); - const [hiddenSeries, setHiddenSeries] = useState>(new Set()); const lastSeriesPropRef = useRef(seriesProp); if (seriesProp && seriesProp.length > 0) lastSeriesPropRef.current = seriesProp; @@ -260,38 +864,20 @@ export const Liveline = memo(function LivelineComponent({ : {} : undefined; - // Window buttons state - const [activeWindowSecs, setActiveWindowSecs] = useState( - windows && windows.length > 0 ? windows[0]!.secs : windowSecs - ); - const effectiveWindowSecs = windows ? activeWindowSecs : windowSecs; + const { activeWindowSecs, effectiveWindowSecs, selectWindow } = + useActiveWindow(windows, windowSecs, onWindowChange); - // Series toggle handler — prevent hiding the last visible series - const handleSeriesToggle = useCallback( - (id: string) => { - setHiddenSeries((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - onSeriesToggle?.(id, true); - } else { - // Count visible series — don't hide last one - const totalSeries = seriesProp?.length ?? 0; - const visibleCount = totalSeries - next.size; - if (visibleCount <= 1) return prev; - next.add(id); - onSeriesToggle?.(id, false); - } - return next; - }); - }, - [seriesProp?.length, onSeriesToggle] + const seriesIds = useMemo( + () => seriesProp?.map((s) => s.id) ?? [], + [seriesProp] + ); + const { hiddenSeries, hiddenSeriesIds, toggleSeries } = useHiddenSeries( + seriesIds, + onSeriesToggle ); const ws = windowStyle ?? 'default'; - const hiddenSeriesIds = useMemo(() => [...hiddenSeries], [hiddenSeries]); - const engine = useLivelineEngine( { data, @@ -354,41 +940,58 @@ export const Liveline = memo(function LivelineComponent({ : defaultValueColor, })); - const activeColor = isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.55)'; - const inactiveColor = isDark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.22)'; - const barBg = - ws === 'text' - ? 'transparent' - : isDark - ? 'rgba(255,255,255,0.03)' - : 'rgba(0,0,0,0.02)'; - const barRadius = ws === 'rounded' ? 999 : 6; - const barPadding = ws === 'text' ? 0 : ws === 'rounded' ? 3 : 2; - const barGap = ws === 'text' ? 4 : 2; - const btnRadius = ws === 'rounded' ? 999 : 4; - const activeMode = lineMode ? 'line' : 'candle'; - const onWindowBtnLayout = useCallback( - (secs: number, e: LayoutChangeEvent) => { - const { x, width } = e.nativeEvent.layout; - setWindowBtnLayouts((prev) => { - const cur = prev[secs]; - if (cur && cur.x === x && cur.width === width) return prev; - return { ...prev, [secs]: { x, width } }; - }); - }, - [] + // --- Controls-row props --------------------------------------------------- + // Everything below exists to keep memo-stable across a + // tick: consumers rebuild `windows`/`series` inline and pass inline arrow + // callbacks, so those identities churn even when their contents do not. + const windowsSignature = useArraySignature(windows, (w) => [w.secs, w.label]); + const stableWindows = useStableValue(windowsSignature, () => windows); + + const chipSource = lastSeriesPropRef.current; + const chipSignature = useArraySignature(chipSource, (s) => [ + s.id, + s.label ?? '', + s.color ?? '', + ]); + const seriesChips = useStableValue(chipSignature, () => + (chipSource ?? []).map((s, si) => ({ + id: s.id, + label: s.label ?? s.id, + color: s.color || SERIES_COLORS[si % SERIES_COLORS.length]!, + })) ); - const onModeBtnLayout = useCallback((key: string, e: LayoutChangeEvent) => { - const { x, width } = e.nativeEvent.layout; - setModeBtnLayouts((prev) => { - const cur = prev[key]; - if (cur && cur.x === x && cur.width === width) return prev; - return { ...prev, [key]: { x, width } }; - }); - }, []); + const selectMode = useStableHandler(onModeChange); + + // --- Accessibility -------------------------------------------------------- + // The chart is a Skia surface: without this a screen reader finds an + // unlabelled blank rectangle. The live number is a UI-thread shared value + // updated at frame rate, and accessibility props are JS-thread React props, + // so there is deliberately no per-frame bridge between them. Instead the + // reading is taken from the props already on this thread (`value` &co.) and + // sampled at a speakable ~1Hz — and only while a reader is actually running. + // + // With no reader (the overwhelmingly common case) `screenReader` is false, + // `resolveLiveValue` is never called, no timer exists, no state is committed + // and `chartA11yValue` is a stable `undefined`: the cost is one boolean test + // per render. Nothing here can make the chart re-render on tick. + const screenReader = useScreenReaderEnabled(); + const chartA11yValue = useAccessibleValue( + screenReader, + screenReader + ? resolveLiveValue({ + value, + mode, + lineMode, + lineValue, + liveCandle, + candles, + }) + : null, + formatValue + ); return ( <> @@ -396,199 +999,89 @@ export const Liveline = memo(function LivelineComponent({ {showValue && (