Skip to content

Workflow state & ViewModel infrastructure#3416

Merged
vegaro merged 7 commits into
mainfrom
cesar/wfl-46-workflow-state
Apr 30, 2026
Merged

Workflow state & ViewModel infrastructure#3416
vegaro merged 7 commits into
mainfrom
cesar/wfl-46-workflow-state

Conversation

@vegaro

@vegaro vegaro commented Apr 30, 2026

Copy link
Copy Markdown
Member

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 perf: pre-warm workflow paywall step states off-thread #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 cachingbuildStateFromStep 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

  • Unit tests added
  • If applicable, create follow-up issues for `purchases-ios` and hybrids

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.

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

@codecov

codecov Bot commented Apr 30, 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 (eb4093f).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #3416   +/-   ##
=======================================
  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 vegaro changed the title feat: workflow state & ViewModel infrastructure Workflow state & ViewModel infrastructure Apr 30, 2026
@vegaro vegaro force-pushed the cesar/wfl-46-workflow-state branch from 0c815c2 to bb79d4e Compare April 30, 2026 09:39
- NavigationDirection enum (forward / backward / none)
- WorkflowPaywallUiState: currentStepId, lazy step state cache, pendingTransition
- WorkflowPendingTransition: fromStepId + direction + monotonic id, set
  atomically with currentStepId so the first composition after navigation
  already knows both surfaces
- WorkflowNavigator: refactored from StateFlow to plain properties —
  navigation is now synchronous; all reactive state lives in the ViewModel
- PaywallViewModel: adds workflowState / onTransitionComplete; removes
  navigationDirection (direction now lives in WorkflowPendingTransition);
  workflowStepStateCache memoizes computed step states on first visit so
  back-navigation is instant; rebuildWorkflowStepStates handles color-scheme
  changes without resetting navigation position

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vegaro vegaro force-pushed the cesar/wfl-46-workflow-state branch from bb79d4e to 2499bec Compare April 30, 2026 10:12
@vegaro vegaro marked this pull request as ready for review April 30, 2026 10:14
@vegaro vegaro requested a review from a team as a code owner April 30, 2026 10:14
@vegaro vegaro requested a review from facumenzella April 30, 2026 10:14

@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.

a few nits! but looks good

vegaro and others added 4 commits April 30, 2026 15:13
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vegaro vegaro enabled auto-merge April 30, 2026 13:37
@vegaro vegaro added this pull request to the merge queue Apr 30, 2026
@vegaro vegaro removed this pull request from the merge queue due to a manual request Apr 30, 2026
refreshStateIfLocaleChanged only mutated the current step's locale,
leaving every other entry in workflowStepStateCache stale. Navigating
back to a previously visited step would render text in the old locale.
Propagate the new locale to all cached entries in the same pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vegaro vegaro enabled auto-merge April 30, 2026 14:14

@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.

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 81298b7. Configure here.

@vegaro vegaro disabled auto-merge April 30, 2026 14:28
Previously, buildStateFromStep unconditionally wrote a non-null
WorkflowPaywallUiState even when computeStateForStep returned
PaywallState.Error. This left workflowState non-null (implying active
workflow mode) while _state was Error and stepStates[currentStepId] was
absent (errors are not cached). Setting workflowState to null on error
lets the UI fall through to the normal error rendering path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vegaro

vegaro commented Apr 30, 2026

Copy link
Copy Markdown
Member Author

@facumenzella I pushed some small commits after Cursor's suggestions. Mind taking a look?

private var currentStepId: String = workflow.initialStepId

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

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.

maybe it's worth mentioning it's just for testing purposes?

@vegaro vegaro added this pull request to the merge queue Apr 30, 2026
Merged via the queue into main with commit 0e387a3 Apr 30, 2026
40 checks passed
@vegaro vegaro deleted the cesar/wfl-46-workflow-state branch April 30, 2026 15:45
matteinn pushed a commit to matteinn/purchases-android that referenced this pull request May 5, 2026
### Motivation

The lazy step-state cache from RevenueCat#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](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!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.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
a661ac3. 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