Skip to content

[Swift] Evict preloaded checkout under memory pressure#447

Open
markmur wants to merge 3 commits into
android-preload-observabilityfrom
preloading-eviction
Open

[Swift] Evict preloaded checkout under memory pressure#447
markmur wants to merge 3 commits into
android-preload-observabilityfrom
preloading-eviction

Conversation

@markmur

@markmur markmur commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Adds memory-pressure handling for the preloaded checkout WebView, plus related hardening surfaced in review.

A preloaded checkout keeps an idle WKWebView resident in memory until its TTL expires. Under memory pressure iOS asks apps to free memory voluntarily; an app that doesn't free enough is jetsam-killed as a whole process. This adds a DispatchSourceMemoryPressure on the shared cache that proactively releases the idle preloaded WebView when the system signals pressure, lowering the host app's jetsam risk.

What's included

  • Memory-pressure eviction: routed through a single evict() funnel that only reclaims an idle preload. A presented checkout is tagged via a new isPresented flag (set when the view controller takes ownership, cleared on teardown) and is always skipped, so the handler can never tear down an active session.
  • Observable eviction reason: eviction surfaces as PreloadState.evicted(reason:).memoryPressure or .webContentProcessTerminated — so consumers can distinguish why the cache was reclaimed.
  • Web content process termination: WKWebView renders out-of-process and the system can jettison that process independently. An idle preload whose content process dies is evicted (.webContentProcessTerminated); a presented checkout surfaces an error to the delegate rather than sitting blank.
  • invalidate() respects presentation: the public invalidate() no longer detaches the bridge of a presented checkout — it only clears an idle preload.
  • No re-warm under pressure: while the system is signalling pressure, preload() declines to warm the cache, so re-warming can't refill a starved system. present() is unaffected and always loads.

Eviction is automatic — consumers observe .evicted to know the cache was reclaimed and can re-warm at the next appropriate opportunity:

checkoutPreload = ShopifyCheckoutKit.preload(checkout: url) { state in
    if case .evicted = state {
        // Cache was reclaimed. Re-warm later, once pressure has cleared —
        // re-warming immediately would fight the reclaim that just happened.
    }
}

