fix(device): review follow-ups on the trait caching work - #502
Conversation
…raits Three follow-ups from review on the trait-caching work. `makeUITraits()` scaled with the implicit `scaledValue(for:)`, which resolves against `UITraitCollection.current` — documented as undefined outside a view or view-controller trait callback, and stored per thread. The category on the line above comes from `UIApplication` and has no such dependency, so the two font numbers could contradict it in the same snapshot. Scale against that category explicitly so all three values in a snapshot have one source. The tests computed their expectations with the implicit overload too, so both sides inherited the fault and couldn't fail on it; they now scale the same explicit way. `isUIKitReadSafe` read both its facts from process-wide globals, so the arm that actually prevents #493 — an app bundle whose application object doesn't exist yet — was unreachable from a test, and loosening `"app"` would have left the suite green. Split the decision into a parameterised overload and pin both arms. `sharedInstance_returnsNonNil` asserted that AVFAudio happened to be loaded in the runner, which is a property of the test host rather than of the proxy. Tie both sides to the same fact instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pre-iOS-17 branch of `currentUITraits` read live on every access, and `makeUITraits()` blocks off-main callers on the main queue — so `makeHeaders`' `X-Device-Interface-Style` read paid that hop on every network request, and `getTemplateDevice()` paid a second, both from cooperative-pool threads whose count is small and fixed. Serve the cache on every version and drop the fork. The cost is bounded: below 17 the only flip the notification observers miss is an in-place automatic light/dark change, now stale for exactly one read before the scheduled refresh lands. Text-size changes and backgrounded flips still land immediately, and from iOS 17 the trait hook keeps the cache current at the moment of the flip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No critical issues — one coverage gap inline, one observation below.
Reviewed changes — the full diff at 0c89b73 (3 files), the surrounding DeviceHelper trait-caching code, the sibling permission-proxy test suites, and the Apple documentation for the two UIKit APIs the production change adopts.
- Font values scale against the resolved category.
makeUITraits()swaps the implicitUIFontMetrics.default.scaledValue(for:)forscaledValue(for:compatibleWith:)against aUITraitCollection(preferredContentSizeCategory: category), sofontSize,fontScale, andpreferredContentSizeCategoryall derive from the onecategoryread on the line above rather than from thread-localUITraitCollection.current. isUIKitReadSafesplit into a parameterised overload. The decision moves toisUIKitReadSafe(hasApplication:bundleURL:)with the property forwarding the two globals; three new tests pin all three arms, including the app-bundle-before-launch arm behind #493 that the runner can't otherwise reach.- Test expectations use a shared explicit-overload helper.
expectedScaledValue(for:)replaces four inline implicit-overload computations so both sides no longer inherit the same ambient-trait fault. sharedInstancetest no longer asserts a property of the runner image. It now ties the assertion to whetherAVAudioSessionresolves at all.
I verified the API contracts against Apple's docs: UITraitCollection.init(preferredContentSizeCategory:) (iOS 10+, Mac Catalyst 13.1+) and UIFontMetrics.scaledValue(for:compatibleWith:) (iOS 11+) are both current and undeprecated — the iOS 17 UITraitCollection deprecations don't touch the single-trait convenience initializers — so the iOS 13 floor is clear and no warning is expected. A single-trait collection is the documented argument shape. I also confirmed the "no CHANGELOG entry" call is right: fontSize/fontScale are backend wire-contract fields, but they were added in the unreleased 4.16.2 cycle (no tag, no release/4.16.2 branch), so no shipped attribute distribution moves.
ℹ️ Nothing in the suite can fail if the implicit overload comes back
expectedScaledValue(for:) removes the shared ambient-trait fault, which is a real improvement, but the runner's content size category is always the default — so production and expectation both compute 16.0 and the four assertions can't distinguish the two overloads. The one change this PR is actually about is unpinned, in a PR that otherwise went out of its way to extract isUIKitReadSafe(hasApplication:bundleURL:) for exactly that reason.
Technical details
# The font-scaling fix has no test that can fail on it
## Affected sites
- `Tests/SuperwallKitTests/Network/DeviceHelperTests.swift:20-27` — `expectedScaledValue(for:)`
is handed the same `category` production resolves, so on the runner (default category)
both sides return `16.0` regardless of which overload production calls.
- `Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift:483-486` — the changed call.
`category` comes from process globals with no injection point, so no test can drive
`makeUITraits()` with a non-default category.
## Required outcome
- A test that goes red if `makeUITraits()` reverts to `scaledValue(for:)`, i.e. one that
exercises the scaling against a category the runner doesn't have.
## Suggested approach (optional)
- Mirror the `isUIKitReadSafe` split already in this PR: lift the two font numbers into a
pure `static func fontValues(for category: UIContentSizeCategory) -> (size: Int, scale: Double)`
that `makeUITraits()` calls with its resolved `category`, then assert an accessibility
category yields `size > 16` / `scale > 1.0` and the default category yields `16` / `1.0`.
## Open questions for the human
- Worth the extra seam, or is the comment at the call site the intended level of protection
here? The `isUIKitReadSafe` extraction sets a precedent either way.Claude Opus | 𝕏
…stically The resolves-check ties both sides to `mangledClassName`, so a typo in the constant sends them nil together and leaves the suite green while every microphone permission read degrades to the unavailable sentinel. Pin the decoded class name the way the Contacts, Location, and Tracking suites do, and close the pre-existing gap on the three selectors. The literals land in the test binary, not the shipped SDK, so they don't undo the mangling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ Nothing blocking in the new commit — two stale-text nitpicks below.
Reviewed changes — the delta since my review of 0c89b73, i.e. the single new commit 2970f61 (perf(device): serve cached traits below iOS 17 instead of reading live), plus the surrounding refreshUITraits() / trait-observer machinery it now relies on across all versions.
currentUITraitsdrops its pre-iOS-17 live-read fallback. Theguard #available(iOS 17.0, *)that returnedmakeUITraits(...) ?? .unavailableis gone, so every version goes throughrefreshUITraits(): main-thread reads refresh synchronously, off-main reads return the cached snapshot and schedule one coalesced main-queue refresh, blocking only to fill an empty cache.- CHANGELOG entry added for the removed main-thread wait on iOS 16 and earlier.
- Comments at the property, at
interfaceStyle, and at thegetTemplateDevice()snapshot updated to describe the cache-on-every-version behaviour.
The trade reads correctly to me and the doc comment states it honestly: this removes a DispatchQueue.main.sync from the X-Device-Interface-Style path on every network request on iOS ≤16, at the cost of an in-place appearance flip while the app is frontmost (automatic light/dark at sunset) landing one read late on those versions. There is no userInterfaceStyle notification below the iOS 17 trait hook, and the alternative — injecting a view into the host's window — is rightly ruled out; text-size changes and flips made while backgrounded still land immediately via the existing observers. Pre-launch behaviour is unchanged: an empty cache still yields the .unavailable placeholder on both paths. Removing the version branch also means CI, which runs on iOS 17+ simulators, now exercises the only path rather than skipping the pre-17 one.
ℹ️ Nitpicks
Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift:402—registerForTraitChanges()'s doc still reads "iOS 17+ only. Earlier releases read live on every access instead, rather than injecting a view into the host's window", which2970f61made false and which now contradicts the property's own comment 60 lines above. The reason to stay 17+-only is still sound; only the "read live" half needs replacing (e.g. earlier versions fall back to the refresh-on-read backstop and the activation observer).- The PR description is now out of step with the branch: it opens with "Three review follow-ups" and closes with "No CHANGELOG entry: no customer-facing behaviour change", while
2970f61is a fourth change that does add one. Worth a refresh before merge, since that text is what the next reader of this PR sees.
Claude Opus | 𝕏
There was a problem hiding this comment.
ℹ️ Nothing new in this commit — the earlier inline finding is fully addressed.
Reviewed changes — the delta since my review of 2970f61, i.e. the single new commit 8e52f21 (test(permissions): pin the microphone proxy's mangled names deterministically), which touches only Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift.
mangledClassName_decodesCorrectlypins the class constant to"AVAudioSession", so a typo can no longer send both sides ofsharedInstance_matchesWhetherTheClassResolvesnil together.selectorNames_areCorrectlyDecodedadditionally closes the pre-existing selector gap, matching what the Contacts, Location, and Tracking suites already had.
I decoded all four constants by hand against AudioSessionProxy.swift:19-28: NINhqvbFrffvba → AVAudioSession, funerqVafgnapr → sharedInstance, erpbeqCrezvffvba → recordPermission, erdhrfgErpbeqCrezvffvba: → requestRecordPermission:. All match, all are environment-independent, and the literals stay in the test binary rather than the shipped SDK. Thread replied to and resolved.
The two nitpicks from the previous review are untouched by this commit and still stand; not repeating them here.
Claude Opus | 𝕏
The iOS-16 main-thread-wait entry described a wait that #497 introduced and #502 removed, both inside unreleased 4.16.2 — no released version had it, and the other entries all describe fixes relative to a release. Also update the registerForTraitChanges note that still described the pre-#502 live-read behaviour below iOS 17. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Changes in this pull request
Three review follow-ups on the device trait work, from open threads on #498.
makeUITraits()used the implicitUIFontMetrics.scaledValue(for:), which resolves againstUITraitCollection.current— documented as undefined outside view/view-controller trait callbacks and stored per thread — whilepreferredContentSizeCategoryon the line above is read fromUIApplicationwith no trait-context dependency. The two font numbers could therefore contradict the category in the same snapshot. They now scale viascaledValue(for:compatibleWith:)against that category, so all three values have one source. The tests computed expectations with the implicit overload too (both sides inherited the fault); they now use the explicit overload via a shared helper.isUIKitReadSafegate are pinned. The property readsUIApplication.sharedApplicationandBundle.main.bundleURLdirectly, so the arm that actually prevents [BUG] Presenting UIWindow resets app tintColor to system blue (tab bar / AccentColor) #493 — app bundle, no application object yet — was unreachable from the test runner. The decision is split into a parameterised overload (isUIKitReadSafe(hasApplication:bundleURL:)) and three tests cover defer-before-launch, allow-after-launch, and allow-outside-app-bundles.sharedInstance_returnsNonNilno longer asserts a property of the test host. WhetherAVAudioSessionresolves depends on the runner image having AVFAudio loaded, not on the proxy. The test now asserts the proxy returns an instance exactly when the class resolves.No CHANGELOG entry: no customer-facing behaviour change (the font values only differed in contexts Apple documents as undefined).
Checklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.swiftlintin the main directory and fixed any issues.🤖 Generated with Claude Code
Greptile Summary
The PR makes device font metrics consistent with the resolved content-size category and improves coverage of the pre-launch UIKit safety gate. It also makes the microphone proxy test conditional on whether AVAudioSession is available in the test process.
Confidence Score: 5/5
The PR appears safe to merge, with no actionable defects identified in the changed behavior.
The production gate refactor is behaviorally equivalent to the previous implementation, explicit font scaling keeps the reported metrics internally consistent, and the revised microphone assertion still detects reflective singleton lookup failures when the class is available.
Important Files Changed
Reviews (1): Last reviewed commit: "fix(device): scale fonts against the res..." | Re-trigger Greptile
Context used: