Actor refactor: PaywallViewCache, TestMode and Entitlements - #466
Conversation
There was a problem hiding this comment.
Caution
Dropping the eager LoadingView/ShimmerView seeding from PaywallViewCache.init breaks the unchecked retrieveView(LoadingView.TAG) as LoadingView cast in SuperwallPaywallActivity, which is reachable from the shipped getPaywall() + startWithView() flow. See the inline comment on the init block.
Reviewed changes — full read of the 34-file diff at a9d47c4, plus the actor primitives in misc/primitives/, every ViewStorage consumer, and the reset/identify paths that invalidate web entitlements.
PaywallViewCachemoved ontoSequentialActor— view map and active key live inPaywallCacheState;save/removePaywallView/removeAllbecamesuspend(propagated toPaywallManagerand its tests);ViewStoragedemoted from source of truth to a write-through mirror hydrated once at construction.- Loading/shimmer views now lazy — created inside
acquireLoadingView()/acquireShimmerView()behind arunBlockingbridge instead of eagerly in the constructor. TestModebecame an actor — immutableTestModeSessionData,Updatesreducers,RefreshProducts/Activateactions, pureTestModeLogic.evaluate, and an injectedshowModallambda.ConfigContext.activateTestModeremoved —ConfigStatecallsTestMode.activatedirectly;ConfigStatealso moved fromconfig.modelstoconfig, which accounts for most of the import churn.Entitlementsbecame a facade overEntitlementsState— pure reducers,createInitialEntitlementsState(storage)replay before the actor starts, explicitpersist(...)in place of the status-flow collector.webentitlements changed from a live storage read to cached state — pushed in through the newWebPaywallRedeemer.Factory.setWebEntitlementshook at three write sites.- Tests — new
PaywallViewCacheTest(30 cases),EntitlementsRefactorSafetyTest(46 cases), reworkedEntitlementsTest, two newTestModeTestmodal-flow cases, andverify→coVerifymigrations.
I confirmed parity for the parts most likely to regress silently: all/active/inactive/byProductId are faithful ports (including the pre-existing quirk where AddProductEntitlements overwrites rather than merges allTracked), inactive == all - active held before and after because the old _inactive was always empty, and Superwall.reset(duringIdentify = true) runs storage.reset() before reedemer.clear(...) so the cached web set is invalidated on reset and identity switch.
⚠️ ViewStorage is now a dual source of truth with unsynchronised external writers
The class doc calls ViewStorage a write-through mirror for "external readers (SuperwallPaywallActivity, DebugView)", but both of those also write to it directly, bypassing the actor: SuperwallPaywallActivity.kt:162 (removeView(key) on launch failure), :192 and :289 (storeView), and DebugView.kt:928. Because every cache read now comes from state.value instead of ViewStorage, those writes are invisible to the cache — where the old implementation read through and picked them up.
Technical details
# Cache state and ViewStorage can diverge under external writes
## Affected sites
- `superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt:164-171` — `ViewStorage` is read exactly once, at construction, via `Updates.Hydrate`.
- `superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt:173-187` — `entries`, `activePaywallVcKey`, `activePaywallView`, `getAllPaywallViews`, `getPaywallView` all read `state.value`.
- `superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt:159-164` — on `launchPaywallActivity` failure, `makeViewStore().removeView(key)` evicts from `ViewStorage` only.
- `superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt:192`, `:289` — `storeView(key, …)` writes to `ViewStorage` only.
- `superwall/src/main/java/com/superwall/sdk/debug/DebugView.kt:928` — `storeView(key, view)` writes to `ViewStorage` only.
## Concrete divergence
After a failed activity launch, `ViewStorage` no longer holds `key` but `PaywallCacheState.views` still does. The next
`PaywallManager.getPaywallView(...)` (`PaywallManager.kt:79`) gets a cache **hit** on the evicted view instead of falling
through to `factory.makePaywallView(...)`. Pre-PR, `getPaywallView` read `store.retrieveView(key)` and correctly returned
`null`, forcing a fresh view.
`PaywallCacheState.Actions.RemoveAllExceptActive` (`PaywallViewCache.kt:90-96`) also narrowed from
"every key in `ViewStorage`" to "every key the actor knows about", so `DebugView`'s entries are no longer swept by
`resetCache()`.
## Required outcome
Cache reads and `ViewStorage` must not be able to disagree about which keys exist. Either route the external writers
through the cache actor, or make the reads authoritative on `ViewStorage` again for the keys those writers own.
## Open questions for the human
- Is `ViewStorage` meant to remain a long-term second source of truth, or is the intent to migrate
`SuperwallPaywallActivity`/`DebugView` onto the cache actor in a follow-up? The answer changes whether this is a
bug to fix now or a documented interim state.
- `RemoveAllExceptActive` no longer sweeping foreign keys looks like an improvement rather than a regression —
confirm that's intentional.ℹ️ Behaviour this PR changes that no added test exercises
Two gaps stand out, both on paths that only fail at runtime on a device:
- The invariant that
acquireLoadingView()/acquireShimmerView()must have run beforeSuperwallPaywallActivity.prepareViewForDisplayreads the tags is now load-bearing, and nothing asserts it.PaywallViewCacheTesthas no case that readsLoadingView.TAGfromViewStoragebefore anacquire*call. - The new
setWebEntitlementsplumbing is stubbed as a no-op inWebPaywallRedeemerTest.kt:153, and there is noDependencyContainertest, so nothing in this PR proves theWebPaywallRedeemer→DependencyContainer→Entitlementschain is wired correctly. TheEntitlementsTestcase that previously covered a user switch by mutatingLatestRedemptionResponsein storage (old lines 750-766) was rewritten to callentitlements.setWebEntitlements(...)directly, so it now tests the setter rather than the scenario.
The PR checklist also still has UI tests, demo build and ktlint unchecked — worth completing before merge given the first finding is a device-only crash.
ℹ️ Nitpicks
PaywallViewCacheTest.kt:418—assertTrue(views.size in 0..ids.size)passes for essentially any outcome, including a cache that always returns an empty list. It only proves "does not throw".PaywallViewCacheTest.kt:393—assertTrue(final!!.startsWith("k_"))would accept a corrupted concatenation like"k_3k_17";assertTrue(final in (0 until 50).map { "k_$it" })pins it.EntitlementsRefactorSafetyTest.kt:654-659—And("it should not contain active entitlements")filtersinactivebyactiveIdsbefore asserting, so the leak it claims to catch is filtered away.assertTrue(inactive.none { it.id in activeIds })is the assertion the name promises.EntitlementsRefactorSafetyTest.kt:279— the test name says "becomes Inactive" but the body assertsstatus is Active(correctly, per the implementation). Renaming it avoids a future "fix" silently inverting the contract.EntitlementsRefactorSafetyTest.kt:1160and:1193—async(Dispatchers.Default) { delay(1.seconds) }.await()adds two real seconds of wall time to wait for a collector, butpersist(StoredSubscriptionStatus, …)is now a synchronous call insidesetSubscriptionStatus, so the wait is no longer needed.EntitlementsRefactorSafetyTest.kt/EntitlementsTest.kt— theStateActor<…>(createInitialEntitlementsState(storage), scope).let { actor -> Entitlements(storage, actor, actorScope) }block is repeated in nearly every test. A singlemakeEntitlements(storage, scope)helper would remove several hundred lines and make the actual test bodies legible.TestModeTest.kt:1215—expectedStatusis computed by callingmanager.buildSubscriptionStatus(), the same production function whose output is under test, so theActivepayload is never pinned. The sibling no-activity test hard-codesSubscriptionStatus.Inactiveand reads much stronger.TemplateLogic.kt— the added blank line at the top ofobject TemplateLogicis unrelated to this PR.TestModeModalResultwas widened frominternalto public to support the injectedshowModallambda, which pullsEntitlementSelectionandFreeTrialOverrideinto the supported public surface.
Claude Opus | 𝕏
There was a problem hiding this comment.
ℹ️ No new issues — all three prior findings are properly addressed. One import-ordering nit inline.
Reviewed changes — the delta since the prior pullfrog review at a9d47c4, i.e. 7cf3375 "Address review on actor refactor" (plus the coverage-badge commit 7d1d11d). I re-read the full 38-file diff, re-traced the getPaywall() + startWithView() path end to end, grepped every ViewStorage writer in src/main, and checked the actor/runBlocking bridge for main-thread hazards.
- Routed external
ViewStoragewriters through the cache —PaywallManager.cacheis nowinternal, andSuperwallPaywallActivity(:161,:192,:290) andDebugView.kt:927callcache.removeView/cache.storeView, which write both the mirror and the actor state. Grep confirms no directViewStoragewrites remain insrc/main. - Fixed the
startWithViewcold-start crash —prepareViewForDisplayacquires throughcache.acquireLoadingView()/cache.acquireShimmerView()instead of the uncheckedretrieveView(TAG) as LoadingViewcasts. - Tightened
RemoveAllExceptActive— it now evicts exactly the key set it snapshotted (Updates.RemoveViews(evicted)), so a view stored concurrently under a new key survives in both stores. - Wired the cache actor to the container scope —
actorbecame a required constructor parameter andDependencyContainer.makeCache()passesSequentialActor(PaywallCacheState(), ioScope), matchingEntitlementsandTestMode. - Moved the polling
setWebEntitlementsinside the persist guard — memory can no longer hold web entitlements that storage does not. - Strengthened the tests —
PaywallViewCacheTestgained five cases for the external-writer and lazy-acquire paths and replaced its two loose assertions with exact ones;EntitlementsRefactorSafetyTestfixed the self-defeatinginactivefilter, renamed the contradicting test, and dropped two real-timedelay(1.seconds)waits;TestModeTestpins the granted entitlement set instead of deriving it from the code under test;WebPaywallRedeemerTestnow asserts the published web entitlements and drops a strayprintln; the repeatedEntitlementsconstruction moved intoEntitlementsTestHelpers.makeEntitlements.
I checked the new main-thread runBlocking bridge specifically: no action on the cache actor's FIFO ever hops to Dispatchers.Main, and DependencyContainer.ioScope is never cancelled nor SequentialActor.close() ever called, so acquireLoadingView() from startWithViewOnMain cannot deadlock on any path I could find.
ℹ️ ViewStorage stays a parallel surface that nothing structurally protects
The write side is now clean, but reads still go straight to ViewStorage (SuperwallPaywallActivity.kt:246/:257/:848, DebugView.kt:961-964) and makeViewStore() still hands out a fully mutable ViewStorage to any caller. The new invariant in the class doc — "Every write must go through this cache … so the two never disagree about which keys exist" — is therefore enforced only by a comment, and a future storeView/removeView added outside PaywallViewCache would silently reintroduce the divergence this commit just fixed. Not a bug today; a scope question worth settling before this lands.
Technical details
# The cache/ViewStorage write invariant is documented, not enforced
## Affected sites
- `superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt:145-155` — class doc states the invariant.
- `superwall/src/main/java/com/superwall/sdk/paywall/view/ViewStorage.kt` — public interface whose `storeView` / `removeView` default methods mutate the shared `ConcurrentHashMap`.
- `superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt:1119-1120` — `makeViewStore()` returns that mutable `ViewStorageViewModel` to any caller.
- `superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt:246`, `:257`, `:848` and `superwall/src/main/java/com/superwall/sdk/debug/DebugView.kt:961-964` — remaining direct readers.
## Required outcome
Either it is structurally impossible to write `ViewStorage` outside `PaywallViewCache`, or the dual track is an explicitly documented interim state with a follow-up owner.
## Open questions for the human
- Is `ViewStorage` on a path to deletion once the remaining readers migrate onto the cache actor, or is it a permanent Activity-facing surface?
- If permanent: is narrowing `makeViewStore()` to a read-only view (leaving the mutable type internal to `PaywallViewCache`) worth doing now, while the call sites are fresh?Claude Opus | 𝕏
There was a problem hiding this comment.
ℹ️ No new issues — both new commits are correct and well-targeted. One test-naming nit inline and one scope question below.
Reviewed changes — the delta since the prior pullfrog review at 7cf3375: 1b59a44e "Route activity view access through PaywallViewRegistry" and 690086f8 "Keep status entitlements in all after a cold start". I re-read the full 40-file diff, re-traced all seven former makeViewStore() call sites, and enumerated the stored-state combinations for createInitialEntitlementsState against the pre-PR Entitlements.init visible in the diff's removed lines.
- Added
PaywallViewRegistryand routed Activity/debug access through it — the new internal interface plusPaywallViewCache.asRegistry()is reached viaDependencyContainer.makeViewRegistry();SuperwallPaywallActivity(:161,:170,:247,:290,:848) andDebugView(:927,:965) no longer touchmakeViewStore()orPaywallManager.cache, which went back toprivate. This settles the "write invariant is documented, not enforced" question from the last review for every Activity-facing call site. - Fixed a cold-start regression in
createInitialEntitlementsState—AddProductEntitlementsnow replays before the stored status. PreviouslySetActiveran first and the product replay overwroteallTracked, so a status-only entitlement disappeared fromallwhile still appearing inactive— anactive ⊄ allviolation on every cold start with both a cached status and cached product entitlements. - Added
OffMainViewConstructionTest— instrumented proof thatLoadingView/ShimmerViewconstruct on a Looper-less thread and still measure, draw and animate once handed to main. This closes the standing question about the actor-thread view construction introduced in81724b5. - Added two unit tests — a
PaywallViewCacheTestcase asserting a registrystoreView/removeViewround-trip lands in both the cache state andViewStorage, and anEntitlementsRefactorSafetyTestcase pinningallto exactly{status_only, product_only}on a cold start. Both fail against the code they replaced.
I verified the registry indirection is behaviour-preserving: every cache write path writes viewStorage before actor.update with no suspension point between, so the registry's retrieveView (which reads viewStorage) is never behind the cache state. activityProvider and paywallManager are both assigned in the DependencyContainer constructor before any Activity can reach the container, so makeViewRegistry() forcing factory.makeCache() introduces no new throw path at the two unguarded call sites. I also enumerated stored status × product map × redemption response × corrupted-read combinations for the entitlements reorder and found the post-reorder state equivalent to pre-PR everywhere.
ℹ️ The allTracked overwrite is fixed only for the cold-start replay
The reorder makes the init path correct, but AddProductEntitlements itself still assigns allTracked = newProducts.values.flatten().toSet() rather than unioning. The same drop therefore recurs at runtime the first time addEntitlementsByProductId fires after a status update — the normal boot order once the store finishes loading products. That is exact parity with pre-PR addEntitlementsByProductId (_all.clear(); _all.addAll(...)), so it is not a regression and nothing here blocks the merge. It is worth confirming the pre-PR behaviour was deliberate, given this PR had to work around it at init.
Technical details
# `AddProductEntitlements` replaces `allTracked` instead of merging it
## Affected sites
- `superwall/src/main/java/com/superwall/sdk/store/EntitlementsState.kt:114` — `allTracked = newProducts.values.flatten().toSet()` discards anything `SetActive` previously tracked.
- `superwall/src/main/java/com/superwall/sdk/store/EntitlementsState.kt:162-168` — the new comment documents the overwrite and orders around it for the init path only.
- `superwall/src/main/java/com/superwall/sdk/store/Entitlements.kt:146-149` — `addEntitlementsByProductId` is the runtime caller; it fires after config/product load, i.e. after the restored status is already in state.
## Behaviour
With a cached `Active(setOf(status_only))` and products that grant only `product_only`, `all` is `{status_only, product_only}` immediately after construction (correct, thanks to `690086f8`) and becomes `{product_only}` once `addEntitlementsByProductId` runs, while `active` still reports `status_only`. Pre-PR code did the same thing, so this is a parity question, not a regression.
## Open questions for the human
- Was the pre-PR `_all.clear()` in `addEntitlementsByProductId` intentional, or an old bug that the actor refactor faithfully carried over?
- If unintentional, is changing the reducer to `allTracked = state.allTracked + newProducts.values.flatten()` in scope for this PR, or a follow-up?Claude Opus | 𝕏
There was a problem hiding this comment.
ℹ️ No new issues —
cc00a10is test-only and correct. The one remaining open item is the earlierOffMainViewConstructionTestnaming nit, which is still unaddressed.
Reviewed changes — the delta since the prior pullfrog review at 690086f, i.e. cc00a10 "Minor fixes".
- Fixed the
PaywallViewCacheTestimport ordering —org.junit.Afternow sorts ahead of theorg.junit.Assert.*block. The prior thread has been resolved. - Added an end-to-end web-entitlements wiring test —
WebPaywallRedeemerTestnow builds a realEntitlementsviamakeEntitlements, wiresTestFactory.setWebEntitlementsFntoentitlements.setWebEntitlements, restores user A's redemption from storage, then runsstorage.clean()+redeemer.clear(AppUser)in the same order asSuperwall.reset. This fills the gap raised in the first review, where thesetWebEntitlementshook was stubbed out as a no-op and the user-switch scenario only tested the setter.
I checked that the new test can actually fail. After storage.clean(), clear() takes the latestResponse == null branch (WebPaywallRedeemer.kt:483). If that factory.setWebEntitlements(emptySet()) call were missing, entitlements.web would keep its cached {userA_web} and the first Then assertion would fail. The active.none { … } check matters too, because EntitlementsState.active includes webEntitlements (EntitlementsState.kt:32). The test still goes through TestFactory, not DependencyContainer.setWebEntitlements, but that override is a one-line forward (DependencyContainer.kt:1268-1269).
Still open from the prior review: the OffMainViewConstructionTest.kt:78 thread. cacheAcquireFromMainBuildsViewsOnTheActorThread never checks which thread built the views.
Claude Opus | 𝕏
There was a problem hiding this comment.
ℹ️ No blocking issues.
ce0f663fixes the runtimeallTrackeddrop that the last review raised. Two small follow-ups are inline.
Reviewed changes — everything since the prior pullfrog review at cc00a10: ce0f663 "Merge product entitlements into allTracked instead of replacing" and the develop merge 4aa8e17. The range-diff shows the merge only touched the coverage badges.
- Changed
AddProductEntitlementsfrom overwrite to union. A status-only entitlement now stays inallafteraddEntitlementsByProductIdruns, soactive ⊆ allholds at runtime as well as on cold start. This is a deliberate change from the pre-PR_all.clear()behaviour. - Added a runtime regression test.
addEntitlementsByProductId keeps status-only entitlements in allrestoresActive({status_only}), then adds a product mapping, and pinsallto exactly{status_only, product_only}. I traced it against the old reducer:allwould have been{product_only}there, so the test really can fail. - Renamed the off-main cache test. It is now
cacheAcquireFromMainReturnsUsableViewsWithoutDeadlocking, which matches what it asserts. I replied to and resolved the prior thread.
Claude Opus | 𝕏
There was a problem hiding this comment.
ℹ️ No blocking issues.
620e459fixes a realfrom == toregression and closes both prior threads. One test-assertion nit is inline.
Reviewed changes: the delta since the prior pullfrog review at 4aa8e17, which is just 620e459 "Fix delegate status 'from', keep allTracked status-only, pin delegate override". Five files changed. The other regions of the full diff are unchanged since earlier reviews.
- Fixed the
fromargument passed tosubscriptionStatusDidChange. This PR moved status persistence into a synchronousEntitlements.persist, so by the time the collector ran,storage.read(StoredSubscriptionStatus)already returned the new status. The collector now pairs values withscan, the same idiom the_customerInfocollector below it uses.filter { it.first != null }drops the initial emission exactly asdrop(1)did. - Stopped
AddProductEntitlementswriting toallTracked.allTrackednow holds only status entitlements.allstill picks up product entitlements throughentitlementsByProduct.values.flatten(), so a remapped product key no longer leaves its old entitlement inall/inactive. The newdrops entitlements of a remapped product from alltest would fail against thece0f663reducer. The old test was renamed toaccumulates entitlements across adds. Both prior threads have been replied to and resolved. - Added
SubscriptionStatusDelegateOverrideTest. It pins the integrator pattern of callingsetSubscriptionStatusfrom insidesubscriptionStatusDidChange, using the realaddListenerscollector, realEntitlementsand a realSuperwallDelegateAdapter. The orderedcallsassertions really can fail: with the old storage read, the relaxed storage returnsnullfor every read, so everyfromwould beUnknown, and the second pair (for examplesetOf("pro") to setOf("pro", "custom")) would not match. - Added an
EntitlementsRefactorSafetyTestcase for a status collector re-setting the status. It covers the same re-entrant pattern at theEntitlementslevel. - Fixed the
savestub inPaywallManagerExperimentIsolationTest. It changed fromeverytocoEvery, which matchesPaywallViewCache.savenow beingsuspend.
I did not run Gradle in this sandbox. Everything above was checked by reading the code.
Claude Opus | 𝕏
Migrates PaywallViewCache and TestMode onto the StateActor primitives: PaywallCacheState/PaywallCacheContext own the view map and active key, TestModeState gains pure reducers and async actions with TestModeContext and TestModeLogic, and ConfigState moves up to the config package. Rebased onto develop (Sep 2026) with these adaptations: - Keep develop's loadingColor on PaywallViewCache/LoadingView. - Port develop's productsLoaded/awaitTestProducts so StoreManager can wait for the test product catalog; the deferred is carried through session copies. - SetActive preserves an existing session and only clears entitlement selections when the activation reason changes, matching develop. - TestMode.activate is suspend and awaited via `immediate`; ConfigState launches it in its scope so the modal never blocks config. - Cache view factories no longer hop to Dispatchers.Main: acquire* block the caller (usually main) with runBlocking, so that hop deadlocked. - Keep develop's ensureActive guard in PaywallMessageHandler. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ports the entitlements slice of the March actor draft (ir/refactor/actors) onto current develop: - EntitlementsState holds status, product entitlements, device, backing and web entitlements as an immutable snapshot with pure reducers; `all`, `active` and `inactive` are derived from it. createInitialEntitlementsState rebuilds the snapshot from storage before the actor starts, including web entitlements from the latest redemption response. - Entitlements becomes a facade over a StateActor implementing EntitlementsContext. Status changes and product entitlement updates are persisted immediately instead of through a collected flow. - Web entitlements are cached in state rather than re-read from storage on every access, so WebPaywallRedeemer now publishes them through a new Factory.setWebEntitlements hook at each point it writes the redemption response. - Adds EntitlementsRefactorSafetyTest (46 cases) and reworks EntitlementsTest for the actor construction. Differences from the draft: the constructor keeps `Entitlements(storage)` working via defaults, and EntitlementsContext no longer carries HasExternalPurchaseControllerFactory since no action used it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Acquire loading/shimmer views through the cache in SuperwallPaywallActivity so getPaywall() + startWithView() no longer crashes when they have not been created yet. - Route external ViewStorage writes (activity launch/restore, DebugView) through new synchronous PaywallViewCache.storeView/removeView so cache state and ViewStorage cannot diverge; RemoveAllExceptActive now removes exactly the keys it evicted. - Run the cache actor on the container ioScope instead of a leaked scope. - Only publish polled web entitlements when they are persisted. - Tests: cover external writers and lazy loading/shimmer, assert the redeemer publishes what it persists, tighten weak assertions, drop obsolete delays, and extract a makeEntitlements helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SuperwallPaywallActivity and DebugView now reach paywall views through an internal PaywallViewRegistry obtained from DependencyContainer.makeViewRegistry(), instead of reaching into paywallManager.cache or ViewStorage directly. Writes go through the cache so it and ViewStorage stay in sync; reads use ViewStorage, which survives Activity recreation. PaywallManager's cache is private again. Adds an on-device test proving the loading and shimmer views can be built on a thread without a Looper, as the cache actor does, and still draw and animate on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
createInitialEntitlementsState replayed the saved status before the stored product entitlements. AddProductEntitlements replaces allTracked, so status entitlements not tied to a product dropped out of `all` while still in `active`, until the next status update. The old startup code never replaced that set. Restore product entitlements first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AddProductEntitlements overwrote allTracked, so a status-only entitlement dropped out of `all` once product entitlements loaded while still showing in `active`. Also rename the off-main cache acquire test to match what it asserts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… override - Take the delegate's previous status from the status flow. Entitlements now persists before the listener runs, so reading storage gave from == to. - AddProductEntitlements no longer writes allTracked; `all` already unions product entitlements, and this avoids stale entries on a remapped product. - Add SubscriptionStatusDelegateOverrideTest for setting the status from inside subscriptionStatusDidChange (add, remove, replace, grant). - Stub the suspend PaywallViewCache.save with coEvery in PaywallManagerExperimentIsolationTest so unit tests compile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b9bd997 to
4933348
Compare
|
Pullfrog billing is temporarily unavailable. payment processing — retry shortly Usually transient; the next dispatch should succeed. If it persists, check status.pullfrog.com or your console. |
|
Pullfrog billing is temporarily unavailable. payment processing — retry shortly Usually transient; the next dispatch should succeed. If it persists, check status.pullfrog.com or your console. |

