Skip to content

Inject device locale into paywall WebView at document start - #450

Merged
ianrumac merged 6 commits into
developfrom
claude/paywall-translation-perf-o43go3
Aug 11, 2026
Merged

Inject device locale into paywall WebView at document start#450
ianrumac merged 6 commits into
developfrom
claude/paywall-translation-perf-o43go3

Conversation

@ianrumac

@ianrumac ianrumac commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Changes in this pull request

Bug: paywalls with translations render in the default language, then visibly re-render into the user's language once the SDK's template_variables message delivers deviceLocale to paywall.js. That message is gated on product/billing loading, so the default-language flash can last seconds.

Fix: inject the device locale into the paywall WebView as soon as the page starts loading. The web runtime (already shipped in paywall-next) reads window.__SW_DEVICE_PRELOAD__ at boot and seeds its locale from it:

window.__SW_DEVICE_PRELOAD__ = {"deviceLocale":"en_US"};
  • Injection mechanism: a plain evaluateJavascript from WebViewClient.onPageStarted — the same mechanism the SDK already uses for its selection/zoom scripts, just hooked at page start instead of template delivery (which would be too late). No new dependencies. The paywall runtime reads the global when its network-fetched bundle boots, so the page-start injection lands well before it; if it ever misses, the runtime simply waits for template_variables as it does today — the failure mode is the status quo, never a wrong translation. DefaultWebviewClient/WebviewFallbackClient gained an optional onPageStartedHook for this.
  • Locale parity: the value comes from PaywallViewState.locale (with a DeviceHelper.locale fallback), which is the exact same DeviceHelper.locale source used for deviceLocale in the device template sent via template_variables — so the later message is a visual no-op.
  • Escaping: the script is built by a pure DevicePreloadScript object that serializes the payload with kotlinx.serialization (buildJsonObject), never string concatenation of the raw value, so hostile locale strings (quotes, backslashes) cannot break out of the JSON literal.
  • Tests: DevicePreloadScriptTest (JVM unit test, Given/When/Then DSL) covers the exact output for en_US and zh_Hans_CN, escaping of a hostile en"};alert(1);// string (with round-trip decode), and preservation of non-ASCII characters. Ran and passed locally: ./gradlew :superwall:testDebugUnitTest --tests '*DevicePreloadScriptTest*' — 4/4 pass. The broader unit/UI test suites and demo app were not run in this environment.
  • CHANGELOG entry added under Unreleased → Fixes.

Counterpart changes: superwall/paywall-next branch claude/paywall-translation-perf-o43go3 (web runtime seeds its locale exclusively from __SW_DEVICE_PRELOAD__ — no browser-locale fallback, since the WebView/system locale can differ from the app's resolved locale and a wrong-translation flash is worse than a source-language one) and superwall/Superwall-iOS same branch name (WKUserScript at .atDocumentStart equivalent).

Checklist

  • All unit tests pass. (New DevicePreloadScriptTest passes locally; full suite not run in this environment.)
  • All UI tests pass. (Not run — requires device/emulator.)
  • Demo project builds and runs. (Not verified in this environment.)
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run ktlint in the main directory and fixed any issues. (Code follows the repo's .editorconfig ktlint_official style; no gradle ktlint task exists in this repo.)
  • I have updated the SDK documentation as well as the online docs. (Internal-only change; no public API affected.)
  • I have reviewed the contributing guide

🤖 Generated with Claude Code

https://claude.ai/code/session_01775Up1AYfMgNQybxjnoDSg


Generated by Claude Code

ianrumac and others added 6 commits August 10, 2026 12:24
Translated paywalls rendered in the default language first, then visibly
re-rendered once the template_variables message delivered deviceLocale to
paywall.js (that message is gated on product/billing loading, so it can
take seconds). The web runtime now reads window.__SW_DEVICE_PRELOAD__ at
boot and seeds its locale from it, so inject that global before any page
JavaScript runs:

- Add DevicePreloadScript, a pure builder that serializes the payload
  with kotlinx.serialization so hostile locale strings cannot break out
  of the script, producing exactly:
  window.__SW_DEVICE_PRELOAD__ = {"deviceLocale":"en_US"};
- Install it via WebViewCompat.addDocumentStartJavaScript (androidx.webkit,
  new dependency) when the WebView supports DOCUMENT_START_SCRIPT, and
  fall back to evaluateJavascript in WebViewClient.onPageStarted on older
  WebView versions.
- The locale comes from PaywallViewState.locale, which is the same
  DeviceHelper.locale value later sent as deviceLocale in
  template_variables, so the later message is a visual no-op.
- Unit-test the builder (exact output, quote escaping, longer and
  non-ASCII locales) and add a CHANGELOG entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01775Up1AYfMgNQybxjnoDSg
The document-start script path required adding androidx.webkit as a new
dependency for every SDK user. It bought little: the paywall runtime
reads window.__SW_DEVICE_PRELOAD__ when its network-fetched bundle
boots, so an evaluateJavascript from onPageStarted lands well before
that — and since the web runtime now seeds exclusively from the preload
global, a missed injection just means today's behavior (wait for
template_variables), never a wrong translation.

This matches how the SDK already injects JS (plain evaluateJavascript,
like the selection/zoom scripts), just hooked at page start rather than
template delivery, which would be too late. DevicePreloadScript and its
tests are unchanged; the script is now built lazily in the hook so it
always uses the freshest locale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01775Up1AYfMgNQybxjnoDSg
@ianrumac
ianrumac merged commit a3e5eab into develop Aug 11, 2026
6 checks passed
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.

2 participants