Skip to content

perf: pre-warm workflow paywall step states off-thread#3420

Merged
vegaro merged 4 commits into
mainfrom
cesar/wfl-46-prewarm
May 4, 2026
Merged

perf: pre-warm workflow paywall step states off-thread#3420
vegaro merged 4 commits into
mainfrom
cesar/wfl-46-prewarm

Conversation

@vegaro

@vegaro vegaro commented Apr 30, 2026

Copy link
Copy Markdown
Member

Motivation

The lazy step-state cache from #3416 already keeps back-navigation instant (each step is memoized on first visit). But the first forward navigation to a never-visited step still pays the full calculateState cost on the main thread, which can drop frames on a slide animation.

Description

Adds preWarmWorkflowStepCache(...), a coroutine launched on viewModelScope immediately after the initial step is built. It iterates every step in the workflow, computes its state on Dispatchers.Default, and stores the result in workflowStepStateCache. Once all steps finish computing, _workflowState.stepStates is updated in a single batch write.

The job is cancelled and the cache cleared whenever a new workflow loads, so a workflow swap doesn't leak stale states or compute work.

Pure perf optimization — buildStateFromStep already handles a missing cache entry by computing on demand, so behavior is identical with or without the pre-warm. The only observable difference is that forward navigation to a never-visited step is no longer blocking on the main thread once the pre-warm has had a chance to finish.

🤖 Generated with Claude Code


Note

Medium Risk
Adds background coroutine work and shared cache mutation in PaywallViewModelImpl, which could introduce race conditions or extra CPU usage if cancellation/timing isn’t handled as expected. Functional behavior should remain the same, but workflow navigation state updates now occur asynchronously.

Overview
Reduces jank when navigating forward in multi-step workflow paywalls by precomputing and caching each step’s PaywallState in the background.

PaywallViewModelImpl now launches a viewModelScope job that computes missing step states on Dispatchers.Default, updates locale on computed states, and then batch-updates workflowState.stepStates; the job is cancelled and the cache cleared when a new workflow is loaded.

Reviewed by Cursor Bugbot for commit a661ac3. Bugbot is set up for automated code reviews on this repo. Configure here.

@vegaro vegaro marked this pull request as ready for review April 30, 2026 10:41
@vegaro vegaro requested a review from a team as a code owner April 30, 2026 10:41
@vegaro vegaro requested a review from facumenzella April 30, 2026 10:41

@facumenzella facumenzella left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

solid

@vegaro vegaro force-pushed the cesar/wfl-46-prewarm branch 3 times, most recently from ff264e9 to 3abb26a Compare April 30, 2026 13:21
@vegaro vegaro force-pushed the cesar/wfl-46-prewarm branch from 3abb26a to 0c63590 Compare April 30, 2026 13:36
@vegaro vegaro force-pushed the cesar/wfl-46-prewarm branch 2 times, most recently from 46bf10d to 59037fa Compare April 30, 2026 14:32
Base automatically changed from cesar/wfl-46-workflow-state to main April 30, 2026 15:45

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 950054c8b89f5655b616d773eccedead645379e9. Configure here.


private val backStack = ArrayDeque<String>()
val backStackSnapshot: List<String> get() = backStack

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

backStackSnapshot returns live mutable reference, not a snapshot

Low Severity

The backStackSnapshot property name implies it returns an immutable copy of the back stack, but it actually returns a direct reference to the internal ArrayDeque. Since ArrayDeque is a MutableList, any caller holding onto this reference will observe mutations made by subsequent triggerAction or navigateBack calls. This violates the "snapshot" semantic — a toList() call would produce an actual defensive copy.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 950054c8b89f5655b616d773eccedead645379e9. Configure here.

@codecov

codecov Bot commented May 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.45%. Comparing base (51c6a66) to head (950054c).
⚠️ Report is 5 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #3420   +/-   ##
=======================================
  Coverage   79.45%   79.45%           
