fix(workflows): preserve workflow page state across navigation#6889
Conversation
Keep visited workflow pages mounted (hidden off-screen) instead of tearing them down, so per-step component state like a tab/toggle selection survives navigating away and back. The seen pages live in one ordered list that also serves as the per-step page cache, so revisiting a step reuses the same SwiftUI identity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
19f0fb7 to
4f4def9
Compare
vegaro
left a comment
There was a problem hiding this comment.
two comments that I think are important before approving
…orkflows Workflow pages stay mounted to preserve state, so onAppear/onDisappear no longer mean shown/hidden. That broke paywall analytics: revisiting a step fired no new paywall_viewed, and trackPaywallClose() only ever closed the single activePaywallSessionID (last impressed), so every non-current step's session dangled open. Drive the events off the active/current role instead of view lifecycle: - Add PurchaseHandler.trackPaywallClose(sessionID:) so a page closes its own session, leaving activePaywallSessionID (exit-offer resolution) untouched. - PaywallsV2View gains isActiveWorkflowPage (nil = standalone, unchanged): workflow pages fire viewed with a fresh per-visit session on becoming current, and close their own session only when current at teardown. - WorkflowPaywallView passes isActive = page is the current step. - Gate isTransitioning so hidden mounted pages don't see the transition flag. Per-visit viewed + a single close for the current step, matching the Monetization Event Naming spec (step-to-step moves are the workflow layer). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ 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 d4a1372. Configure here.
MonikaMateska
left a comment
There was a problem hiding this comment.
Looks solid! I'd just keep an eye on the behavior of components inside hidden pages, they stay mounted, so anything time-based (videos, looping carousels, animations) keeps running invisibly at opacity 0. We might be burning CPU/battery on pages the user may never revisit
|
Good catch @MonikaMateska. Followed up in #6913: hidden pages now pause their carousel auto-advance and video playback while off-screen (they stay mounted, so onDisappear never fires, which is exactly why they kept running). Left the declarative animations (repeatForever/TimelineView) as a separate follow-up since they don't run a timer/player. |

