Some SwiftOpenUI backends use an ambient "current environment" while rendering. That works for body evaluation, but it is not enough for callbacks that are registered during render and invoked later by the platform.
The rule is:
- render-time body work must run under the correct current environment
- delayed callbacks that may read
@Environment(...)must capture and reinstall the render-time environment when they are registered
This note documents the Win32 failure that exposed the issue, the fix pattern, and what other backends should audit.
SwiftOpenUI supports two environment read styles:
- key-path values such as
.environment(\.buttonStyle, value) - injected reference objects such as
.environment(model)read via@Environment(Model.self)
For injected objects, @Environment(T.self) resolves through the current environment at read time.
In practice that means:
- an ancestor modifier temporarily installs a modified environment
- descendant bodies read from that ambient environment while rendering
- the modifier restores the previous environment after descendant rendering returns
On native backends this ambient environment is backend-managed state:
- Win32 uses thread-local storage
- GTK uses thread-local storage
- Web uses a single-threaded global current environment
The important constraint is the same on all three: the current environment is a render-scope value, not a general-purpose lifetime container for later callbacks.
The confirmed Win32 crash came from Win32ReviewSmoke.
Observed shape:
.environment(model)pushedReviewModelinto the current environmentWin32ReviewViewandObservableCounterViewrendered successfully@Environment(ReviewModel.self)returned the injected object during render- the modifier restored the previous environment after render completed
- a native button callback fired later and re-read
@Environment(ReviewModel.self) - the lookup failed because current environment no longer contained
ReviewModel
This was initially easy to misread as:
- a missing injection
- a broken
ReviewModel - or a generic Observation failure
It was none of those.
The real bug was that the callback executed outside render scope, while @Environment(T.self)
still depended on ambient current-environment state.
These need different fixes.
Examples:
- initial stateful render
- rebuild
- descriptor capture
These paths are still rendering bodies, just not necessarily from the original modifier stack.
The fix here is host-owned environment reinstall, such as Win32's
installEffectiveEnvironment().
Examples:
- button actions
- menu item commands
- navigation pushes
- gesture handlers
onAppearonDisappear
These are not body-render paths. They happen later, after the original render scope has ended. Host reinstall alone does not help, because the callback may not go through a host rebuild entry.
The fix here is callback binding: capture the current environment at registration time, then save/restore it around callback execution later.
Win32 now uses this pattern in Sources/Backend/Win32/Rendering/WinRenderer.swift:
func bindActionToCurrentEnvironment(_ action: @escaping () -> Void) -> () -> Void {
let capturedEnvironment = getCurrentEnvironment()
return {
let previousEnvironment = getCurrentEnvironment()
setCurrentEnvironment(capturedEnvironment)
defer { setCurrentEnvironment(previousEnvironment) }
action()
}
}
func bindActionToCurrentEnvironment<T>(_ action: @escaping (T) -> Void) -> (T) -> Void {
let capturedEnvironment = getCurrentEnvironment()
return { value in
let previousEnvironment = getCurrentEnvironment()
setCurrentEnvironment(capturedEnvironment)
defer { setCurrentEnvironment(previousEnvironment) }
action(value)
}
}The important properties are:
- capture happens at render time, when the correct environment is active
- callback execution later saves the then-current environment and restores it afterwards
- nested callback execution composes correctly because each layer saves/restores independently
- the generic overload covers payload-bearing callbacks like gestures and expansion handlers
Bind at the registration boundary, not later.
Good:
- when storing a native button action
- when registering a menu command
- when creating a gesture handler object
- when boxing an
onDisappearclosure - when scheduling deferred work with a main-thread or timer callback
Bad:
- wrapping only at dispatch time after render has already finished
- relying on whatever environment happens to be current when the platform fires the callback
This distinction mattered in Win32 DisclosureGroup: the first attempt rebound the callback at
dispatch time, which just captured already-empty current environment. The correct fix was to bind
the (Bool) -> Void callback itself when the view was rendered.
The Win32 audit ended up covering these delayed-callback boundaries:
ButtonNavigationLinkMenuitem actionsonAppearonDisappearDisclosureGrouponTapGestureonLongPressGestureonDrag(onChangedandonEnded)
This is intentionally broader than a single smoke-test fix. Once one backend proves the bug class, it is usually worth auditing every similar delayed callback on that backend.
This is not a "Win32-only" idea. It applies to any backend that satisfies both conditions:
@Environment(...)reads from an ambient current environment- the backend stores closures during render and invokes them later
Audit:
- signal handlers
- gesture controllers
g_idle_add/ timeout callbacks- destroy/unmap lifecycle handlers
- menu activations
GTK also uses thread-local environment, so the same failure class can exist there.
Audit:
- DOM event listeners
requestAnimationFramesetTimeout- promise continuations
- menu / overlay dismiss handlers
Web does not use TLS, but it still has an ambient current environment. If callback execution later depends on that ambient state, the same bug class exists.
Audit:
- any Swift callback that survives across the JSON/JNI boundary
- callbacks invoked later from host-side lifecycle or event dispatch
If Android lowers actions into identifiers and later re-enters Swift to execute closures that read
@Environment(...), it should apply the same binding pattern when those callbacks are registered.
If the host mutates state directly and never re-enters Swift environment lookup, the issue may not
apply there.
This note is about SwiftOpenUI-native backends, not Apple's SwiftUI runtime. Real SwiftUI manages its own environment semantics internally.
When adding a new backend feature, ask:
- Does this code store a closure during render and run it later?
- Can that closure read
@Environment(...)directly or indirectly? - Does callback execution happen outside a known host reinstall scope?
- If the callback takes arguments, is the bound wrapper capturing environment at registration time, not building a new wrapper at dispatch time?
- Is there a regression test that exercises the real delayed path, not just the helper?
If the answer to the first three is yes, the callback should almost certainly be environment-bound.
Callback binding does not solve every related issue.
For example, the original Win32 smoke crash was triggered immediately by a startup click-through artifact on the native button. Binding the callback fixed the correctness issue, but the startup click-through itself remained a separate UX bug.
Likewise, host reinstall is still required for render-time re-entry. Callback binding is not a replacement for proper host-owned environment restoration during rebuild and descriptor work.