Why ship this

  • Platform contract: honoring DispatchSourceMemoryPressure and web-content-process termination is the idiomatic iOS behaviour for a reclaimable cache; proactively releasing an idle checkout WebView lowers the host app's jetsam risk under pressure.
  • Safe for active sessions: the isPresented guard ensures neither the pressure handler nor invalidate() can tear down a presented checkout.
  • Bounded and low-risk: the cache is single-slot and TTL-bounded, so eviction can only ever reclaim one idle preload.
  • Parity and lifecycle: aligns with the Android eviction ([Android] Evict preloaded checkout under memory pressure #453) and completes the PreloadState lifecycle from [Swift] Add preload state observability #445, keeping the kits — and the React Native wrapper on top of them — symmetric.

@markmur
markmur requested a review from a team as a code owner July 13, 2026 19:52
@github-actions github-actions Bot added the #gsd:50662 Rebase Checkout Kit on UCP label Jul 13, 2026

markmur commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@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 changed the base branch from preloading-impl to graphite-base/447 July 13, 2026 20:02
@markmur
markmur force-pushed the preloading-eviction branch from e917898 to 7279a6f Compare July 13, 2026 20:48
@markmur
markmur force-pushed the graphite-base/447 branch from 43288ca to 03e7678 Compare July 13, 2026 20:48
@markmur
markmur changed the base branch from graphite-base/447 to preloading-impl July 13, 2026 20:48
@markmur
markmur force-pushed the preloading-eviction branch from 7279a6f to df253fc Compare July 13, 2026 21:09
@markmur
markmur force-pushed the preloading-eviction branch from df253fc to 18b6dc0 Compare July 13, 2026 21:13
@markmur markmur self-assigned this Jul 13, 2026
@kieran-osgood-shopify

Copy link
Copy Markdown
Contributor

"Correctness: without the evict() funnel and isPresented guard, a live checkout could be torn down mid-session under memory pressure. This closes that hole, which is the change's main justification."

Have you been able to reproduce iOS tearing down the preload cache? I believe the premise may be slightly off, though your changes still make sense to respond to the system warning, just want to ensure framing is correct / for anyone else following along

Responding to low memory warnings

Under system pressure, iOS will alert running applications to it with the listener you added, it's then on app developers to free up memory, if they dont free up enough memory then the System will start killing apps to free up enough memory.

Notably the System will kill an entire app, on a process by process basis, not individual memory allocations like the cache entry which would lead to app instability.

xcode/responding-to-low-memory-warnings


Valid gap - Missing coverage

The Web Content rendered in the web view (not the cache entry, but the out of process web content) is a unique case as its a separate process and up for collection

We should look at whether we can inform the user that the system tore down the content process (I think this can be acheived with `webViewWebContentProcessDidTerminate``
Then we can explore the UX to recovery


On the PR body, I think the example is a bit misleading for any users that might discover the PR, it demonstrates repopulating the cache immediately after an eviction, which would be counter to the eviction we'd want to recommend they use the DispatchSource to listen for a .normal event before preloading again

checkoutPreload = ShopifyCheckoutKit.preload(checkout: url) { state in
    if state == .evicted {
        // freed under memory pressure — re-warm on the next runloop tick,
        // not synchronously from inside this callback
        DispatchQueue.main.async { 
           preloadCheckoutIfNeeded() 
        }
    }
}

A few questions

  1. Should we consider guarding the preload fn under memory pressure, and always respect .present?

  2. this is tangential to your PR, with the introduction of isPresented guarding teardown under memory pressure should invalidate be guarding against isPresented itself? It seems like a gap that we allow invalidating (detaching communication channel) whilst the checkout is presented right? Not sure if this has happened to anyone before, but feels like it'd be an easy thing to shore up now

@markmur
markmur force-pushed the preloading-eviction branch from 18b6dc0 to 6bd0117 Compare July 15, 2026 12:06
@markmur
markmur force-pushed the preloading-eviction branch from 6bd0117 to 00bddec Compare July 15, 2026 13:08
@markmur
markmur force-pushed the preloading-eviction branch from 00bddec to 1472f29 Compare July 15, 2026 13:09
@markmur
markmur changed the base branch from preloading-impl to graphite-base/447 July 15, 2026 14:42
@markmur
markmur force-pushed the preloading-eviction branch from 1472f29 to 9d501ce Compare July 15, 2026 14:42
@markmur
markmur changed the base branch from graphite-base/447 to android-preload-per-entry July 15, 2026 14:42
@markmur
markmur force-pushed the preloading-eviction branch from 9d501ce to 5fb8321 Compare July 15, 2026 15:00
@markmur
markmur force-pushed the android-preload-per-entry branch from 174d3c9 to 0e05590 Compare July 15, 2026 15:00
@markmur
markmur force-pushed the preloading-eviction branch from 5fb8321 to 4950275 Compare July 15, 2026 15:11
@markmur
markmur force-pushed the android-preload-per-entry branch from 0e05590 to 1ae18db Compare July 15, 2026 15:11
@markmur
markmur force-pushed the android-preload-per-entry branch from e1e680f to c640e93 Compare July 16, 2026 14:18
@markmur
markmur force-pushed the preloading-eviction branch 2 times, most recently from 6312803 to d45eadc Compare July 16, 2026 15:09
@markmur
markmur force-pushed the android-preload-per-entry branch 2 times, most recently from d799837 to a2a3439 Compare July 16, 2026 15:20
@markmur
markmur force-pushed the preloading-eviction branch 2 times, most recently from 23fda26 to d4fff69 Compare July 16, 2026 15:48
@markmur
markmur force-pushed the android-preload-per-entry branch 2 times, most recently from 4e9e834 to 391cb55 Compare July 16, 2026 19:16
@markmur
markmur force-pushed the preloading-eviction branch 2 times, most recently from 37982e2 to e3a3ccf Compare July 16, 2026 19:41
@markmur
markmur force-pushed the android-preload-per-entry branch from 391cb55 to 6447416 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
Swift 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

@markmur
markmur force-pushed the preloading-eviction branch from e3a3ccf to 6f38ed6 Compare July 16, 2026 20:09
@markmur
markmur force-pushed the android-preload-per-entry branch 2 times, most recently from e035af9 to 6a1fb1e Compare July 17, 2026 10:25
@markmur
markmur force-pushed the preloading-eviction branch from 6f38ed6 to 200b47f Compare July 17, 2026 10:25
@markmur
markmur changed the base branch from android-preload-per-entry to graphite-base/447 July 17, 2026 12:48
@markmur
markmur force-pushed the preloading-eviction branch from 200b47f to d812e9a Compare July 17, 2026 12:48
@markmur
markmur force-pushed the graphite-base/447 branch from 6a1fb1e to ba71690 Compare July 17, 2026 12:48
@markmur
markmur changed the base branch from graphite-base/447 to android-preload-observability July 17, 2026 12:48

markmur commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Thanks Kieran, this was spot on - you're right that the framing was off 👍

I've rewritten the description to lead with the accurate reason (proactively releasing an idle WebView lowers the host app's jetsam risk) and dropped the circular "closes a hole" justification. The isPresented guard is now framed as guarding a risk this handler would otherwise introduce, not a pre-existing one. Also fixed the misleading code example! (Good catch)

On the web content process gap: good catch, this was the real missing coverage. Decided to add webViewWebContentProcessDidTerminate to this PR so that an idle preload whose content process is jettisoned is now evicted, and a presented checkout surfaces an error to the delegate instead of sitting blank.

I stopped short of auto-reloading a presented session on termination - that recovery UX feels like a follow up.

---

One more thing that came out of this: .evicted now carries a reason (.memoryPressure / .webContentProcessTerminated) so consumers can distinguish why the cache was reclaimed.

@markmur
markmur force-pushed the android-preload-observability branch from ba71690 to affdb58 Compare July 17, 2026 14:53
@markmur
markmur force-pushed the preloading-eviction branch from b5368a2 to ab0b2d8 Compare July 17, 2026 14:53
@markmur
markmur changed the base branch from android-preload-observability to graphite-base/447 July 17, 2026 16:10
@markmur
markmur force-pushed the preloading-eviction branch from ab0b2d8 to c79c41f Compare July 17, 2026 16:19
@markmur
markmur changed the base branch from graphite-base/447 to android-preload-observability July 17, 2026 16:19
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