Skip to content

feat(workflows): synchronously seed workflow paywall from warm cache#6905

Merged
facumenzella merged 6 commits into
mainfrom
facu/workflow-sync-seed
Jun 8, 2026
Merged

feat(workflows): synchronously seed workflow paywall from warm cache#6905
facumenzella merged 6 commits into
mainfrom
facu/workflow-sync-seed

Conversation

@facumenzella

@facumenzella facumenzella commented Jun 4, 2026

Copy link
Copy Markdown
Member

Follow-up to #6887.

Now that the workflows cache (#6882) landed and is synchronously readable, PaywallView can 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), cachedInitialWorkflowContext builds the WorkflowContext synchronously, and PaywallView seeds 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 resolveWorkflowContext into a shared, non-throwing makeWorkflowContext so 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

Goal

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

  • "Anything we're missing for this PR. How do you know its working" (drove explicit verification + gap analysis).
  • "Can you spin up paywalls tester ... don't forget about copying local.xcconfig" (manual verification path).
  • "add the logs that we need" then "let's strip them" (temporary observability, then revert).

Agent Contribution

  • Diagnosed the conflict as stacked-PR drift: the base (feat(workflows): Enable workflow resolution for PaywallView #6887, branch codex/codex-20260602-155608) had gained b728b635e ("refactor: dedup cachedInitialOffering switch"), which relocated cachedInitialOffering(for:workflowsEndpointEnabled:) above and out of #if !os(tvOS), colliding with this PR's edits to the same overload's comment.
  • Resolved the single conflict in RevenueCatUI/Purchasing/PurchaseHandler.swift.
  • Mid-session feat(workflows): Enable workflow resolution for PaywallView #6887 squash-merged into main (5e621e195) and its branch was deleted; re-rebased this PR onto origin/main via git rebase --onto origin/main b728b635e facu/workflow-sync-seed so it carries only its single commit. Force-pushed with lease.
  • Verified builds and ran the relevant Tuist unit tests.
  • Spun up PaywallsTester (mafdet) against the PR worktree and confirmed the warm-cache seed path at runtime by temporarily adding two Logger.debug lines, then stripped them.

Human Decisions

  • Test against the PR worktree, not the main checkout.
  • Strip the temporary debug logs rather than keep them as observability (PR diff left unchanged at 9 files).
  • Author recorded the "no more loading" video attached above as their own manual verification.

Key Implementation Decisions

  • Decision: in the conflict resolution, keep the base's relocated/unguarded overload, adopt this PR's reworded comment on it, and place the new cachedInitialWorkflowContext with the other workflow code inside #if !os(tvOS).
    • Rationale: cachedInitialWorkflowContext calls cachedWorkflow(...), 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).
    • Rejected: merging instead of rebasing (would retain duplicate feat(workflows): Enable workflow resolution for PaywallView #6887 commits after the squash-merge and keep the PR conflicting).

Files / Symbols Touched

  • RevenueCatUI/Purchasing/PurchaseHandler.swift
    • Why: only conflicted file during rebase.
    • Symbols: cachedInitialOffering(for:workflowsEndpointEnabled:), cachedInitialWorkflowContext, makeWorkflowContext, NotConfiguredPurchases.cachedWorkflow.
    • Review relevance: confirm every workflow symbol stays inside #if !os(tvOS); confirm presentedOfferingContext is derived the same way as the async resolvePaywallViewData path.

(The other 8 files in the diff applied cleanly during rebase and were not edited by the agent.)

Dependencies / Config / Migrations

  • None. No new dependencies, flags, or schema changes. (Local.xcconfig, Package.resolved, and a StoreKitConfig.storekit were touched only locally for PaywallsTester spin-up and are not part of the diff.)

Validation

  • Commands run:
    • swift build --target RevenueCatUI: success (host).
    • xcodebuild -scheme RevenueCatUI -destination 'generic/platform=tvOS' build: BUILD SUCCEEDED.
    • Tuist UnitTests scheme, WorkflowManagerTests: 25 passed / 0 failed.
    • Tuist RevenueCatUITests scheme, PaywallViewConfigurationTests: 13 passed / 0 failed (via RevenueCat-Tuist.xcworkspace).
    • swiftlint on PurchaseHandler.swift: clean.
  • Manual verification:
    • PaywallsTester (iPhone 17 Pro sim, -EnableWorkflowsEndpoint): on present, the temporary log confirmed cachedInitialWorkflowContext returned a context and the paywall rendered with no loading state. Author also attached a screen recording.
  • CI (on rebased commit 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

  • The cold/stale async-fallback branch was not exercised in the manual session; the feat(workflows): fetch workflows list with offerings and clear it on identity changes #6883 list prefetch warmed the cache before every present, so only the warm path was observed at runtime (it is covered by unit tests, including testCachedWorkflowReturnsNilWhenCachedWorkflowIsStale).
  • The PaywallView.init @State seeding wiring is not directly unit-tested (SwiftUI @State cannot be seeded/inspected outside a view init; documented in code). Pure logic is covered by cachedInitialWorkflowContext tests.

Review Focus

  • tvOS: are all workflow symbols (cachedInitialWorkflowContext, makeWorkflowContext, cachedWorkflow usages) inside #if !os(tvOS)?
  • Does the synchronously seeded WorkflowContext match what the async path produces (shared makeWorkflowContext, same presentedOfferingContext derivation), so there is no visible swap?
  • Staleness: is a stale cached workflow correctly skipped so it is never seeded?

Risks / Reviewer Notes

  • Risk: seeded context diverging from the async-resolved context, causing a brief swap.
    • Evidence: both paths build via the shared non-throwing makeWorkflowContext; seed returns nil on any partial/stale/cold hit.
    • Mitigation: covered by PaywallViewConfigurationTests (warm + nil cases) and WorkflowManagerTests (freshness).

Non-goals / Out of Scope

  • Handling on_purchase_press / on_close_workflow_press workflow trigger types (separate PRs; this branch logs them as unknown).
  • StoreKit test-config product metadata warnings ("MISSING_METADATA" / empty titles) seen during manual testing are environmental, not part of this change.

Omitted Context

  • Raw transcript, unrelated exploration, sensitive values (API keys redacted), repetitive attempts, and chain-of-thought content were omitted.

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 ?? offeringId fallback 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).

  • Tests (WorkflowManagerTests, test-first): added …WhenWorkflowsListIsStale and …WhenOfferingHasNoListMapping, repurposed the old fallback test to …WhenListNeverFetched, tightened …WhenCachedWorkflowIsStale to fresh-list + stale-detail. 27/0 pass; the 3 new tests were watched failing before the fix.
  • Still open from review: [FINAL] Automatic purchaser info updating. #2 (offeringNotFound reports 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, PaywallView now seeds workflowContext and the workflow-mapped initialOffering in its initializer so the paywall can render immediately instead of showing the redacted loading placeholder.

A new synchronous cachedWorkflow(forOfferingIdentifier:) path runs from WorkflowManager through Purchases and PaywallPurchasesType. 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.

PurchaseHandler adds cachedInitialWorkflowContext for that seed and refactors workflow resolution around shared makeWorkflowContext, which both the sync seed (try? → nil) and async resolve use. Async callers now take the offering from context.initialOffering, and offeringNotFound can 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.

@facumenzella facumenzella force-pushed the facu/workflow-sync-seed branch from 7fe7bdf to e15f61a Compare June 4, 2026 08:48
Base automatically changed from codex/codex-20260602-155608 to main June 4, 2026 10:36
@facumenzella facumenzella force-pushed the facu/workflow-sync-seed branch from e15f61a to caa60af Compare June 4, 2026 10:48
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>
@facumenzella facumenzella force-pushed the facu/workflow-sync-seed branch from caa60af to 309ca83 Compare June 4, 2026 10:50
…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>
@facumenzella facumenzella marked this pull request as ready for review June 4, 2026 13:44
@facumenzella facumenzella requested review from a team as code owners June 4, 2026 13:44
@facumenzella facumenzella requested a review from vegaro June 4, 2026 13:44
Comment thread RevenueCatUI/Purchasing/PurchaseHandler.swift Outdated
Comment thread Sources/Purchasing/WorkflowManager.swift
Comment thread RevenueCatUI/Purchasing/PurchaseHandler.swift Outdated
Comment thread RevenueCatUI/Purchasing/PurchaseHandler.swift Outdated
Comment thread RevenueCatUI/Purchasing/PurchaseHandler.swift Outdated
facumenzella and others added 2 commits June 5, 2026 13:44
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>
@facumenzella facumenzella requested a review from vegaro June 5, 2026 11:47

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

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

Comment thread RevenueCatUI/Purchasing/PurchaseHandler.swift Outdated
facumenzella and others added 2 commits June 5, 2026 13:55
…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 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.

Thanks for the updates. I think it looks good now

@facumenzella facumenzella merged commit ce0a0fd into main Jun 8, 2026
18 of 20 checks passed
@facumenzella facumenzella deleted the facu/workflow-sync-seed branch June 8, 2026 11:58
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