feat(workflows): synchronously seed workflow paywall from warm cache#6905
Merged
Conversation
7fe7bdf to
e15f61a
Compare
e15f61a to
caa60af
Compare
Seed the WorkflowContext (and its mapped offering) synchronously when the workflow and offerings are already cached and fresh, so a warm cache renders without a loading state. On a cold, stale, or partial cache the seed is nil and the existing async resolve path takes over. Stale entries deliberately fall through to the refetch so we never seed out-of-date data. The context-building logic is pulled out of the async resolveWorkflowContext into a shared, non-throwing makeWorkflowContext used by both paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
caa60af to
309ca83
Compare
…plicit mapping cachedWorkflow(forOfferingId:) now returns nil unless the workflows list is fresh and explicitly maps the offering, instead of falling back to the offering id and checking only the workflow detail TTL. A stale list mapping (or the offering-id fallback) could seed the wrong workflow, and a synchronous seed skips the view's async refresh, so there was no correction. Stale/missing list and unmapped offerings now fall through to the async resolve path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vegaro
reviewed
Jun 5, 2026
Addresses vegaro's review on #6905: - Snapshot purchases.cachedOfferings once in cachedInitialWorkflowContext so the default-offering lookup and the workflow's base offering resolve against the same value (the property can be swapped on a background thread, and reading it twice could mix two snapshots). - Use the offering.presentedOfferingContext convenience instead of reaching into availablePackages.first?.presentedOfferingContext, in both the sync seed and the async resolveWorkflowPaywallViewData path. - makeWorkflowContext / resolveWorkflowContext now return just WorkflowContext; callers read context.initialOffering instead of an offering returned alongside it. - Document why cachedWorkflow(forOfferingId:) hardcodes isAppBackgrounded: false (seed only runs while a paywall is presented, i.e. foreground, so the stricter foreground TTL is the safe choice). 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.
❌ 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 b7d62c0. Configure here.
…ound Extracting makeWorkflowContext collapsed two distinct failures into a nil return, so resolveWorkflowContext threw offeringNotFound using the trigger offering identifier even when it was the workflow screen's own offering that was missing from the offerings snapshot. Restore the pre-refactor behavior: makeWorkflowContext now throws, reporting the screen's offeringIdentifier when that offering is absent and the trigger identifier when there is no initial screen. The async resolve path propagates the error; the synchronous cache seed treats any throw as a miss via try? and falls through to the async path. Adds a test asserting the thrown identifier is the screen offering id. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vegaro
approved these changes
Jun 8, 2026
vegaro
left a comment
Member
There was a problem hiding this comment.
Thanks for the updates. I think it looks good now
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Follow-up to #6887.
Now that the workflows cache (#6882) landed and is synchronously readable,
PaywallViewcan seed the workflow context straight from cache instead of always showing a loading state while the async resolve runs.When workflows are on and both the workflow and its offerings are already cached (and fresh),
cachedInitialWorkflowContextbuilds theWorkflowContextsynchronously, andPaywallViewseeds both the offering and the context in its init, so a warm cache renders right away. On a cold, stale, or partial cache it returns nil and the existing async path takes over, exactly like before. Stale entries deliberately fall through to the refetch so we never seed something out of date.I pulled the context building out of the async
resolveWorkflowContextinto a shared, non-throwingmakeWorkflowContextso both paths build the context identically.This is the seeding revisit I mentioned in the #6887 review thread (
PurchaseHandler.swift"I don't follow this / re-fetch even if offering passed").See that there's no more loading
https://github.com/user-attachments/assets/837a45cd-8ec8-45df-ad8e-a1be1de5f854
AI session context
Metadata
facu/workflow-sync-seedGoal
Get #6905 mergeable and verified. The feature itself (synchronous warm-cache seeding of the workflow
PaywallView) was authored before this session; the agent's contribution was conflict resolution, a re-rebase after the base PR merged, and build/test/manual verification.Initial Prompt
"Let's first address the merge conflicts".
Important Follow-up Prompts
Agent Contribution
codex/codex-20260602-155608) had gainedb728b635e("refactor: dedup cachedInitialOffering switch"), which relocatedcachedInitialOffering(for:workflowsEndpointEnabled:)above and out of#if !os(tvOS), colliding with this PR's edits to the same overload's comment.RevenueCatUI/Purchasing/PurchaseHandler.swift.main(5e621e195) and its branch was deleted; re-rebased this PR ontoorigin/mainviagit rebase --onto origin/main b728b635e facu/workflow-sync-seedso it carries only its single commit. Force-pushed with lease.Logger.debuglines, then stripped them.Human Decisions
Key Implementation Decisions
cachedInitialWorkflowContextwith the other workflow code inside#if !os(tvOS).cachedInitialWorkflowContextcallscachedWorkflow(...), which is#if !os(tvOS)-only; placing it next to the now-unguarded overload would break the tvOS build. A macOS/host build would not catch this (the guard is true on macOS).Files / Symbols Touched
RevenueCatUI/Purchasing/PurchaseHandler.swiftcachedInitialOffering(for:workflowsEndpointEnabled:),cachedInitialWorkflowContext,makeWorkflowContext,NotConfiguredPurchases.cachedWorkflow.#if !os(tvOS); confirmpresentedOfferingContextis derived the same way as the asyncresolvePaywallViewDatapath.(The other 8 files in the diff applied cleanly during rebase and were not edited by the agent.)
Dependencies / Config / Migrations
Local.xcconfig,Package.resolved, and aStoreKitConfig.storekitwere touched only locally for PaywallsTester spin-up and are not part of the diff.)Validation
swift build --target RevenueCatUI: success (host).xcodebuild -scheme RevenueCatUI -destination 'generic/platform=tvOS' build: BUILD SUCCEEDED.UnitTestsscheme,WorkflowManagerTests: 25 passed / 0 failed.RevenueCatUITestsscheme,PaywallViewConfigurationTests: 13 passed / 0 failed (viaRevenueCat-Tuist.xcworkspace).swiftlintonPurchaseHandler.swift: clean.-EnableWorkflowsEndpoint): on present, the temporary log confirmedcachedInitialWorkflowContextreturned a context and the paywall rendered with no loading state. Author also attached a screen recording.309ca833c):run-test-ios-26,run-revenuecat-ui-ios-26,check-api-changes-revenuecat,check-api-changes-revenuecatui, emerge snapshot: pass.run-all-tests/approve-full-tests: on hold (manual approval gate).Validation Gaps
testCachedWorkflowReturnsNilWhenCachedWorkflowIsStale).PaywallView.init@Stateseeding wiring is not directly unit-tested (SwiftUI@Statecannot be seeded/inspected outside a view init; documented in code). Pure logic is covered bycachedInitialWorkflowContexttests.Review Focus
cachedInitialWorkflowContext,makeWorkflowContext,cachedWorkflowusages) inside#if !os(tvOS)?WorkflowContextmatch what the async path produces (sharedmakeWorkflowContext, samepresentedOfferingContextderivation), so there is no visible swap?Risks / Reviewer Notes
makeWorkflowContext; seed returns nil on any partial/stale/cold hit.PaywallViewConfigurationTests(warm + nil cases) andWorkflowManagerTests(freshness).Non-goals / Out of Scope
on_purchase_press/on_close_workflow_pressworkflow trigger types (separate PRs; this branch logs them as unknown).Omitted Context
Update — review follow-up (commit
4059da81e)Addressed external-review findings #1 (list-staleness half) and #4.
WorkflowManager.cachedWorkflow(forOfferingId:)now seeds only when the workflows list is fresh and explicitly maps the offering: removed the?? offeringIdfallback and the detail-only freshness check, so a stale/missing list or an unmapped offering falls through to the async path (which refetches the list and resolves correctly). Rationale: a synchronous seed skips the view's async refresh, so a stale mapping or fallback guess would render the wrong workflow with no correction. The offerings-TTL half of #1 is intentionally left as parity with existing non-workflow seeding (cachedInitialOffering).WorkflowManagerTests, test-first): added…WhenWorkflowsListIsStaleand…WhenOfferingHasNoListMapping, repurposed the old fallback test to…WhenListNeverFetched, tightened…WhenCachedWorkflowIsStaleto fresh-list + stale-detail. 27/0 pass; the 3 new tests were watched failing before the fix.offeringNotFoundreports the requested offering, not the missing screen offering id) and [FINAL] Remove purchasing KVO property. #3 (view-layer test that the loading placeholder is skipped on a warm seed).Note
Medium Risk
Changes first-frame paywall resolution and cache eligibility for workflows; incorrect seeding could show the wrong paywall briefly, mitigated by freshness checks and falling back to async resolve.
Overview
When workflows are enabled and the workflows list, workflow detail, and offerings are already cached and fresh,
PaywallViewnow seedsworkflowContextand the workflow-mappedinitialOfferingin its initializer so the paywall can render immediately instead of showing the redacted loading placeholder.A new synchronous
cachedWorkflow(forOfferingIdentifier:)path runs fromWorkflowManagerthroughPurchasesandPaywallPurchasesType. It only returns data when the offering→workflow list mapping and the workflow payload are both fresh (no stale-list or offering-id fallback), so a bad seed does not skip the async refresh.PurchaseHandleraddscachedInitialWorkflowContextfor that seed and refactors workflow resolution around sharedmakeWorkflowContext, which both the sync seed (try?→ nil) and async resolve use. Async callers now take the offering fromcontext.initialOffering, andofferingNotFoundcan report the workflow screen’s missing offering id.Unit tests cover warm-cache hits, partial/stale misses, and list-mapping strictness.
Reviewed by Cursor Bugbot for commit 4ee9e69. Bugbot is set up for automated code reviews on this repo. Configure here.