Skip to content

fix(workflows): preserve workflow page state across navigation#6889

Merged
facumenzella merged 3 commits into
mainfrom
facu/workflow-preserve-page-state
Jun 4, 2026
Merged

fix(workflows): preserve workflow page state across navigation#6889
facumenzella merged 3 commits into
mainfrom
facu/workflow-preserve-page-state

Conversation

@facumenzella

@facumenzella facumenzella commented Jun 2, 2026

Copy link
Copy Markdown
Member

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's TabControlContext.

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 / onDisappear no longer line up with "shown" / "hidden", which quietly broke paywall analytics: revisiting a step fired no new paywall_viewed, and trackPaywallClose() 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_viewed with a fresh session, and a single paywall_close fires for whichever step is current when the workflow is dismissed. Moving between steps is a workflow layer event (navigate_back, and the future workflow_step_*), not a paywall_close.

Verified in PaywallsTester. All forward (5 steps) then close:

session revision event
15007C86 1 paywall_viewed
A39CA391 1 paywall_viewed
5DA703D8 2 paywall_viewed
1B5FDC89 7 paywall_viewed
57F01D49 14 paywall_viewed
57F01D49 14 paywall_close

5 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_viewed with fresh sessions (they fired nothing before):

session revision event
7E830984 1 paywall_viewed
482F8051 1 paywall_viewed
4339EDB7 2 paywall_viewed
515BE140 7 paywall_viewed
515BE140 7 navigate_back
B07611F1 2 paywall_viewed (revisit)
BD044908 7 paywall_viewed (revisit)
4C379279 14 paywall_viewed
4C379279 14 paywall_close

The 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

  • PR: 6889
  • Branch: facu/workflow-preserve-page-state
  • Author / human owner: Facundo Menzella (facumenzella)
  • Agent(s): Claude Code, Opus 4.8 (1M context)
  • Session source: Current conversation (continuation; the original state-preservation work is summarized from an earlier session, the analytics + isTransitioning work is from this session with full evidence)
  • Generated: 2026-06-04
  • Context document version: 1

Goal

Fix workflow paywall navigation in RevenueCatUI (gated behind -EnableWorkflowsEndpoint, internal-only). Two linked problems:

  1. A step's local UI state (toggle/tab selection) was reset when navigating away and back.
  2. Paywall analytics events did not behave correctly across multi-step navigation.

Initial Prompt

"When navigating workflows state is not preserved (for example, a toggle switch)" (invoked with deep-reasoning / "ultrathink").

Important Follow-up Prompts

  • "isn't WorkflowMountedPages too complex?" → collapse the design to a single seenPages array; renamed "parked" → "seen"; dropped 5 now-redundant tests.
  • "state does not propagate backwards ... however state propagates forward" → confirmed the carry-forward (nav-time snapshot) model and per-step isolated PackageContext.
  • Spec-validation thread (read the Monetization Event Naming spec in Notion) → switched the paywall-event model from per-leave closes to Reading 2 (per-visit viewed, single close for the current step).
  • "Commit and push, and let's include this tables in the PR description."

Agent Contribution

  • Implemented the page-mounting fix: one ordered @State seenPages: [RenderedPage] (each RenderedPage gained stepId), rendering all visited pages with non-current ones hidden (opacity 0, zIndex -1, allowsHitTesting(false), accessibilityHidden(true)). (Largely prior session.)
  • isTransitioning fix: hidden mounted pages now receive isTransitioning: false (addresses vegaro review).
  • Added PurchaseHandler.trackPaywallClose(sessionID:) (per-session close, forwards to the tracker; leaves activePaywallSessionID/exit-offer path untouched).
  • PaywallsV2View: added isActiveWorkflowPage: Bool? and drove paywall_viewed / paywall_close off activation instead of view lifecycle; parametrized createEventData(sessionID:).
  • WorkflowPaywallView: passes isActive = page.id == currentPage?.id.
  • Added unit test testTrackPaywallCloseBySessionClosesThatSpecificSessionNotJustTheActiveOne.
  • Ran build + tests, analyzed device logs, updated the PR description with verified event tables.

