Skip to content

[Swift] Scope preload state to per-cache entry#454

Closed
markmur wants to merge 2 commits into
mainfrom
swift-preload-per-entry
Closed

[Swift] Scope preload state to per-cache entry#454
markmur wants to merge 2 commits into
mainfrom
swift-preload-per-entry

Conversation

@markmur

@markmur markmur commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Promotes the preload cache's single global slot into a per-entry state machine, so each preloaded checkout owns its own lifecycle state, observer, and keep-alive/expiry timers. This removes the singleton-observer behavior and is forward-compatible with a future multi-entry keyed cache.

What changed

  • Entry promoted to a @MainActor class carrying state, a weak observer, and its own keep-alive/expiry timers (transition(to:), stopTimers()).
  • PreloadCache keys observers by PreloadKey via a handles registry (register(_:for:)). State changes route per entry through transition(_ view:to:) (view-guarded), plus markIdle() and a preloadedView accessor.
  • Navigation callbacks (didFinish, didFail, handleResponse) transition the matching entry instead of a shared cache-level slot.
  • CheckoutPreload is key-bound and owns its own state, so distinct preload keys observe independently rather than sharing one slot.

Public API surface is unchanged (api/ShopifyCheckoutKit.json unmodified).

PreloadObservabilityTests rewritten to per-entry semantics; testNewObserverReplacesPrevious replaced with testDistinctKeysObserveIndependently.

markmur commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

@markmur
markmur marked this pull request as ready for review July 13, 2026 22:11
@markmur
markmur requested a review from a team as a code owner July 13, 2026 22:11
@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown

React Native — Coverage Report

Lines Statements Branches Functions
Coverage: 92%
91.85% (327/356) 87.98% (183/208) 100% (86/86)

@markmur
markmur force-pushed the preloading-eviction branch from 18b6dc0 to 6bd0117 Compare July 15, 2026 12:06
@markmur
markmur force-pushed the swift-preload-per-entry branch 2 times, most recently from 2cac69b to 4087102 Compare July 15, 2026 13:08
@markmur
markmur force-pushed the preloading-eviction branch 2 times, most recently from 00bddec to 1472f29 Compare July 15, 2026 13:09
@markmur
markmur force-pushed the swift-preload-per-entry branch from 4087102 to 0c56668 Compare July 15, 2026 13:09
@markmur
markmur changed the base branch from preloading-eviction to graphite-base/454 July 15, 2026 14:42
@markmur
markmur force-pushed the graphite-base/454 branch from 1472f29 to bc1be61 Compare July 15, 2026 14:42
@markmur
markmur force-pushed the swift-preload-per-entry branch from 0c56668 to 9e05f19 Compare July 15, 2026 14:42
@markmur
markmur changed the base branch from graphite-base/454 to preloading-impl July 15, 2026 14:42
@markmur
markmur force-pushed the swift-preload-per-entry branch from 9e05f19 to 00d2d04 Compare July 15, 2026 15:00
@markmur
markmur force-pushed the swift-preload-per-entry branch from 00d2d04 to c8f76e8 Compare July 15, 2026 15:11
@markmur
markmur force-pushed the swift-preload-per-entry branch from c8f76e8 to 2cfbff0 Compare July 15, 2026 16:01
@markmur markmur self-assigned this Jul 15, 2026
@markmur
markmur force-pushed the swift-preload-per-entry branch 2 times, most recently from 912a186 to 6c6f096 Compare July 16, 2026 13:53
@markmur
markmur force-pushed the swift-preload-per-entry branch 2 times, most recently from 6876cd9 to 599dafd Compare July 16, 2026 15:09
@markmur
markmur force-pushed the swift-preload-per-entry branch from 599dafd to aaf688c Compare July 16, 2026 15:20
@markmur
markmur force-pushed the swift-preload-per-entry branch from b0aff16 to e67aa39 Compare July 16, 2026 19:16
Base automatically changed from preloading-impl to main July 16, 2026 19:40
@markmur
markmur force-pushed the swift-preload-per-entry branch from e67aa39 to a896563 Compare July 16, 2026 19:41
@bitrise

bitrise Bot commented Jul 16, 2026

Copy link
Copy Markdown

Install this build

Open Tophat, select your target device, then click Install. Links open on the Mac running Tophat.

SDK Install
React Native Install with Tophat
Swift Install with Tophat
Kotlin Install with Tophat

Checkout Kit E2E results

Status Suite Target Platform OS version tag Device
tests/shared/launch-smoke.yaml swift ios latest iPhone 15
iOS 27 Beta

Copy link
Copy Markdown
Contributor

Moving the timer state handling into the Entry is a good direction and I think moving the other two I commented on below would also be consistent


I'm struggling to get my head around the cache work here so forgive the rambling here!

On the handles dictionary - can you explain why we key the observers instead of the entries?

I picturing prep work for this to look something like this: Cache<Entry>(capacity: 1) - and we'd be wiring up ShopifyCheckoutKit.preload in platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift to check the Cache for an entry and return it if not stale

If I (+claude) am reading things right, it seems like every preload creates a new CheckoutPreload, which in turn replaces the entry.observer so only the latest preload is subscribed, after this the values in the handles are effectively dead

I don't think the CheckoutPreload is pulled from the handles dictionary on a subsequent preload effectively because preload creates a new CheckoutPreload every preload (platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift:55`) leaving consumers with dead subscriptions that won't revive

There also doesn't seem to be a terminal transition state for those preloads that are no longer the current one, they'll stay in the state they were in after invalidate, we cancel the timers but users aren't aware that its now a dead reference


Just to play the YAGNI devils advocate - if we aren't supporting multi cache entry in this release should we hold off on this until we are able to fully build out the multi slot cache? (Keeping the timer moves into the Entry as that looks clean)

Comment on lines +180 to 207
private func startKeepAlive(for entry: Entry) {
entry.keepAliveTimer?.invalidate()
let timer = Timer.scheduledTimer(withTimeInterval: Self.keepAliveInterval, repeats: true) { [weak self, weak entry] _ in
Task { @MainActor in
guard let entry else { return }
do {
_ = try await view?.evaluateJavaScript("void 0")
_ = try await entry.view.evaluateJavaScript("void 0")
} catch {
OSLogger.shared.debug("Preload keep-alive failed; invalidating preload cache")
self?.keepAliveDidFail()
}
}
}
timer.tolerance = Self.keepAliveInterval / 2
keepAliveTimer = timer
entry.keepAliveTimer = timer
}

private func startExpiryTimer(after interval: TimeInterval) {
stopExpiryTimer()
private func startExpiryTimer(for entry: Entry) {
entry.expiryTimer?.invalidate()
let interval = entry.remainingTTL
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
Task { @MainActor in
self?.expire()
}
}
timer.tolerance = min(interval / 2, 1)
expiryTimer = timer
entry.expiryTimer = timer
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like it'd make sense to roll these into the Entry?
They can reference keepAliveDidFail / self.expire via weak ref back to the parent

@markmur

markmur commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Just to play the YAGNI devils advocate - if we aren't supporting multi cache entry in this release should we hold off on this until we are able to fully build out the multi slot cache? (Keeping the timer moves into the Entry as that looks clean)

Yeah went back and forth on this but I've been thinking the same @kieran-osgood-shopify. It sets us up for multi-slot but it's easier if we just do it when we need it. I think I'll defer it until then

@markmur markmur closed this Jul 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

#gsd:50662 Rebase Checkout Kit on UCP

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants