Skip to content

fix(device): review follow-ups on the trait caching work - #502

Merged
yusuftor merged 3 commits into
developfrom
fix/pullfrog-device-trait-followups
Aug 13, 2026
Merged

fix(device): review follow-ups on the trait caching work#502
yusuftor merged 3 commits into
developfrom
fix/pullfrog-device-trait-followups

Conversation

@yusuftor

@yusuftor yusuftor commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Changes in this pull request

Three review follow-ups on the device trait work, from open threads on #498.

  • Font values scale against the resolved category. makeUITraits() used the implicit UIFontMetrics.scaledValue(for:), which resolves against UITraitCollection.current — documented as undefined outside view/view-controller trait callbacks and stored per thread — while preferredContentSizeCategory on the line above is read from UIApplication with no trait-context dependency. The two font numbers could therefore contradict the category in the same snapshot. They now scale via scaledValue(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.
  • Both arms of the isUIKitReadSafe gate are pinned. The property reads UIApplication.sharedApplication and Bundle.main.bundleURL directly, 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_returnsNonNil no longer asserts a property of the test host. Whether AVAudioSession resolves 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

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs on iOS.
  • Demo project builds and runs on Mac Catalyst.
  • Demo project builds and runs on visionOS.
  • 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 swiftlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs.
  • I have reviewed the contributing guide

🤖 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.

  • Scales font values using an explicit trait collection.
  • Extracts and tests both branches of the UIKit-read safety decision.
  • Removes a test-host-dependent AVAudioSession assumption.

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

Filename Overview
Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift Preserves the UIKit safety-gate semantics while making it directly testable and aligns font scaling with the reported content-size category.
Tests/SuperwallKitTests/Network/DeviceHelperTests.swift Updates trait expectations to use explicit category scaling and adds focused coverage for all UIKit safety-gate states.
Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift Correctly verifies that proxy singleton availability matches Objective-C runtime class availability without depending on the runner image.

Reviews (1): Last reviewed commit: "fix(device): scale fonts against the res..." | Re-trigger Greptile

Context used:

yusuftor and others added 2 commits August 13, 2026 15:06
…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>

@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 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 implicit UIFontMetrics.default.scaledValue(for:) for scaledValue(for:compatibleWith:) against a UITraitCollection(preferredContentSizeCategory: category), so fontSize, fontScale, and preferredContentSizeCategory all derive from the one category read on the line above rather than from thread-local UITraitCollection.current.
  • isUIKitReadSafe split into a parameterised overload. The decision moves to isUIKitReadSafe(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.
  • sharedInstance test no longer asserts a property of the runner image. It now ties the assertion to whether AVAudioSession resolves 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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift
…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>

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

ℹ️ 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.

  • currentUITraits drops its pre-iOS-17 live-read fallback. The guard #available(iOS 17.0, *) that returned makeUITraits(...) ?? .unavailable is gone, so every version goes through refreshUITraits(): 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 the getTemplateDevice() 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:402registerForTraitChanges()'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", which 2970f61 made 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 2970f61 is a fourth change that does add one. Worth a refresh before merge, since that text is what the next reader of this PR sees.

Pullfrog  | Fix it ➔View workflow run | Using Claude Opus𝕏

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

ℹ️ 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_decodesCorrectly pins the class constant to "AVAudioSession", so a typo can no longer send both sides of sharedInstance_matchesWhetherTheClassResolves nil together.
  • selectorNames_areCorrectlyDecoded additionally 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: NINhqvbFrffvbaAVAudioSession, funerqVafgnaprsharedInstance, erpbeqCrezvffvbarecordPermission, 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.

Pullfrog  | Fix it ➔View workflow run | Using Claude Opus𝕏

@yusuftor
yusuftor merged commit c8edc89 into develop Aug 13, 2026
3 checks passed
@yusuftor
yusuftor deleted the fix/pullfrog-device-trait-followups branch August 13, 2026 13:20
@pullfrog pullfrog Bot mentioned this pull request Aug 13, 2026
10 tasks
yusuftor added a commit that referenced this pull request Aug 13, 2026
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>
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