Human Decisions

  • Chose "keep visited pages mounted" over externalizing each component's state.
  • Chose to inline WorkflowMountedPages into seenPages and drop its 5 tests.
  • Set the forward-only state-propagation constraint (no backward propagation).
  • Reading 2 over Reading 1 for paywall events (per-visit viewed, a single close for 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).
  • Approved commit + push and the PR-description update.

Key Implementation Decisions

  • Decision: Keep every visited step mounted (seenPages).
    • Rationale: SwiftUI ties a @StateObject's lifetime to view identity; tearing the page down reset component state.
    • Rejected: Externalizing each component's state out of the view tree.
  • Decision: Single seenPages source of truth (render list + per-step cache).
    • Rationale: prior 3 overlapping caches (mountedPages + stepPages + stepPackageContexts) held the same per-step data; the cached page already holds its PackageContext by reference.
    • Rejected: parallel per-step dictionaries.
  • Decision: Drive paywall events off the active/current role via isActiveWorkflowPage (nil = standalone, unchanged).
    • Rationale: once pages stay mounted, onAppear/onDisappear no longer mean shown/hidden.
  • Decision: Reading 2 — per-visit paywall_viewed (fresh session each visit), one paywall_close for the step current at dismiss.
    • Rationale: Monetization Event Naming spec + colleague guidance; paywall_viewed/paywall_close are discrete events, and step transitions belong to the workflow-events layer.
    • Rejected: Reading 1 (a paywall_close on every leave) — would be one extra else { firePaywallClose() } branch on the activation onChange.
  • Decision: Per-session close via trackPaywallClose(sessionID:), leaving activePaywallSessionID set.
    • Rationale: the global active session is deliberately kept alive for exit-offer resolution.

Files / Symbols Touched

  • RevenueCatUI/Purchasing/PurchaseHandler.swift
    • Why: close a specific session, not just the globally last-active one.
    • Symbols: trackPaywallClose(sessionID:)
    • Review relevance: confirm it does not disturb activePaywallSessionID / exit-offer flow.
  • RevenueCatUI/Templates/V2/PaywallsV2View.swift
    • Why: activation-driven per-visit events; standalone path must stay identical.
    • Symbols: isActiveWorkflowPage, firePaywallViewed(sessionID:), firePaywallClose(), createEventData(forDefaultPaywall:sessionID:), addPaywallModifiers
    • Review relevance: the isActiveWorkflowPage == nil branch must be byte-equivalent to old onAppear/onDisappear behavior; fresh-session-per-visit correctness.
  • RevenueCatUI/Templates/V2/WorkflowPaywallView.swift
    • Why: pass isActive; gate isTransitioning for hidden pages.
    • Symbols: seenPageView, pageView(for:isActive:), RenderedPage.stepId
    • Review relevance: isActive = page.id == currentPage?.id mapping during transitions.
  • Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift
    • Why: cover per-session close (the core bug).
    • Symbols: testTrackPaywallCloseBySessionClosesThatSpecificSessionNotJustTheActiveOne

Dependencies / Config / Migrations

  • Feature flag: -EnableWorkflowsEndpoint (runtime launch arg) gates all workflow UI. No new dependencies, schema, or public API. PaywallsV2View is an internal type, so the added init param is not part of the tracked public API surface.

Validation

  • Commands run:
    • xcodebuild test -scheme RevenueCatUITests -only-testing:.../PurchaseHandlerTests -only-testing:.../PaywallViewEventsFullscreenLightModeTests -only-testing:.../PaywallViewEventsFooterDarkModeTests: 35 tests, 0 failures.
    • swiftlint lint on the 4 changed files: 0 violations.
  • Manual verification (PaywallsTester on device, -EnableWorkflowsEndpoint):
    • All-forward 5 steps + close → 5 paywall_viewed (5 distinct sessions), 1 paywall_close matching the current step's session.
    • Forward → one back → forward to end + close → revisited steps re-fire paywall_viewed with fresh sessions (B07611F1, BD044908); leave fires navigate_back (component_interaction), not a close; 1 paywall_close on the current step.
    • Toggle/tab state confirmed preserved across back-navigation.
  • CI: Not captured (push triggers CircleCI; two manual-approval holds were pending pre-push).

