Skip to content

fix(replay): capture Jetpack Compose content in wireframe mode - #665

Draft
posthog[bot] wants to merge 1 commit into
mainfrom
posthog-code/compose-wireframe-replay
Draft

fix(replay): capture Jetpack Compose content in wireframe mode#665
posthog[bot] wants to merge 1 commit into
mainfrom
posthog-code/compose-wireframe-replay

Conversation

@posthog

@posthog posthog Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

A customer reported that many of their Android session recordings render as blank screens. The cause is not app-specific: Jetpack Compose draws its entire UI into a single AndroidComposeView that has no child Views, and View.toWireframe() only recurses into ViewGroup children. In the default wireframe mode the tree therefore bottomed out at the decor view plus a background colour, which the player renders as an empty screen. Screenshot mode is the only mode that could see Compose, and it ships off by default — so any Compose app that turns on session replay and doesn't also flip screenshot = true got recordings that look broken, with nothing in the logs to point at the cause even with debug = true.

Compose content is now built from the semantics tree — the only structural description of the UI available without capturing pixels — so Compose apps get the same wireframe experience View-based apps already get, with no change to the privacy posture of wireframe mode (still no pixels, and existing masking rules apply). Screenshot mode remains the highest-fidelity option, and the SDK now logs that recommendation once when it detects Compose in wireframe mode, closing the silent-failure gap.

Masking follows the same precedence as the screenshot path (findMaskableComposeWidgets) and the View path: postHogUnmask wins over everything, then postHogMask and passwords force masking, then maskAllTextInputs applies. Images need no rule of their own — semantics carries no pixels, so a Compose image is always the player's placeholder.

Semantics can only be read on the main thread, so every Compose root under the decor view is read in one hop per capture (a screen can hold many roots, e.g. a ComposeView per list row); the mapping from semantics to wireframes then runs on the capture thread.

💚 How did you test it?

Unit tests. 16 tests over the semantics-to-wireframe mapping (masking precedence, roles, density conversion, node skipping, id stability) and 4 end-to-end tests driving generateSnapshot() under Robolectric: content appears in the emitted RRFullSnapshotEvent, an empty read reproduces the blank screen this fixes, ids stay stable across snapshots so an unchanged tree emits no mutations, and two Compose roots in one window don't collide. Full suite, lintDebug and apiCheck pass.

Before/after on real snapshot payloads. No emulator was available in this environment, so instead of a screen recording I captured the actual wireframes the player receives, end to end through generateSnapshot(), for a Compose login screen. Before, the whole payload is four nested empty divs and a window background — nothing to draw. After:

type   inputType   x    y     w     h    text/value/label
text   -           40   120   260   60   ****
text   -           40   260   660   70   ************
text   -           40   350   1000  50   *******************
image  -           400  460   280   280  (placeholder, no base64)
input  text_area   40   820   1000  130  ***************
input  text_area   40   990   1000  130  *************
input  checkbox    40   1160  520   60   ***********  checked=true
input  button      40   1290  1000  130  *******
text   -           300  1480  480   60   ****************

Rendering both payloads side by side (an approximation of the player's transformer, not the player itself) goes from an empty screen to a recognisable masked login screen — the positions above are absolute, so the layout survives.

Not manually tested on a device or in the real player: no emulator/KVM was available here, so a device-level demo and a check in the real player are worth doing before release.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Fully autonomous

Written by Claude Code (Opus) from a PostHog Signals inbox report about blank Android recordings, using the repo's Gradle/Robolectric setup for verification.

Two alternatives were considered and rejected. Auto-promoting Compose screens to screenshot mode would fix the blank screens with full fidelity, but it silently changes what leaves the device: wireframe mode omits anything the SDK doesn't understand, whereas screenshot mode ships those pixels. That is a privacy-posture change that shouldn't be flipped without a human decision, and it would also affect mixed View/Compose windows. Logging a warning only was the report's suggested first step, but on its own it leaves the customer's recordings blank. Reading the semantics tree fixes the symptom while staying inside wireframe mode's "only ship what we understand" model — and PostHog already walks that same tree for screenshot masking.

Fidelity is deliberately bounded: no colours or typography (semantics doesn't carry them), and content with no semantics at all (custom Canvas drawing, icons without a contentDescription) stays invisible. That's why the one-time log still recommends screenshot mode. Reviewers may want to weigh in on whether the docs should also gain an Android Compose note, mirroring the existing iOS/SwiftUI guidance.

Compose access is routed through a single internal seam (composeSemanticsReader) because the Compose dependency is compileOnly — its classes are absent from the unit-test classpath, and the existing test file deliberately avoids resolving Compose-referencing methods. The seam keeps that constraint in one place and lets the tests drive the mapping without a Compose runtime.


Created with PostHog Desktop from this inbox report.

Compose draws its whole UI into a single AndroidComposeView that has no child
Views, so the wireframe walk in `toWireframe()` bottomed out at the decor view
and Compose screens recorded as a blank screen — with nothing in the logs to
explain it, even with `debug = true`. Screenshot mode was the only mode that
could see Compose, and it ships off by default.

Compose content is now read from the semantics tree (the only structural
description available without capturing pixels) and emitted as text, input and
image wireframes, giving Compose apps the same wireframe experience View-based
apps already get. Masking follows the existing rules: `postHogUnmask` wins,
then `postHogMask` and passwords force masking, then `maskAllTextInputs`.

Semantics can only be read on the main thread, so every Compose root under the
decor view is read in a single hop per capture rather than one hop per root, and
the mapping from semantics to wireframes runs on the capture thread.

Screenshot mode still gives the highest fidelity, so the SDK now also logs that
recommendation once when it detects Compose in wireframe mode.

Generated-By: PostHog Code
Task-Id: 537208f8-bc65-49f5-9463-e30d3aa58623
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants