@dynamicMemberLookup + nested KeyPath access not triggering my custom observation access tracking

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

So @Observable's macro will transform stored properties into computed properties that access a replicated stored property (prefixed with an underscore); so my guess is that somehow you are hitting the stored version when you need the computed variant.

Additionally Observation's key path tracking is by the reported paths not the computed path - that means that \Some.Very.Deep.Path.To.My.Subject.Property does not trigger the changes that \Subject.Property does; it only tracks key paths as unique pointers; \Some.Very.Deep.Path.To.My.Subject.Property != \Subject.Property. You would need some sort of KeyPath decomposition to extract the trailing components (I don't think there is anything like that currently as public API for KeyPath).

1 Like