Hello everyone,
I’m experimenting with a custom observation mechanism for value-type State (similar in spirit to @Observable, but without macros). While doing that, I ran into an unexpected behavior involving @dynamicMemberLookup
I am not sure what the right terminology is here. What I mean by nested is accessing a property through another stored property.
\State.person.name
When state.person.name is read through my dynamic member lookup subscript, I expected my access tracking to run for the relevant key paths, so later mutations could notify observers correctly.
public subscript<Subject>(dynamicMember keyPath: KeyPath<State, Subject>) -> Subject {
trackAccess(for: keyPath)
return _state[keyPath: keyPath]
}
Reads performed through the @dynamicMemberLookup subscript don’t seem to register access for nested properties like \State.person.name.
However, if I access via an explicit subscript or function that takes a key path, everything behaves as expected.
public subscript<T>(_ keyPath: KeyPath<State, T>) -> T {
trackAccess(for: keyPath)
return _state[keyPath: keyPath]
}
public func read<T>(_ keypath: KeyPath<State, T>) -> T {
trackAccess(for: keypath)
return _state[keyPath: keypath]
}
My tracking mechanism is intentionally minimal. I keep a dictionary keyed by PartialKeyPath<State> and touch a token’s version during reads, so dependencies are registered.
@Observable
final class ObservationToken: @unchecked Sendable {
var version: Int = 0
func increment() {
version += 1
}
}
@inline(__always)
private func token(for keyPath: PartialKeyPath<State>) -> ObservationToken {
if let existing = _observationTokens[keyPath] {
return existing
}
let newToken = ObservationToken()
_observationTokens[keyPath] = newToken
return newToken
}
@inline(__always)
private func trackAccess<Value>(for keyPath: KeyPath<State, Value>) {
_ = token(for: keyPath).version
}
Is it expected that @dynamicMemberLookup behaves differently here, especially for nested accesses like state.person.name due to compiler rewrites or optimization of the nested member access?
Why do the explicit read(_:) / subscript(_:) approaches reliably track the access, but the dynamicMember subscript appears not to for nested member reads?
Is the compiler generating different key paths or bypassing the dynamic member subscript for subsequent members in a chain?
Thanks