From 7fb383d6bf3e3a891be0b6bfd2bf8e2163775a2a Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 23 Jun 2026 12:03:15 +0200 Subject: [PATCH 1/2] chore: add gallery scenario --- .claude/skills/perf-benchmarking/SKILL.md | 13 +- perf/drive-gallery-scenario.sh | 89 ++++++++++ perf/scenario-lib.sh | 187 +++++++++++++++++++++- 3 files changed, 285 insertions(+), 4 deletions(-) create mode 100755 perf/drive-gallery-scenario.sh diff --git a/.claude/skills/perf-benchmarking/SKILL.md b/.claude/skills/perf-benchmarking/SKILL.md index 08253e8b1e..6c73db5e00 100644 --- a/.claude/skills/perf-benchmarking/SKILL.md +++ b/.claude/skills/perf-benchmarking/SKILL.md @@ -8,6 +8,7 @@ description: Measure React Native performance on-device for stream-chat-react-na How to measure SDK performance for real, against the **SampleApp running on a physical Android device**, using the committed `perf/` toolkit. This skill exists because the methodology is full of traps that produce confidently-wrong numbers; follow the rules and recipes here instead of improvising. Prerequisites for almost everything below: +- **The installed app is a DEBUG build wired to Metro — check this FIRST, before anything else.** A release build embeds the JS bundle in the APK and ignores Metro entirely, so none of your `src` edits or throwaway instrumentation ever run, AND the Hermes inspector is disabled so CPU profiling can't attach. Symptoms are silent and misleading: the app runs fine, navigation works, but every marker you add stays absent and you waste an hour chasing phantom "stale bundle" / "adb reverse" causes (this happened). Verify with one command — `adb shell run-as io.getstream.reactnative.sampleapp ls >/dev/null 2>&1 && echo DEBUG || echo "RELEASE — rebuild"` (a release build reports "package not debuggable"); or check the flags: `adb shell dumpsys package io.getstream.reactnative.sampleapp | grep flags=` should include `DEBUGGABLE`. If it's release, install debug first: `yarn workspace sampleapp android` (builds, installs over the release app, sets up `adb reverse`). Confirm the device→Metro reverse exists: `adb reverse --list` should show `tcp:8081`. - Metro running: `yarn workspace sampleapp start` (the device pulls the JS bundle from it; **Metro bundles the SDK from `src`** via the `react-native` package.json field, so editing `package/src/**` hot-reloads on relaunch — no `yarn build` needed). - A device/emulator connected: `adb devices` shows one. The SampleApp package is `io.getstream.reactnative.sampleapp`. @@ -116,7 +117,11 @@ Heed rule 5: if the change is sub-noise, the diff will show **nothing real** (on ### 3) Driving the device — scenarios on `scenario-lib.sh` Scenarios are **thin scripts on top of `perf/scenario-lib.sh`**, which provides device-driving verbs so each scenario stays declarative and waits on real mount signals (never `sleep`). Verbs: -`relaunch` · `wait_for [secs]` · `tap_testid ` · `tap_until [tries]` (tap + retry until the next screen's testID appears — taps occasionally don't register) · `swipe_up/down/left/right [ms]` · `scroll [n] [ms]` · `count_testid ` (uses `grep -o | wc -l`, never `grep -c`). +`relaunch` · `wait_for [secs]` · `tap_testid ` · `tap_until [tries]` (tap + retry until the next screen's testID appears — taps occasionally don't register) · `tap_text ` / `tap_text_until ` (tap a node by its visible text — e.g. open a channel by display name) · `tap_header_action [ymax]` / `tap_header_until ` (tap the right-most header action when RN flattens its testID away) · `swipe_up/down/left/right [ms]` · `scroll [n] [ms]` · `scroll_down [n] [ms] [settle]` · `count_testid ` (uses `grep -o | wc -l`, never `grep -c`) · `wait_for_log [secs]` (poll logcat for states the view tree can't see). + +**Pagination direction depends on whether the list is inverted.** To load more rows you must scroll toward the list's *growing* end and trigger its `onEndReached`. The **message list is inverted** (newest at bottom, older above) — paginate by scrolling **up** (`scroll`, the lib's `swipe_up`). The **Photos & Videos media grid is a normal list** (newest first, older below) — paginate by scrolling **down** (`scroll_down`, the lib's `swipe_down`). Use the wrong direction and you scroll against the anchored edge: nothing loads, the loaded set looks capped, and every "scaling" run silently measures the **same N** (this happened — preload depths 4/12/25 all yielded 44 assets until the grid was scrolled *down*, which grew it to 165). + +**Driving the lib inline runs under zsh, which breaks it.** The scenario scripts have a `#!/usr/bin/env bash` shebang, so `bash perf/drive-*.sh` is fine. But `source perf/scenario-lib.sh` from an interactive tool shell runs under **zsh**, where `"$id[^\"]*"` inside `tap_testid`'s grep is parsed as an array subscript (`bad math expression: operand expected at \`^"'`). Always drive via `bash perf/drive--scenario.sh …`, never by sourcing the lib into zsh. The channel scenario (`perf/drive-channel-scenario.sh`) is the reference shape: ```bash @@ -139,11 +144,14 @@ echo "rows=$(count_testid message-list-item-)" ## Anti-patterns to avoid (each = a real mistake made; do the fix) +- **Measuring against a release build without checking** → src edits + instrumentation silently never run, the Hermes inspector won't attach, and the failure looks exactly like a stale bundle or a missing `adb reverse` (an hour lost chasing both). → `adb shell run-as ls` (or check `dumpsys package … flags=` for `DEBUGGABLE`) as step zero; install debug with `yarn workspace sampleapp android` if needed. - **Node/Jest microbench of native-touching code** → mocked to ~free, ~14× wrong. → Measure on device. - **Instrumenting one call site** → undercount; misses dependency callers. → Wrap the native API (Pattern 1a). - **CPU-profile `--diff` for a tiny / cross-edit change** → phantom line-shift deltas dominate, real signal is sub-noise. → Use counting (Pattern 1). - **`grep -c` on `uiautomator` XML** → the dump is one line, so it returns 1 regardless. → `grep -oE '…' | wc -l`. - **`uiautomator dump` mid-scroll-animation** → returns a partial/sparse tree (looked like "1 row" when there were 15). → Dump after the list settles. +- **Paginating the wrong direction for the list's inversion** → scroll against the anchored edge, nothing loads, the loaded set looks capped and every "scaling" run measures the same N. → Inverted list (message list) paginates by scrolling **up** (`scroll`); normal list (media grid) paginates by scrolling **down** (`scroll_down`). +- **Sourcing `scenario-lib.sh` into the tool's zsh shell** → `tap_testid`'s `"$id[^\"]*"` is read as a zsh array subscript → `bad math expression`. → Drive via `bash perf/drive-*.sh`, never `source` into zsh. - **Fixed `sleep` after a tap, then swipe** → debug nav is slow; swipes hit the list / half-mounted screen. → Wait on `message-flat-list` (Pattern 3). - **Reading cold first-mount numbers** → dominated by startup/JIT/module-init (~2500ms "mount" is mostly not your code). → Use warm re-opens; never quote the cold mount as the row cost. - **Claiming "faster"/"slower" from one render sample** → it's within noise. → Lead with the deterministic count; only call timing differences real with N runs + non-overlapping spreads. @@ -167,7 +175,8 @@ The `[SR_STACK]` traces revealed the residual ~30 wasn't ours: 1 call = our prov ## Execution checklist -- [ ] Metro running, exactly one device connected (`adb devices`). +- [ ] **DEBUG build installed** (`adb shell run-as ls` succeeds), not release — checked BEFORE measuring. +- [ ] Metro running, exactly one device connected (`adb devices`); `adb reverse --list` shows `tcp:8081`. - [ ] Picked the right **instrument** for the change (rule 5). - [ ] Chose a channel with enough messages; drove it on testIDs, not sleeps. - [ ] Instrumented the **native API** (counts every caller), not a single call site. diff --git a/perf/drive-gallery-scenario.sh b/perf/drive-gallery-scenario.sh new file mode 100755 index 0000000000..5500a2439b --- /dev/null +++ b/perf/drive-gallery-scenario.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# Scenario: open the Channel Details -> Photos & Videos gallery and scroll through it. +# +# This is the SCALABILITY path. The media grid infinite-scrolls, and tapping a tile feeds +# EVERY loaded message into the fullscreen viewer (formatMessage over all of them, then a +# non-windowed pager that mounts one AnimatedGallery* per asset). Preload the grid first +# to grow N, so the open + scroll exercise the O(N) cost. +# +# Run this WHILE a perf capture is active (see perf/README.md, perf/capture-*.js), or on +# its own to sanity-check the flow. +# +# Usage: perf/drive-gallery-scenario.sh [channel_name] [grid_preload_swipes] [gallery_swipes] +# channel_name substring of the target channel's display name (default: first channel) +# grid_preload_swipes times to scroll the media grid before opening (default 6) -> grows N +# gallery_swipes pages to swipe through inside the gallery (default 10) +# +# The channel is NOT hardcoded: pass the display name to benchmark as $1. The agent driving +# this should ASK the user which channel to benchmark; only if they don't specify one does +# it fall back to the first channel in the list. Example: +# perf/drive-gallery-scenario.sh "" 12 15 +# +# NOTE: the gallery overlay is INVISIBLE to uiautomator (it exposes no accessibility nodes +# when TalkBack is off), so gallery-open is detected via a logcat marker emitted by the +# throwaway instrumentation in image-gallery-state-store.ts. Without that instrumentation, +# wait_for_log will time out and the run falls back to a short settle. + +set -uo pipefail +source "$(dirname "$0")/scenario-lib.sh" + +CHANNEL="${1:-}" +PRELOAD="${2:-6}" +GAL_SWIPES="${3:-10}" + +relaunch +wait_for channel-preview-button 30 || exit 1 + +# 1) Open the target channel (by display name if given, else the first one). +if [ -n "$CHANNEL" ]; then + tap_text_until "$CHANNEL" message-flat-list || exit 1 +else + tap_until channel-preview-button message-flat-list || exit 1 +fi +echo "CHANNEL OPEN" + +# 2) Channel header avatar -> Channel Details. RN composes the avatar Pressable into one +# Button node and drops its testID, so target the right-most header action by position. +tap_header_until channel-details-photos-and-videos || exit 1 +echo "DETAILS OPEN" + +# 3) Channel Details -> Photos & Videos grid. +tap_until channel-details-photos-and-videos media-list || exit 1 +wait_for media-list 15 || exit 1 +echo "MEDIA LIST OPEN" + +# 4) Preload: scroll DOWN through the grid to load more pages (grows the loaded-media set +# N). The media grid is a NORMAL list (newest first), so pagination = scrolling down to +# hit onEndReached — the OPPOSITE direction from the inverted message list. Using the +# wrong direction here silently loads nothing beyond the first page. +scroll_down "$PRELOAD" +echo "PRELOADED ($PRELOAD down-swipes); tiles on screen=$(count_testid media-item-)" + +# 5) Open the gallery on a fully-visible tile, then wait on the logcat open-marker. +adb logcat -c +tap_visible_testid media-item- 200 270 2300 || exit 1 +if wait_for_log "[PERF_GAL] OPEN" 15; then + echo "GALLERY OPEN" +else + echo "WARN: open-marker not seen; settling 3s and continuing" >&2 + sleep 3 +fi + +# 6) Scroll through the gallery (page forward). +# +# IMPORTANT: the gallery pager is VELOCITY-gated, not distance-gated +# (useImageGalleryGestures.tsx onEnd: finalXPosition = translationX - velocityX*0.3 must +# exceed half-screen). `adb input swipe` releases at ~0 velocity, so these swipes SNAP +# BACK and do NOT page on a stock build — real fingers page fine. To actually drive the +# swipe phase for a perf run, temporarily OR a distance gate into that onEnd, e.g.: +# (finalXPosition > halfScreenWidth || -event.translationX > halfScreenWidth) // NEXT +# (finalXPosition < -halfScreenWidth || event.translationX > halfScreenWidth) // PREV +# then a large swipe_left/swipe_right (>half-screen travel) pages. Verify by reading the +# footer "X of Y" counter (disable LogBox so it isn't covered); compare the INDEX, not the +# slide image — adjacent slides often look identical. +for _ in $(seq 1 "$GAL_SWIPES"); do + swipe_left 250 + sleep 0.6 +done +echo "DONE" diff --git a/perf/scenario-lib.sh b/perf/scenario-lib.sh index 685d8e0749..8ba49be986 100755 --- a/perf/scenario-lib.sh +++ b/perf/scenario-lib.sh @@ -15,11 +15,36 @@ # wait_for [secs] poll uiautomator until a testID appears (default 30s) # tap_testid tap the center of the first element whose resource-id # starts with (prefix match, so a UUID suffix is fine) +# tap_visible_testid [min_h] [top] [bottom] +# like tap_testid but skips elements clipped off-screen +# (needed for grid tiles: the first match is often clipped +# under the header) +# tap_text tap the center of the first node whose text= contains +# (e.g. open a channel by its display name) +# tap_text_until [tries] +# scroll the list until a node with text is visible, +# tap it, poll for ; the generic "open a +# specific channel by name" primitive +# wait_for_log [secs] +# poll logcat (ReactNativeJS) for — use for states +# the view tree can't see (the image gallery overlay +# contributes NO accessibility nodes when TalkBack is off, so +# uiautomator cannot detect it). Clear logcat before the action. +# tap_header_action [y_max] tap the right-most clickable node in the top header band — +# for header actions RN won't surface a resource-id for (e.g. +# the channel-screen avatar button -> Channel Details) +# tap_header_until [tries] [y_max] +# tap_until, but via tap_header_action instead of a testID # swipe_up [ms] scroll content up / reveal older (default 250ms) # swipe_down [ms] scroll content down # swipe_left [ms] page forward (e.g. image gallery) # swipe_right [ms] page back # scroll [n] [ms] swipe_up n times (default 8) with a settle between each +# (for the INVERTED message list — reveals older messages) +# scroll_down [n] [ms] [settle] +# swipe_down n times to paginate a NORMAL list (e.g. the +# Photos & Videos grid). Opposite direction from scroll(); +# longer settle so each network page lands before the next swipe # count_testid count elements whose resource-id starts with # (uses grep -o | wc -l — NEVER grep -c: the dump is one line) # ui_dump refresh the cached view tree (most verbs call this themselves) @@ -35,7 +60,12 @@ ui_dump() { } relaunch() { + # force-stop alone can resume warm on some OEM ROMs (MIUI), which would leave the JS + # context — and any cumulative perf counters — alive. force-stop + kill + a short wait + # guarantees a true cold JS re-init. adb shell am force-stop "$PKG" + adb shell am kill "$PKG" >/dev/null 2>&1 + sleep 1 adb shell monkey -p "$PKG" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 } @@ -93,8 +123,11 @@ tap_until() { swipe_up() { adb shell input swipe 540 700 540 1600 "${1:-250}"; } swipe_down() { adb shell input swipe 540 1600 540 700 "${1:-250}"; } -swipe_left() { adb shell input swipe 950 1200 130 1200 "${1:-250}"; } -swipe_right() { adb shell input swipe 130 1200 950 1200 "${1:-250}"; } +# Horizontal swipes kept clear of the screen edges: a swipe that starts/ends within the +# OEM edge-gesture zone (outer ~10%) triggers the system BACK gesture instead (it will +# pop you out of the screen). 880<->200 is a ~680px central swipe, safe on a 1080px device. +swipe_left() { adb shell input swipe 880 1100 200 1100 "${1:-250}"; } +swipe_right() { adb shell input swipe 200 1100 880 1100 "${1:-250}"; } scroll() { local n="${1:-8}" ms="${2:-250}" @@ -104,7 +137,157 @@ scroll() { done } +scroll_down() { + # Paginate a NORMAL (non-inverted) list — e.g. the Photos & Videos media grid — by + # scrolling down through it to reach the bottom and trigger onEndReached/loadMore. + # This is the OPPOSITE direction from scroll() (which scrolls an inverted message list + # up to reveal older). The settle is longer because each page is a network round-trip; + # too short and the next swipe fires before the page lands, so N stops growing. + local n="${1:-8}" ms="${2:-250}" settle="${3:-1.2}" + for _ in $(seq 1 "$n"); do + swipe_down "$ms" + sleep "$settle" + done +} + count_testid() { ui_dump grep -oE "resource-id=\"$1[^\"]*\"" "$UIXML" | wc -l | tr -d ' ' } + +_center_of_box() { + # echo "cx cy" for a uiautomator bounds string "[x1,y1][x2,y2]" + echo "$1" | sed -E 's/\[([0-9]+),([0-9]+)\]\[([0-9]+),([0-9]+)\]/(\1+\3)\/2 (\2+\4)\/2/' | + { read -r a b; echo "$(echo "$a" | bc) $(echo "$b" | bc)"; } +} + +tap_visible_testid() { + # tap_visible_testid [min_h] [top_guard] [bottom_guard] + # Tap the center of the first element whose resource-id starts with that is + # fully on-screen (height >= min_h, top >= top_guard, bottom <= bottom_guard). Grid + # tiles' first match is usually clipped under the header, so a plain tap_testid lands + # on a sliver and misses. + local prefix="$1" min_h="${2:-200}" top="${3:-260}" bottom="${4:-2300}" + ui_dump + local center + center=$(grep -oE "]*resource-id=\"$prefix[^\"]*\"[^>]*bounds=\"\[[0-9]+,[0-9]+\]\[[0-9]+,[0-9]+\]\"" "$UIXML" | + grep -oE '\[[0-9]+,[0-9]+\]\[[0-9]+,[0-9]+\]' | + awk -F'[][,]' -v mh="$min_h" -v tg="$top" -v bg="$bottom" \ + '{x1=$2;y1=$3;x2=$5;y2=$6; h=y2-y1; if (h>=mh && y1>=tg && y2<=bg){printf "%d %d\n",(x1+x2)/2,(y1+y2)/2; exit}}') + if [ -z "$center" ]; then + echo "ERR: no fully-visible element for testID '$prefix'" >&2 + return 1 + fi + echo "tap visible '$prefix' @ $center" + # shellcheck disable=SC2086 + adb shell input tap $center +} + +tap_text() { + # tap_text : tap the center of the first node whose text= contains + # . The tap lands on the pressable behind the text (e.g. a channel row). + # Keep free of regex metacharacters. + local needle="$1" + ui_dump + local box + box=$(grep -oE "]*text=\"[^\"]*${needle}[^\"]*\"[^>]*bounds=\"\[[0-9]+,[0-9]+\]\[[0-9]+,[0-9]+\]\"" "$UIXML" | + grep -oE '\[[0-9]+,[0-9]+\]\[[0-9]+,[0-9]+\]' | head -1) + if [ -z "$box" ]; then + echo "ERR: no node with text matching '$needle'" >&2 + return 1 + fi + local center + center=$(_center_of_box "$box") + echo "tap text '$needle' @ $center" + # shellcheck disable=SC2086 + adb shell input tap $center +} + +tap_text_until() { + # tap_text_until [tries] + # Scroll the list until a node with text containing is visible, tap it, + # and poll for . Retries / scrolls if not found. Opens a specific + # channel by name without hardcoding its position in the list. + local needle="$1" expect_id="$2" tries="${3:-8}" + local i j + for i in $(seq 1 "$tries"); do + ui_dump + if grep -qE "text=\"[^\"]*${needle}[^\"]*\"" "$UIXML"; then + tap_text "$needle" || return 1 + for j in $(seq 1 6); do + sleep 1 + ui_dump + if grep -q "resource-id=\"$expect_id" "$UIXML"; then + return 0 + fi + done + echo "retry $i: '$expect_id' not up after tapping text '$needle'" >&2 + else + echo "scan $i: text '$needle' not visible, scrolling" >&2 + swipe_up 250 + sleep 0.6 + fi + done + echo "ERR: never reached '$expect_id' via text '$needle'" >&2 + return 1 +} + +wait_for_log() { + # wait_for_log [secs] + # Poll logcat (ReactNativeJS) for a fixed-string pattern. Use for states the view tree + # cannot see — notably the image gallery overlay, which exposes no accessibility nodes + # when TalkBack is off, so uiautomator dump only ever sees the screen behind it. + # Clear logcat (adb logcat -c) before the action that should emit the marker. + local pat="$1" timeout="${2:-15}" + for _ in $(seq 1 "$timeout"); do + if adb logcat -d -s ReactNativeJS:V 2>/dev/null | grep -qF "$pat"; then + return 0 + fi + sleep 1 + done + echo "ERR: log pattern '$pat' never appeared within ${timeout}s" >&2 + return 1 +} + +tap_header_action() { + # tap_header_action [y_max] + # Tap the right-most clickable node within the top header band (top edge < y_max). + # Use for a header action that RN won't surface a resource-id for — e.g. the channel + # screen's avatar button, which RN composes with the avatar into one Button node with + # the avatar's content-desc, dropping the testID. The back button shares the band on + # the left, so we take the right-most clickable. + local ymax="${1:-300}" + ui_dump + local center + center=$(grep -oE ']*clickable="true"[^>]*bounds="\[[0-9]+,[0-9]+\]\[[0-9]+,[0-9]+\]"' "$UIXML" | + grep -oE '\[[0-9]+,[0-9]+\]\[[0-9]+,[0-9]+\]' | + awk -F'[][,]' -v ym="$ymax" \ + '{x1=$2;y1=$3;x2=$5;y2=$6; if (y1maxx){maxx=cx; bx=cx; by=cy}}} END{if (bx!=""){printf "%d %d\n",bx,by}}') + if [ -z "$center" ]; then + echo "ERR: no header-band clickable node found (top < ${ymax}px)" >&2 + return 1 + fi + echo "tap header action @ $center" + # shellcheck disable=SC2086 + adb shell input tap $center +} + +tap_header_until() { + # tap_header_until [tries] [y_max] + # Like tap_until, but taps the right-most header action (see tap_header_action) instead + # of a testID. Polls for and retries. + local expect_id="$1" tries="${2:-4}" ymax="${3:-300}" i j + for i in $(seq 1 "$tries"); do + tap_header_action "$ymax" || return 1 + for j in $(seq 1 6); do + sleep 1 + ui_dump + if grep -q "resource-id=\"$expect_id" "$UIXML"; then + return 0 + fi + done + echo "retry $i: '$expect_id' not up after header tap" >&2 + done + echo "ERR: '$expect_id' never appeared after $tries header taps" >&2 + return 1 +} From ca086c6a416d80abfd88b40dd81d8221400686c4 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 23 Jun 2026 14:41:34 +0200 Subject: [PATCH 2/2] perf: gallery virtualization --- .claude/skills/perf-benchmarking/SKILL.md | 3 +- .../components/ImageGallery/ImageGallery.tsx | 159 +++++++++++++----- 2 files changed, 116 insertions(+), 46 deletions(-) diff --git a/.claude/skills/perf-benchmarking/SKILL.md b/.claude/skills/perf-benchmarking/SKILL.md index 6c73db5e00..5c74b3fe4f 100644 --- a/.claude/skills/perf-benchmarking/SKILL.md +++ b/.claude/skills/perf-benchmarking/SKILL.md @@ -33,7 +33,7 @@ Good-practice bar for this SDK: **if messages (or the action under test) end up - "What component re-renders too much / too often" → **React DevTools profile** (`analyze-react-profile.js`). - Memory / jank / dropped frames → **`android-heap-dump.sh`**. - A CPU-profile **`--diff` is blind to sub-noise changes** and is polluted by line-number-shift phantom deltas when you diff across a code edit. Do not use it to "prove" a tiny change. -6. **A/B fairly.** Same scenario, same instrumentation, same device, same channel (with enough messages), same swipe count. Only the code under test differs. +6. **A/B fairly — same data, same path, multiple runs.** Same scenario, instrumentation, device, channel (with enough messages), and swipe count; only the code under test differs. Crucially, **both builds must traverse the *identical content*** — the same images, the same messages, the same items in the same order. Content-dependent costs (image decode, layout, text shaping) otherwise confound the result, and the **tail percentiles (95th/99th) are dominated by the single heaviest item traversed** — so a different start position or swipe path makes them meaningless (this bit us once: two gallery runs opened on different images, and the 99th moved 50ms purely from decoding a bigger bitmap, not from the code). Pin the start (e.g. force a fixed gallery index via throwaway instrumentation) and drive a fixed path so each run does identical work. And **run each side ≥3× and report the spread** — a single run per side cannot separate a real delta from run-to-run noise, especially at the tail; a difference inside the runs' spread is not a result. 7. **Drive the device on real mount signals, not `sleep`.** Android *debug* navigation is slow and variable. Wait on testIDs (`channel-preview-button` → `message-flat-list`), never a fixed sleep after a tap — or your swipes land on the list / a half-mounted screen. 8. **Respect git-hands-off.** To A/B a committed change, produce the baseline by editing the file back locally (or `git stash` if it's uncommitted), measure, then restore by re-writing the committed version. **Never** `git commit`, `git revert`, or rewrite history to benchmark. @@ -155,6 +155,7 @@ echo "rows=$(count_testid message-list-item-)" - **Fixed `sleep` after a tap, then swipe** → debug nav is slow; swipes hit the list / half-mounted screen. → Wait on `message-flat-list` (Pattern 3). - **Reading cold first-mount numbers** → dominated by startup/JIT/module-init (~2500ms "mount" is mostly not your code). → Use warm re-opens; never quote the cold mount as the row cost. - **Claiming "faster"/"slower" from one render sample** → it's within noise. → Lead with the deterministic count; only call timing differences real with N runs + non-overlapping spreads. +- **A/B runs that traverse different content** (different images/messages, or a different start index / swipe path between baseline and after) → content-dependent cost (image decode, layout, text shaping) confounds the delta, and the 95th/99th are set by the single heaviest item traversed, so wins *and* losses are noise. → Pin the start (force a fixed index via throwaway instrumentation) and drive an identical path so both builds do byte-identical work; run each side ≥3× and compare spreads, not single numbers. ## Environment gotchas diff --git a/package/src/components/ImageGallery/ImageGallery.tsx b/package/src/components/ImageGallery/ImageGallery.tsx index fc7bb1e10a..d77633ee1d 100644 --- a/package/src/components/ImageGallery/ImageGallery.tsx +++ b/package/src/components/ImageGallery/ImageGallery.tsx @@ -1,9 +1,10 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { AccessibilityInfo, ImageStyle, Platform, StyleSheet, ViewStyle } from 'react-native'; +import { AccessibilityInfo, ImageStyle, Platform, StyleSheet, View, ViewStyle } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Easing, + SharedValue, useAnimatedReaction, useAnimatedStyle, useDerivedValue, @@ -79,6 +80,109 @@ type ImageGalleryWithContextProps = Pick< ImageGalleryGrid?: React.ComponentType; }; +/** + * Number of slides mounted on each side of the current one. Kept one wider + * than AnimatedGalleryImage's +-3 load window so an edge slide is already + * mounted (as the empty placeholder) before it ever needs to load its + * image - paging never reveals an unmounted slot. + */ +const PAGER_WINDOW_RADIUS = 4; + +const galleryPagerSelector = (state: ImageGalleryState) => ({ + assets: state.assets, + currentIndex: state.currentIndex, +}); + +type GalleryPagerProps = { + fullWindowHeight: number; + fullWindowWidth: number; + offsetScale: SharedValue; + scale: SharedValue; + slide?: ImageStyle; + translateX: SharedValue; + translateY: SharedValue; +}; + +/** + * Windowed pager body. Subscribes to `currentIndex` itself (rather than the + * parent doing so) so a page change rerenders only this small slide list and + * never the parent - keeping the gesture objects/`GestureDetector` stable. + * + * Only the slides within +-{@link PAGER_WINDOW_RADIUS} of the current index are + * mounted; a single leading spacer occupies the flex width of all preceding + * slides so the rendered ones keep their exact natural positions. The slide + * transforms in `useAnimatedGalleryStyle` are a pure function of each slide's + * `index` and that natural flex position, so windowing the mount leaves every + * slide pixelidentical while dropping the mounted component/view count to O(window) + * instead of it being O(N). This way, less React fibers are reconciled and less + * shadow nodes are rendered as well. + */ +const GalleryPager = (props: GalleryPagerProps) => { + const { fullWindowHeight, fullWindowWidth, offsetScale, scale, slide, translateX, translateY } = + props; + const { imageGalleryStateStore } = useImageGalleryContext(); + const { assets, currentIndex } = useStateStore( + imageGalleryStateStore.state, + galleryPagerSelector, + ); + + const slideStyle = { + height: fullWindowHeight * 8, + marginRight: MARGIN, + width: fullWindowWidth * 8, + }; + + const lo = Math.max(0, currentIndex - PAGER_WINDOW_RADIUS); + const hi = Math.min(assets.length - 1, currentIndex + PAGER_WINDOW_RADIUS); + + const slides: React.ReactNode[] = []; + for (let i = lo; i <= hi; i++) { + const photo = assets[i]; + slides.push( + photo.type === FileTypes.Video ? ( + + ) : ( + + ), + ); + } + + return ( + <> + {lo > 0 ? ( + + ) : null} + {slides} + + ); +}; + export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => { const { numberOfImageGalleryGridColumns, @@ -292,50 +396,15 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => testID='image-gallery-pager' style={[styles.animatedContainer, pagerStyle, pager]} > - {assets.map((photo, i) => - photo.type === FileTypes.Video ? ( - - ) : ( - - ), - )} +