Navigating between workflow steps was wiping out a step's local UI state. Flip a toggle (or pick a tab) on one step, go forward, then come back, and it'd snap back to the default.
The cause: every navigation tore the page you left out of the view hierarchy (the outgoing page was dropped once the transition finished, and each navigation minted a fresh page identity). SwiftUI ties a
@StateObject's lifetime to its view identity, so the destination subtree got rebuilt from scratch on the way back, resetting things like the toggle'sTabControlContext.Fix keeps every visited step mounted in
seenPages(hidden off-screen with opacity 0, no hit testing, accessibility hidden), so its subtree and the state it owns survive leaving and returning. The transition math and header overlay (#6877, #6880) are untouched, the current/outgoing render path is identical, only the hidden seen pages are new. All behind-EnableWorkflowsEndpoint, so it's internal-only for now.Paywall events
Keeping pages mounted meant
onAppear/onDisappearno longer line up with "shown" / "hidden", which quietly broke paywall analytics: revisiting a step fired no newpaywall_viewed, andtrackPaywallClose()only ever closed the last impressed session, so every earlier step's session dangled open. (This is the side effect the earlier reviews flagged.)Events now fire off the active step instead of the view lifecycle, matching the Monetization Event Naming spec: each step visit fires its own
paywall_viewedwith a fresh session, and a singlepaywall_closefires for whichever step is current when the workflow is dismissed. Moving between steps is a workflow layer event (navigate_back, and the futureworkflow_step_*), not apaywall_close.Verified in PaywallsTester. All forward (5 steps) then close:
15007C86A39CA3915DA703D81B5FDC8957F01D4957F01D495 viewed, 1 close on the current step's own session.
Forward to step 4, one back to step 3, then forward to the end and close. The revisited steps now re-fire
paywall_viewedwith fresh sessions (they fired nothing before):7E830984482F80514339EDB7515BE140515BE140B07611F1BD0449084C3792794C379279The toggle and tab state also sticks across forward/back now, and the transitions look the same.
🤖 AI Context
Decision log, files touched, validation, and reviewer focus (generated from the build session)
Metadata
facu/workflow-preserve-page-statefacumenzella)isTransitioningwork is from this session with full evidence)Goal
Fix workflow paywall navigation in RevenueCatUI (gated behind
-EnableWorkflowsEndpoint, internal-only). Two linked problems:Initial Prompt
"When navigating workflows state is not preserved (for example, a toggle switch)" (invoked with deep-reasoning / "ultrathink").
Important Follow-up Prompts
seenPagesarray; renamed "parked" → "seen"; dropped 5 now-redundant tests.PackageContext.viewed, singleclosefor the current step).Agent Contribution
@State seenPages: [RenderedPage](eachRenderedPagegainedstepId), rendering all visited pages with non-current ones hidden (opacity 0,zIndex -1,allowsHitTesting(false),accessibilityHidden(true)). (Largely prior session.)isTransitioningfix: hidden mounted pages now receiveisTransitioning: false(addresses vegaro review).PurchaseHandler.trackPaywallClose(sessionID:)(per-session close, forwards to the tracker; leavesactivePaywallSessionID/exit-offer path untouched).PaywallsV2View: addedisActiveWorkflowPage: Bool?and drovepaywall_viewed/paywall_closeoff activation instead of view lifecycle; parametrizedcreateEventData(sessionID:).WorkflowPaywallView: passesisActive = page.id == currentPage?.id.testTrackPaywallCloseBySessionClosesThatSpecificSessionNotJustTheActiveOne.Human Decisions
WorkflowMountedPagesintoseenPagesand drop its 5 tests.viewed, a singleclosefor the step current at dismiss; step-to-step moves are a workflow-layer event). This was the human's call, informed by a colleague's explicit guidance and the event spec, and it overrode the agent's initial lean toward Reading 1 (close-per-leave).Key Implementation Decisions
seenPages).@StateObject's lifetime to view identity; tearing the page down reset component state.seenPagessource of truth (render list + per-step cache).mountedPages+stepPages+stepPackageContexts) held the same per-step data; the cached page already holds itsPackageContextby reference.isActiveWorkflowPage(nil= standalone, unchanged).onAppear/onDisappearno longer mean shown/hidden.paywall_viewed(fresh session each visit), onepaywall_closefor the step current at dismiss.paywall_viewed/paywall_closeare discrete events, and step transitions belong to the workflow-events layer.paywall_closeon every leave) — would be one extraelse { firePaywallClose() }branch on the activationonChange.trackPaywallClose(sessionID:), leavingactivePaywallSessionIDset.Files / Symbols Touched
RevenueCatUI/Purchasing/PurchaseHandler.swifttrackPaywallClose(sessionID:)activePaywallSessionID/ exit-offer flow.RevenueCatUI/Templates/V2/PaywallsV2View.swiftisActiveWorkflowPage,firePaywallViewed(sessionID:),firePaywallClose(),createEventData(forDefaultPaywall:sessionID:),addPaywallModifiersisActiveWorkflowPage == nilbranch must be byte-equivalent to oldonAppear/onDisappearbehavior; fresh-session-per-visit correctness.RevenueCatUI/Templates/V2/WorkflowPaywallView.swiftisActive; gateisTransitioningfor hidden pages.seenPageView,pageView(for:isActive:),RenderedPage.stepIdisActive = page.id == currentPage?.idmapping during transitions.Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swifttestTrackPaywallCloseBySessionClosesThatSpecificSessionNotJustTheActiveOneDependencies / Config / Migrations
-EnableWorkflowsEndpoint(runtime launch arg) gates all workflow UI. No new dependencies, schema, or public API.PaywallsV2Viewis an internal type, so the added init param is not part of the tracked public API surface.Validation
xcodebuild test -scheme RevenueCatUITests -only-testing:.../PurchaseHandlerTests -only-testing:.../PaywallViewEventsFullscreenLightModeTests -only-testing:.../PaywallViewEventsFooterDarkModeTests: 35 tests, 0 failures.swiftlint linton the 4 changed files: 0 violations.-EnableWorkflowsEndpoint):paywall_viewed(5 distinct sessions), 1paywall_closematching the current step's session.paywall_viewedwith fresh sessions (B07611F1,BD044908); leave firesnavigate_back(component_interaction), not a close; 1paywall_closeon the current step.Validation Gaps
PaywallViewEventsTests(14 cases).showCloseButtonis frozen at first-visit value on revisit (correct for linear workflows; revisit for cycle/DAG reachability).Review Focus
isActiveWorkflowPage == nil) path byte-equivalent to the previousonAppear/onDisappeartracking?isActive = page.id == currentPage?.idflip at the right moment during a transition (incoming becomes current atbeginTransition)?paywallSessionIDregenerated and used consistently by the impression, the close, and thecomponentInteractionLogger?Risks / Reviewer Notes
transitionState).opacity 0but still incur layout.close).paywall_viewed/paywall_closeare discrete events; session state is bounded to the presentation's lifetime and never emits wrong events (fresh sessionID per visit, so no accidental auto-close).workflow_step_*), not yet in the iOS SDK.PaywallsV2View, used by standalone paywalls too.PaywallViewEventstests pass unchanged; standalone branch untouched.Non-goals / Out of Scope
workflow_step_started/workflow_step_completed) — not in the iOS SDK; separate work.rc_event-name prefixes,paywall_*→checkout_*reclassification, and source-context properties from the spec — separate effort.paywall_close).Omitted Context
Note
Medium Risk
Changes shared PaywallsV2View event wiring and paywall session tracking; standalone path is guarded but workflow analytics semantics (no close on step leave) need reviewer alignment with the events spec.
Overview
Workflow steps keep their UI state when users navigate forward and back. Previously each transition dropped the outgoing page, so toggles, tabs, and package selections reset. The workflow now keeps every visited step in
seenPages, mounted but hidden off-screen, and reuses the same page instance when a step is revisited.Paywall analytics no longer rely on
onAppear/onDisappear(which broke once pages stayed mounted).PaywallsV2ViewtakesisActiveWorkflowPageand firespaywall_viewedwhen a step becomes current (new session on each revisit) andpaywall_closefor that step’s session when the workflow is dismissed.PurchaseHandleraddstrackPaywallClose(sessionID:)so a mounted step can close its own session instead of only the last-impressed one.Standalone paywalls are unchanged (
isActiveWorkflowPage == nil). Workflow behavior is behind-EnableWorkflowsEndpoint.Reviewed by Cursor Bugbot for commit d4a1372. Bugbot is set up for automated code reviews on this repo. Configure here.