Repository navigation
Conversation
|
This PR does not match any of the 1 configured review trigger rule. |
Maple review🟢 Confidence 9/10 · safe to merge Tracks web-document readiness separately from paywall state and replays What was checked
|
There was a problem hiding this comment.
ℹ️ No critical issues — one suggestion inline to close the remaining reload gaps.
Reviewed changes
Reviewed the full diff: per-document readiness in PaywallMessageHandler, its call sites, the tests and the changelog entry.
- Readiness moved off
paywall.paywalljsVersion—isDocumentReadyis reset bydocumentWillLoad()and set byonReady, so a value left over from a dead page no longer counts as ready. - Open state tracked in the handler —
isPaywallOpenfollowspaywall_open/paywall_close, anddocumentWillLoad()queuespaywall_openagain for the next document while the paywall is open. - Hooks at every SDK-initiated load —
loadWebView(), the refresh button'sreloadWebView()andwebViewWebContentProcessDidTerminateall calldocumentWillLoad(). - Analytics unchanged —
trackOpen()still runs once per presentation, so the trackedpaywall_open,didPresentPaywallandstorage.trackPaywallOpen()are not repeated. - Tests — six new
PaywallMessageHandlerTestscover open before and after ready, a reload while open, while hidden and after close, and a page dying before it is ready.
I traced both reported paths (killed while presented → reload(); killed while preloaded → didFailToLoad → loadWebView() in viewWillAppear → trackOpen()). Both now deliver paywall_open exactly once to the new page, and a hidden preload is never told it is open.
claude-opus-5.5 | 𝕏
| } | ||
| } | ||
|
|
||
| func documentWillLoad() { |
There was a problem hiding this comment.
paywall_open is re-armed when the SDK starts a load, not when a new document pings. A new document that pings without an SDK-started load before it still misses paywall_open and stays blank. One case is a page that reloads itself, such as the dev server's live reload of a DevServerPaywall (which DevServerPaywall.swift notes happens on every edit) or location.reload(). Another is the previous document's didLoadWebView drain landing after documentWillLoad(). Re-arming in onReady closes all of these with less state.
Technical details
# `paywall_open` re-arm is tied to load initiation, not to the document that pings
## Affected sites
- `PaywallMessageHandler.swift:283-298` — `documentWillLoad()` enqueues `paywall_open` at load start.
- `PaywallMessageHandler.swift:80-86` — `onReady` only sets `isDocumentReady`; it relies on the queue already holding `paywall_open`.
- `PaywallMessageHandler.swift:493-502` — the drain runs after `await TemplateLogic.getBase64EncodedTemplates(...)`, so a drain started by the old document's ping can run after `documentWillLoad()` has re-enqueued `paywall_open`, flushing it into the new, not-yet-ready document. The new document's ping then finds an empty queue.
## Gaps
1. Page-initiated full reloads (dev-server live reload, `location.reload()`) never call `documentWillLoad()`: `isDocumentReady` stays `true` and the new document's ping drains an empty queue.
2. The stale drain race above.
3. An old document that pings between `load()`/`reload()` and the new navigation committing. This is plausible for the refresh button, which appears on slow loads. The queued `paywall_open` goes to the outgoing document.
All three are narrow or not regressions (the old `paywalljsVersion` check had the same holes), so this is not blocking.
## Required outcome
- Every document that pings while `isPaywallOpen` is true gets exactly one `paywall_open` after its templates, however it was loaded.
## Suggested approach
- In `case .onReady`, after `isDocumentReady = true`, rebuild the queue from state: `messageQueue = Queue()`, and if `isPaywallOpen`, enqueue `paywall_open`. The queue only ever holds `paywall_open`/`paywall_close`, so the current open state is all a fresh document needs. A close that was queued before the ping, with nothing open, is moot for a page that was never told it is open.
- `documentWillLoad()` then shrinks to `isDocumentReady = false` (plus clearing the queue), and the re-enqueue there can go.
- Add a test: `onReady` → `paywallOpen` → `onReady` again with no `documentWillLoad()` delivers `paywall_open` twice.169c8a5 to
f72f2bc
Compare
Maple reviewNothing to review Only CHANGELOG.md changed since the previous review. No new source or test changes require review.
|

The bug
Paywalls that gate their entrance on
paywall_opengo blank after iOS kills the WKWebView content process. Every framework paywall gates this way, because the SDK preloads paywalls hidden and a mount-timed animation would finish before anyone sees it.App 1022's event log shows
paywallWebviewLoad_processTerminatedfor all 11 of its paywall web views when the app returned from the background, followed about a second later bypaywallWebviewLoad_complete. The page reloaded, but it never left its pre-open state and showed only its background colour.There are two paths, and both lose
paywall_open:webViewWebContentProcessDidTerminatecallsreload(). The new document pings and gets templates, butpaywall_openis only ever sent fromtrackOpen(), which runs once per presentation. The reloaded page is never told it is on screen.didFailToLoadand callsloadWebView()at presentation, and thentrackOpen()runs. Readiness was keyed onpaywall.paywalljsVersion == nil, but that value survives from the dead page. The SDK treats the still-loading document as ready and evaluatesaccept64into it, so the message is lost.The fix
PaywallMessageHandlernow tracks readiness per document instead of per paywall:documentWillLoad()runs before every new document: the initialloadWebView(), the refresh button'sreloadWebView(), and process termination. It marks the page not ready and clears the queue. If the paywall is open, it queuespaywall_openfor the new document.onReady(the page'sping) marks the page ready. The existing queue drain indidLoadWebViewdelivers the queuedpaywall_openafter templates.paywall_openandpaywall_closeset or clear the open state, and queue while the page is not ready.Only the message to the page is repeated. The analytics
paywall_openevent,didPresentPaywall, andstorage.trackPaywallOpen()still fire once per presentation.Why not a new event, or a fix in the framework
paywall_reloadevent would need every paywall to handle a second signal for "you are on screen". The reloaded document is a fresh page that has never been told it is open, so telling it is exactly the meaning ofpaywall_open. The framework runtime already treats a repeatedpaywall_openas idempotent.navigation.type === "reload", is unreliable. It also cannot tell a reload under a presented paywall from one under a hidden preload, where revealing would play the entrance unseen.Tests
Six new
PaywallMessageHandlerTestscover these cases:paywall_openexactly once.paywall_openonly once.PaywallMessageHandlerTests,SWWebViewLoadingHandlerTests,SWWebViewLogicTests,RawWebMessageHandlerTestsandPresentationIdTestspass on an iOS 27.0 simulator.End-to-end on a simulator
I ran the Basic example with
devServer = .defaultagainstsuperwall devserving thewith-motionexample, which gates its entrance onpaywall_open. The paywall was presented, then the web content process was killed withkill -9. I ran this once withdevelopand once with this branch, decoding the SDK's debug log for the messages posted to the page.developpaywall_opento the new pagepaywall_opensent once after the new page's pingpaywall_opensent oncepaywall_openper presentationThe first row is a second bug this fixes. Dev mode gives the paywall a non-nil empty
paywalljsVersion, so the old readiness check sentpaywall_openbefore the local page was ready. Gated paywalls served bysuperwall devnever opened. The changelog has a line for it.Not covered end to end: a process killed while the paywall is preloaded but hidden, because dev mode disables preloading. The unit tests cover that path. Not verified on a physical device.
Android
Not checked. Android forwards render-process crashes through
onRenderCrashed, but I did not trace what its handler does. Android sendspaywall_openfromtrackOpen(), including when the app returns from the background. Whether a reloaded page there gets it is still an open question.🤖 Generated with Claude Code