Changes in this pull request
Continues the actor migration after identity (#387) and config (#400). Two commits:
Add actors for PV cache and TestMode
PaywallViewCachestate (view map + active key) now lives in aSequentialActorviaPaywallCacheState/PaywallCacheContext; loading and shimmer views are acquired atomically across concurrent callers.TestModebecomes an actor:TestModeStategains pure reducers and async actions (product refresh, activation/modal), withTestModeContextand a pureTestModeLogicfor evaluation rules.ConfigStatecallsTestMode.activatedirectly instead of the removedactivateTestModecallback.ConfigStatemoves fromconfig.modelstoconfig.loadingColor,awaitTestProducts/productsLoaded, session preservation on re-activation, and theensureActiveguard inPaywallMessageHandler. The cache view factories no longer hop toDispatchers.Main(callers block withrunBlockingfrom the main thread, so that deadlocked), andTestMode.activateis nowsuspendwithConfigStatelaunching it in its own scope.Move Entitlements onto the StateActor primitives
EntitlementsStateholds status, product, device, backing and web entitlements as an immutable snapshot with pure reducers;createInitialEntitlementsStaterebuilds it from storage before the actor starts.Entitlementsis a facade over the actor; its public/internal API is unchanged so callers are untouched.WebPaywallRedeemerpublishes them through a newFactory.setWebEntitlementshook wherever it writes the redemption response.EntitlementsRefactorSafetyTest(46 cases) and reworksEntitlementsTest.Not ported from the old draft (#383):
SdkState, which depended on that draft'sSdkContext. #383 can be closed once this lands.Checklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.ktlintin the main directory and fixed any issues.🤖 Generated with Claude Code