Skip to content

fix: send paywall_open to a paywall page reloaded after its web process dies - #545

Open
chroxify wants to merge 1 commit into
developfrom
christo/paywall-open-after-reload
Open

chroxify wants to merge 1 commit into
developfrom
christo/paywall-open-after-reload

Conversation

@chroxify

@chroxify chroxify commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

The bug

Paywalls that gate their entrance on paywall_open go 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_processTerminated for all 11 of its paywall web views when the app returned from the background, followed about a second later by paywallWebviewLoad_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:

  1. Killed while presented. webViewWebContentProcessDidTerminate calls reload(). The new document pings and gets templates, but paywall_open is only ever sent from trackOpen(), which runs once per presentation. The reloaded page is never told it is on screen.
  2. Killed while preloaded. The SDK sets didFailToLoad and calls loadWebView() at presentation, and then trackOpen() runs. Readiness was keyed on paywall.paywalljsVersion == nil, but that value survives from the dead page. The SDK treats the still-loading document as ready and evaluates accept64 into it, so the message is lost.

The fix

PaywallMessageHandler now tracks readiness per document instead of per paywall:

  • documentWillLoad() runs before every new document: the initial loadWebView(), the refresh button's reloadWebView(), and process termination. It marks the page not ready and clears the queue. If the paywall is open, it queues paywall_open for the new document.
  • onReady (the page's ping) marks the page ready. The existing queue drain in didLoadWebView delivers the queued paywall_open after templates.
  • paywall_open and paywall_close set or clear the open state, and queue while the page is not ready.

Only the message to the page is repeated. The analytics paywall_open event, didPresentPaywall, and storage.trackPaywallOpen() still fire once per presentation.

Why not a new event, or a fix in the framework

  • A new paywall_reload event 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 of paywall_open. The framework runtime already treats a repeated paywall_open as idempotent.
  • Detecting the reload in the page, for example by checking 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 PaywallMessageHandlerTests cover these cases:

  • Open after ready passes straight through.
  • Open before ready waits for the ping.
  • A reload while open re-sends paywall_open exactly once.
  • A reload while hidden sends nothing.
  • A reload after close sends nothing.
  • A page that dies before it is ready still gets paywall_open only once.

PaywallMessageHandlerTests, SWWebViewLoadingHandlerTests, SWWebViewLogicTests, RawWebMessageHandlerTests and PresentationIdTests pass on an iOS 27.0 simulator.

End-to-end on a simulator

I ran the Basic example with devServer = .default against superwall dev serving the with-motion example, which gates its entrance on paywall_open. The paywall was presented, then the web content process was killed with kill -9. I ran this once with develop and once with this branch, decoding the SDK's debug log for the messages posted to the page.

develop this branch
Paywall on first open blank renders
Kill while in the foreground stays blank, no paywall_open to the new page renders, paywall_open sent once after the new page's ping
Kill while in the background, then return not run renders, same process resumed, paywall_open sent once
Analytics paywall_open per presentation 1 1
Paywall analytics event counts same in both same in both

The first row is a second bug this fixes. Dev mode gives the paywall a non-nil empty paywalljsVersion, so the old readiness check sent paywall_open before the local page was ready. Gated paywalls served by superwall dev never 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 sends paywall_open from trackOpen(), including when the app returns from the background. Whether a reloaded page there gets it is still an open question.

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Oct 9, 2026

Copy link
Copy Markdown

This PR does not match any of the 1 configured review trigger rule.

@maple-review-bot

maple-review-bot Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Maple review

🟢 Confidence 9/10 · safe to merge
The lifecycle changes are contained, and regression tests cover both queued delivery and replay suppression.
quality 100/100 · no findings · tests covered · risk medium

Tracks web-document readiness separately from paywall state and replays paywall_open after reloads while presented. The change is safe to merge; six regression tests cover ready, loading, hidden, closed, and repeated-load cases.

What was checked
  • Readiness resets cover initial loading, refresh, and web-process termination.
  • Replay bypasses trackOpen(), preserving presentation analytics and delegate callback counts.
  • trackClose() clears replay state; tests cover hidden and closed reloads.

169c8a5 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@chroxify
chroxify requested a review from yusuftor October 9, 2026 13:34

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

ℹ️ 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 — isDocumentReady is reset by documentWillLoad() and set by onReady, so a value left over from a dead page no longer counts as ready.
  • Open state tracked in the handler — isPaywallOpen follows paywall_open/paywall_close, and documentWillLoad() queues paywall_open again for the next document while the paywall is open.
  • Hooks at every SDK-initiated load — loadWebView(), the refresh button's reloadWebView() and webViewWebContentProcessDidTerminate all call documentWillLoad().
  • Analytics unchanged — trackOpen() still runs once per presentation, so the tracked paywall_open, didPresentPaywall and storage.trackPaywallOpen() are not repeated.
  • Tests — six new PaywallMessageHandlerTests cover 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5.5 | 𝕏

}
}

func documentWillLoad() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@chroxify
chroxify force-pushed the christo/paywall-open-after-reload branch from 169c8a5 to f72f2bc Compare October 9, 2026 13:46
@maple-review-bot

maple-review-bot Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Maple review

Nothing to review

Only CHANGELOG.md changed since the previous review. No new source or test changes require review.

f72f2bc · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant