Repository navigation
Inject device locale into paywall webview at document start - #503
Conversation
Paywalls with translations rendered in the default language and visibly
re-rendered once template_variables arrived, since that message is gated
on receipt and product loading. A WKUserScript now sets
window.__SW_DEVICE_PRELOAD__ = {"deviceLocale": ...} at document start —
before any page JavaScript runs — so paywall.js can resolve localized
strings on its first render. template_variables is unchanged and remains
the source of truth.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01775Up1AYfMgNQybxjnoDSg
|
PR author is not in the allowed authors list. |
There was a problem hiding this comment.
ℹ️ No correctness issues found in the Swift. Two things worth confirming before merge, both about what the change doesn't cover.
Reviewed changes — the full diff at f405d64: a new document-start user script carrying the device locale into the paywall web view.
DevicePreloadScript— newenumnamespace whosesource(deviceLocale:)buildswindow.__SW_DEVICE_PRELOAD__ = {"deviceLocale":"…"};, usingJSONSerializationso a host-supplied locale override can't break out of the statement.SWWebViewinjection — the script is registered as a.atDocumentStart, main-frame-onlyWKUserScriptonwkConfig.userContentController, alongside the existingpaywallMessageHandlerregistration.- Factory widening —
SWWebView.init'sfactorybecomesFeatureFlagsFactory & DeviceHelperFactory; every call site already passesDependencyContainer, which conforms to both, so no call-site churn. - Tests + changelog — three Swift Testing cases over the string builder, and an
Unreleasedchangelog entry.
I verified the one thing that looked risky: adding a user script to wkConfig.userContentController after super.init(frame:configuration:) does reach the live web view. WKWebViewConfiguration's copy shares the underlying content controller by reference (WebKit's APIPageConfiguration holds it in a LazyInitializedRef that super.init force-materializes on the caller's object before copying), and init never loads a URL — loadURL(from:) runs later. The pre-existing message-handler registration on the same object is production proof of the same mechanism.
ℹ️ The paywall.js half of the contract can't be verified from this repo
The global name window.__SW_DEVICE_PRELOAD__ and the key deviceLocale are a contract with the web bundle, and nothing in the SDK pins it. If paywall.js reads a different global or key, this ships as a silent no-op that nobody notices — there's no log, no assertion, and the paywall still renders correctly via template_variables. Linking the paywall.js counterpart in the PR description would settle it.
Technical details
# Web-side contract for `window.__SW_DEVICE_PRELOAD__` is unpinned
## Affected sites
- `Sources/SuperwallKit/Paywall/View Controller/Web View/DevicePreloadScript.swift:19-27` — global name and payload key are string literals with no counterpart in this repo.
## Required outcome
- Confirm `paywall.js` reads `window.__SW_DEVICE_PRELOAD__.deviceLocale` under exactly that name, and that the paywall.js change is released before or alongside this SDK version.
## Open questions for the human
- Does `paywall.js` resolve translations from `deviceLocale`, or from `preferredLocale`? The two differ when the host app is not localized for the user's preferred language — `deviceLocale` is `Locale.autoupdatingCurrent.identifier` (the locale the app itself resolves to), which is why `preferredLocale` / `preferredLanguageCode` were added in the first place (`CHANGELOG.md:1129`). Preloading `deviceLocale` is consistent with how the SDK already keys paywall localization server-side (`Network.swift:97`, `Endpoint.swift:174-190`, `ConfigLogic.getStaticPaywall`), so this is likely correct — but if the page resolves translations from `preferredLocale`, the first paint will still land in the wrong language and the flicker this PR targets remains.ℹ️ The injection itself is untested — only the string builder is
DevicePreloadScriptTests covers source(deviceLocale:) as a pure function, which is the part least likely to regress. The part that carries the behavior — that a script with .atDocumentStart and the right locale actually lands on the web view's content controller — has no coverage, so a future refactor could drop or reorder the registration silently. Several suites already build an SWWebView from a DependencyContainer, so asserting on webView.configuration.userContentController.userScripts is cheap.
Technical details
# No test covers the `WKUserScript` registration
## Affected sites
- `Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift:119-133` — registration path with no test.
- `Tests/SuperwallKitTests/Paywall/View Controller/Web View/DevicePreloadScriptTests.swift` — covers only the string builder.
## Required outcome
- A test that constructs an `SWWebView` and asserts exactly one user script whose `source` matches `DevicePreloadScript.source(deviceLocale: dependencyContainer.makeDeviceInfo().locale)`, with `injectionTime == .atDocumentStart` and `isForMainFrameOnly == true`.
## Suggested approach
- Follow the existing construction pattern in `Tests/SuperwallKitTests/Paywall/View Controller/SurveyManagerTests.swift:20-33` (real `DependencyContainer`, real `PaywallMessageHandler`); the assertion reads off `webView.configuration.userContentController.userScripts`.ℹ️ Nitpicks
CHANGELOG.md:5—## Unreleasedis the first use of that heading in this file; every other section is a released version number, andCLAUDE.mddescribes adding the entry as part of the version bump. Worth confirming the release flow handles the placeholder heading rather than expecting a version.
Claude Opus | 𝕏
4.16.2 hasn't shipped yet, so the entry rides the pending release section instead of a separate Unreleased header. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JErpaZs7R3kJ4HHHA1P4nm
…I input zh_Hans_CN is pure ASCII, so the case never tested what its name claimed, and its contains assertion was already implied by the exact-equality test above. Feed a genuinely non-ASCII locale and pin the full output, documenting that JSONSerialization emits it as raw UTF-8 rather than \u-escaped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JErpaZs7R3kJ4HHHA1P4nm
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the single test-only commit since the prior review at f405d64; the production Swift surface is untouched.
- Non-ASCII test now exercises non-ASCII —
handlesNonAsciiLocalesswaps the ASCII inputzh_Hans_CNfor中文_CNand replaces thecontainscheck with exact string equality, so it pins the full emitted statement rather than a substring. This addresses the prior review's inline thread on that case.
I confirmed the new expectation is correct rather than aspirational: running the same builder logic against Foundation, JSONSerialization.data(withJSONObject:) emits non-ASCII as raw UTF-8 with no \u escaping and no whitespace, so {"deviceLocale":"中文_CN"} is exactly what the function produces. The assertion would now fail if the encoder ever started escaping or the wrapper text drifted.
Claude Opus | 𝕏

Changes in this pull request
Checklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.swiftlintin the main directory and fixed any issues.