=======================================
  Files         362      362           
  Lines       14539    14539           
  Branches     1976     1976           
=======================================
  Hits        11552    11552           
  Misses       2190     2190           
  Partials      797      797           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

vegaro and others added 2 commits May 4, 2026 13:00
Computes step states for all workflow steps off the main thread immediately
after the initial step renders, populating workflowStepStateCache so any
forward navigation to a never-visited step is instant instead of blocking
on calculateState.

The cache is otherwise lazy: without this pre-warm, states are still
memoized on first visit (so back-nav stays instant), but the first
forward navigation to each new step pays the calculateState cost on the
main thread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…epCache

The sole call site already cancels the job before calling this function,
so the extra cancel inside the function was misleading about ownership.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
vegaro and others added 2 commits May 4, 2026 13:00
Previously the state was updated after each step, creating N map copies
and N Compose state writes for N pre-warmed steps. A single publish at
the end is equivalent since stepStates is only read by tests and never
drives mid-loop UI decisions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
computeStateForStep runs on Dispatchers.Default and reads _lastLocaleList
at computation time. If refreshStateIfLocaleChanged fires while the job is
suspended, it updates existing cache entries but any entry added after that
point retains the stale locale. Fix by calling computed.update(localeList)
immediately after insertion, using the authoritative _lastLocaleList value
from the Main thread.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vegaro vegaro force-pushed the cesar/wfl-46-prewarm branch from 950054c to a661ac3 Compare May 4, 2026 11:07
@vegaro vegaro added this pull request to the merge queue May 4, 2026
Merged via the queue into main with commit 96b0418 May 4, 2026
35 checks passed
@vegaro vegaro deleted the cesar/wfl-46-prewarm branch May 4, 2026 11:57
matteinn pushed a commit to matteinn/purchases-android that referenced this pull request May 5, 2026
### Motivation

Multi-step workflow paywalls need the UI to know which step is active,
what the neighboring steps look like, and which direction a navigation
is going, all before the first recomposition after a tap. Without this,
the slide animation can't start from the correct offscreen position and
the outgoing step can't be cleaned up safely.

### Description

Introduces the ViewModel-side infrastructure that drives animated,
multi-step workflow paywalls:

