Coerce @AppStorage values across primitive types instead of crashing - #491
Open
vincentborko wants to merge 1 commit into
Open
Coerce @AppStorage values across primitive types instead of crashing#491vincentborko wants to merge 1 commit into
vincentborko wants to merge 1 commit into
Conversation
On iOS `@AppStorage` coerces between primitive types via UserDefaults' typed accessors: a value stored as a String can be read back as a Double, etc. On Android `AppStorage<Value>.trackState()` read the stored object with `object as? Value`, which under Kotlin's type erasure is a no-op — it neither coerces nor rejects a mismatched type. A value stored under one type therefore flowed unchanged into a property of another, crashing a later consumer with a ClassCastException (issue skiptools#317). Dispatch on the concrete runtime type of the current wrapped value and use the matching typed UserDefaults accessor, mirroring the coercion the bridged AppStorageSupport already performs. Factored both the initial read and the change-listener read through a single `storedValue(from:store:)` helper. Fixes skiptools#317. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
On iOS,
@AppStoragecoerces between primitive types: you can store a value as aDoubleand read it back as aString, or vice versa. On Android the same code crashes (#317) — e.g. store"1.0"under a key, then read it through aDouble-typed@AppStorage:Root cause
AppStorage<Value>.trackState()read the stored object with:Under Kotlin's type erasure
Valueis unknown at runtime, soobject as? Valueis a no-op: it neither coerces the value nor rejects a mismatched type. AStringstored value flowed unchanged into aDouble-typed property and crashed a later consumer.The bridged
AppStorageSupportdoesn't have this problem because it captures a per-typegetclosure at init (e.g.{ $1 as? Double ?? $0.double(forKey: key) }) usingUserDefaults' coercing typed accessors. The genericAppStorage<Value>has only one generic initializer, so it has no such per-type dispatch point — but its_wrappedValuestill holds a concrete runtime value.Fix
Dispatch on the concrete runtime type of
_wrappedValueand read through the matching typedUserDefaultsaccessor, mirroringAppStorageSupport's coercion. Both the initial read and the change-listener read now go through a singlestoredValue(from:store:)helper. Behavior is otherwise unchanged: an absent key still returnsnil(seeding the default), and adeserializer, when present, still takes priority.Testing
New
AppStorageTests(runs under Robolectric via the transpiled test path):testAppStorageCoercesStringToDouble— stores"1.0", reads aDouble, expects1.0.testAppStorageCoercesIntToDouble— stores3, reads aDouble, expects3.0.Verified the tests are a real regression guard: against the pre-fix
object as? Valueboth fail under Robolectric (AssertionError: 1.0 != 1.0— String vs Double — and3 != 3.0), and pass with the fix. Full transpiledSkipUIsuite green (94 tests, 0 failures).