Validation Gaps

  • No automated test for the SwiftUI activation→event wiring; it is lifecycle-coupled and verified via device logs per the repo testability policy. The per-session close (the core correctness piece) is unit-tested, and the standalone path is regression-covered by PaywallViewEventsTests (14 cases).
  • showCloseButton is frozen at first-visit value on revisit (correct for linear workflows; revisit for cycle/DAG reachability).
  • Eligibility check still runs once per mount (not per visit) — intentional.

Review Focus

  • Is the standalone (isActiveWorkflowPage == nil) path byte-equivalent to the previous onAppear/onDisappear tracking?
  • Does isActive = page.id == currentPage?.id flip at the right moment during a transition (incoming becomes current at beginTransition)?
  • Fresh-session-per-visit: is paywallSessionID regenerated and used consistently by the impression, the close, and the componentInteractionLogger?
  • Reading 2 is intentional: non-current steps' sessions are left open by design (Cursor Bugbot flagged this as "missing close on leave").

Risks / Reviewer Notes

  • Risk: Transition re-render cost grows with navigation history (all seen pages read transitionState).
    • Evidence: hidden pages are opacity 0 but still incur layout.
    • Mitigation: not addressed; acceptable for typical short workflows; noted as follow-up.
  • Risk: Non-current steps' paywall sessions stay "open" (no close).
    • Evidence: intentional per Reading 2; paywall_viewed/paywall_close are 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).
    • Mitigation: step transitions will be reported by the workflow-events layer (workflow_step_*), not yet in the iOS SDK.
  • Risk: Change lives in the shared PaywallsV2View, used by standalone paywalls too.
    • Evidence: 14 PaywallViewEvents tests pass unchanged; standalone branch untouched.

Non-goals / Out of Scope

  • Workflow-events layer (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.
  • Reading 1 (per-leave paywall_close).

Omitted Context

  • Raw transcript, unrelated exploration, sensitive details, repetitive attempts, and chain-of-thought-style content were omitted.

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). PaywallsV2View takes isActiveWorkflowPage and fires paywall_viewed when a step becomes current (new session on each revisit) and paywall_close for that step’s session when the workflow is dismissed. PurchaseHandler adds trackPaywallClose(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.

@facumenzella facumenzella requested review from a team as code owners June 2, 2026 15:33
@facumenzella facumenzella changed the title fix: preserve workflow page state across navigation fix(workflows): preserve workflow page state across navigation Jun 2, 2026
Comment thread RevenueCatUI/Templates/V2/WorkflowPaywallView.swift Outdated
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>
@facumenzella facumenzella force-pushed the facu/workflow-preserve-page-state branch from 19f0fb7 to 4f4def9 Compare June 2, 2026 15:49

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

two comments that I think are important before approving

Comment thread RevenueCatUI/Templates/V2/WorkflowPaywallView.swift Outdated
Comment thread RevenueCatUI/Templates/V2/WorkflowPaywallView.swift
…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>
@facumenzella facumenzella requested a review from a team as a code owner June 4, 2026 09:58
Comment thread RevenueCatUI/Templates/V2/PaywallsV2View.swift

@cursor cursor Bot left a comment

Copy link
Copy Markdown

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 d4a1372. Configure here.

Comment thread RevenueCatUI/Templates/V2/PaywallsV2View.swift

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

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

@facumenzella facumenzella merged commit 0b51535 into main Jun 4, 2026
18 of 20 checks passed
@facumenzella facumenzella deleted the facu/workflow-preserve-page-state branch June 4, 2026 13:48
@facumenzella

Copy link
Copy Markdown
Member Author

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.

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.

3 participants