**`WorkflowPaywallUiState`** — a new `@Stable` data class exposed from
`PaywallViewModel` as `workflowState: State<WorkflowPaywallUiState?>`.
It is non-null when the active paywall is a workflow, and carries:
- `currentStepId` — the step currently on screen.
- `stepStates` — a map of computed `PaywallState.Loaded.Components`
keyed by step id. Populated lazily as the user navigates so any visited
step is cheap to revisit. (Eager pre-warm is added on top in RevenueCat#3420.)
- `pendingTransition` — a `WorkflowPendingTransition` (from-step,
direction, monotonic id) set atomically alongside `currentStepId` so the
first recomposition after a navigation already has both surfaces and
their target positions.

**`NavigationDirection`** — a simple `FORWARD` / `BACKWARD` enum used by
`WorkflowPendingTransition` so the UI knows which way to slide.

**Step state caching** — `buildStateFromStep` checks
`workflowStepStateCache` before doing the heavy `calculateState` work
and stores the result on first visit, so back-navigation never
recomputes.

**`onTransitionComplete(transitionId: Int)`** — called by the UI when a
slide animation ends. Clears `pendingTransition` for the matching id
(guarded against stale callbacks), letting the outgoing step be removed
from the Compose slot table.

**`WorkflowNavigator` cleanup** — replaces the internal
`MutableStateFlow<String>` with a plain `var` (the ViewModel already
owns the single source of truth) and drops `StateFlow`-related imports.
The `backStack` field is exposed directly so the new tests can assert on
its contents (the class is `internal`, so visibility is naturally
scoped).

### Checklist

- [x] Unit tests added
- [ ] If applicable, create follow-up issues for \`purchases-ios\` and
hybrids

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches paywall state management and workflow navigation paths, which
could affect paywall rendering and step transitions; changes are scoped
and backed by new unit tests.
> 
> **Overview**
> Adds **ViewModel-driven workflow UI state** to support animated,
multi-step paywall workflows. `PaywallViewModel` now exposes
`workflowState` (`WorkflowPaywallUiState`) plus
`onTransitionComplete(transitionId)` so the UI can render the current
step, cache visited step `PaywallState.Loaded.Components`, and
coordinate slide transitions via a monotonic transition id and
`NavigationDirection`.
> 
> Refactors workflow navigation to be less reactive (removes `StateFlow`
from `WorkflowNavigator`), updates locale-refresh to also update cached
step states, and adds unit tests covering forward/back navigation,
transition cleanup, and step-state caching; mocks/previews are updated
to implement the new interface members.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
eb4093f. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
matteinn pushed a commit to matteinn/purchases-android that referenced this pull request Jun 5, 2026
**This is an automatic release.**

## RevenueCat SDK
### ✨ New Features
* Add optional support for setting obfuscated account id to product
changes (RevenueCat#3428) via Mark Villacampa (@MarkVillacampa)

## RevenueCatUI SDK
### Paywallv2
#### ✨ New Features
* Add slide transition to workflow paywalls (RevenueCat#3418) via Cesar de la Vega
(@vegaro)
* Workflow state & ViewModel infrastructure (RevenueCat#3416) via Cesar de la Vega
(@vegaro)
#### 🐞 Bugfixes
* Fix paywall layout direction for RTL locale overrides (PWENG-39)
(RevenueCat#3425) via Monika Mateska (@MonikaMateska)
* Apply ripple shape clip on a sibling Box to avoid clipping content
(RevenueCat#3395) via Toni Rico (@tonidero)

### 🔄 Other Changes
* build(deps): bump fastlane-plugin-revenuecat_internal from `21e02ec`
to `af7bb5c` (RevenueCat#3442) via dependabot[bot] (@dependabot[bot])
* Abstract workflow page transition animation behind sealed class
(RevenueCat#3430) via Cesar de la Vega (@vegaro)
* Add `single_step_fallback_id` field to `PublishedWorkflow` (RevenueCat#3436) via
Cesar de la Vega (@vegaro)
* build(deps): bump fastlane-plugin-revenuecat_internal from `2d11430`
to `21e02ec` (RevenueCat#3429) via dependabot[bot] (@dependabot[bot])
* Generalize `PaywallComponentsScaffold` for workflow reuse (RevenueCat#3417) via
Cesar de la Vega (@vegaro)
* perf: pre-warm workflow paywall step states off-thread (RevenueCat#3420) via
Cesar de la Vega (@vegaro)
* Update baseline profiles (RevenueCat#3427) via RevenueCat Git Bot (@RCGitBot)
* build(deps): bump fastlane-plugin-revenuecat_internal from `d24ab26`
to `2d11430` (RevenueCat#3426) via dependabot[bot] (@dependabot[bot])
* Replace unauthenticated SDKMAN install with SHA-pinned orb command
(RevenueCat#3407) via Rick (@rickvdl)
* Auto load paywall in paywall tester via local.properties (RevenueCat#3405) via
Cesar de la Vega (@vegaro)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: this is a version/release cut that mainly updates version
strings, changelogs, and doc deployment targets with no functional logic
changes beyond version identifiers.
> 
> **Overview**
> Cuts the `10.4.0` release by removing `-SNAPSHOT` across the project
(core `VERSION_NAME`, `Config.frameworkVersion`, sample/test app
dependency versions, and the root `.version` file).
> 
> Updates release collateral and publishing to point at `10.4.0`,
including changelogs (`CHANGELOG.md`/`CHANGELOG.latest.md`), docs
redirect (`docs/index.html`), and the CircleCI `docs-deploy` S3 sync
path (from `10.4.0-SNAPSHOT` to `10.4.0`).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
f7b3604. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants