perf(design-system): hoist NSColor bridging out of adaptiveColor hot path (LUM-709)#30314
Conversation
…path (LUM-709) AppKit invokes the NSColor(name:dynamicProvider:) closure whenever any method on the color needs component values (currentDrawingAppearance, alphaComponent, cgColor, etc.) — i.e. on every SwiftUI render/layout pass that touches a design token. The previous body of the closure re-allocated a [NSAppearance.Name] literal, called bestMatch, and ran NSColor(SwiftUI.Color) twice per invocation. With ~56 adaptive tokens queried many times per render pass, this added up to 2s+ main-thread hangs (Sentry MACOS-D6 / LUM-709, 33 events / 13 users on 0.8.0; same fingerprint as MACOS-99 / LUM-468 where the watchdog caught _allocateUninitializedArray<T> inside the closure). Move the match-list literal to a module-level constant and pre-bridge the light/dark Color arguments to NSColor at token-init time, each under the appropriate drawing appearance via NSAppearance.performAsCurrentDrawingAppearance(_:). The closure body reduces to a single bestMatch + ternary picking between two captured references — the shape Apple's NSColor(name:dynamicProvider:) docs use as the canonical example. The performAsCurrentDrawingAppearance wrapper is required so nested adaptive arguments (3 tokens in MeadowTokens.swift compose VColor.surfaceOverlay.opacity(…) etc.) resolve their inner dynamic providers in the correct branch's appearance rather than whatever ambient appearance happens to be set at module-init time. Apple refs checked (2026-05-11): - NSColor.init(name:dynamicProvider:): https://developer.apple.com/documentation/appkit/nscolor/init(name:dynamicprovider:) - NSAppearance.performAsCurrentDrawingAppearance(_:): https://developer.apple.com/documentation/appkit/nsappearance/performascurrentdrawingappearance(_:) Closes LUM-709 Resolves duplicate fingerprint LUM-468
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
There was a problem hiding this comment.
Value
Kills the largest active hang in the macOS backlog (LUM-709, 33 events / 13 users, v0.8.0). Targeted +29/-3 hot-path fix in one file that closes the watchdog gap without touching VTheme.applyTheme's NSApp.appearance machinery or any of the ~3,752 VColor.* consumer call sites. LUM-468 is the duplicate-fingerprint partner (same _allocateUninitializedArray<T> in the same closure) and gets cleared by the same edit. Approach (Option B) is correctly chosen over the asset-catalog migration, which remains the right long-term Apple-recommended pattern but is a separate workstream.
Verification
| Claim | Verified |
|---|---|
| ~56 adaptive tokens | ✅ exact — 53 in ColorTokens.swift + 3 in MeadowTokens.swift |
| Originating PR #2957 | ✅ confirmed via git log --follow |
adaptiveColor scope |
✅ only clients/shared/DesignSystem/Tokens/{ColorTokens,MeadowTokens}.swift |
| 3 nested-adaptive Meadow entries | ✅ panelBackground, panelBorder, captionText — all compose VColor.X.opacity(…) |
| Design-token CI guard compatibility | ✅ Rule B excludes shared/DesignSystem/Tokens/; new private helper resolveSwiftUIColor lives inside that scope |
Apple NSColor.init(name:dynamicProvider:) semantics |
✅ closure is invoked per component-getter call (alphaComponent, cgColor, etc.), making it a render hot path |
| Per-window appearance overrides preserved | ✅ outer closure still runs under the drawing context's appearance, bestMatch selects between the two captured NSColor refs |
Why the pre-bridge is safe
- 53 plain tokens (light/dark args are
Color(hex:)/Color(.sRGB, …)): appearance-independent. Pre-bridging produces the sameNSColorregardless of whichperformAsCurrentDrawingAppearancewe wrap it in. - 3 nested-adaptive Meadow tokens: the
Color → NSColorbridge is what triggers each nestedVColor.X's own dynamic provider.performAsCurrentDrawingAppearance(.aqua)for the light branch forces that nested resolution under the right appearance;.darkAquafor the dark branch. Without the explicit wrapper, the nested provider would resolve under whatever ambient appearance happened to be set at module-init time — the silent correctness bug this PR avoids.
Memory footprint is negligible (~56 × 2 = 112 NSColor instances retained, vs. on-demand resolution before).
Anti-pattern sweep
Clean against data/codebase/anti-patterns.md and data/codebase/swiftui-patterns.md. This PR is the removal of a hot-path allocation pattern, not the introduction of one. Worth distilling into a new entry alongside the existing repeatForever / motionVectors rules:
AppKit/UIKit dynamic color providers are a SwiftUI render hot path.
NSColor.init(name:dynamicProvider:)'s closure runs on every component-getter (alphaComponent,cgColor,currentDrawingAppearance, …), which means per-render-pass × N tokens. Keep the closure body trivial — no allocations, noNSColor(SwiftUI.Color)thunks, nobestMatch(from:)array literals. Pre-bridge at init time and capture references.
Happy to land that as a follow-up to anti-patterns.md + the AGENTS.md note this PR proposed.
Concurrency / cross-platform
- No async/concurrent code touched.
NSColoris documented thread-safe.performAsCurrentDrawingAppearance(_:)runs the closure synchronously. - macOS-only (AppKit). No iOS / web blast radius.
Non-blocking observations
resolveSwiftUIColorusesvar resolved: NSColor!and relies on the (documented) synchronous semantics ofperformAsCurrentDrawingAppearance. Defensively this could bevar resolved: NSColor?withreturn resolved ?? NSColor(color), but the current form matches Apple's own examples and isn't a correctness risk.NSAppearance(named:)lookup happens twice per token at init time — could be cached at module scope like the match list, but it's init-time work so the perf delta is invisible.
Merge gate
- Vex ✅ this review
- Devin Review: "No Issues Found" ✅
- Codex 👍 on PR description ✅
- CI: Socket Security ×2 ✅, FlexFrame Lint ✅; macOS Build/Tests SKIPPED (expected for
clients/shared/paths) - PR description ✅ — options considered, alternatives ruled out, safety analysis, Apple docs cited
Boss QA on a local build is the gating factor. Validation steps:
cd ~/Development/vellum-assistant/clients/macos && ./build.sh clean && ./build.sh run- Visual sweep across light + dark mode (window-level + system-level appearance flips) to confirm Meadow onboarding panels still render correctly under each branch.
- Instruments → Time Profiler over a long ChatView session —
_allocateUninitializedArray<T>should disappear from the closure leaf, render-pass main-thread time on dynamic colors should drop. - Confirm
[adaptiveColor:33]and_allocateUninitializedArraySentry signals go quiet on the cherry-picked build.
Merge-ready once Boss QA passes. ✦
Prompt / plan
Investigate LUM-709 (
App Hang during view rendering — culprit adaptiveColor), which Sentry attributes to the dynamic-provider closure inclients/shared/DesignSystem/Tokens/ColorTokens.swift— the largest active hang in the macOS backlog (33 events / 13 users onvellum-macos@0.8.0, last seen 2026-05-11). LUM-468 is the duplicate-fingerprint partner where the watchdog catches_allocateUninitializedArray<T>inside the same closure.After investigation we considered: (A) asset-catalog migration, (B) hoist out of the closure, (C) drop
bestMatchforappearance.name, (D)\.colorScheme-env per view, (E) notification-observer cache, (F) customShapeStyle. Recommendation approved: Option B — targeted hot-path fix without disturbingVTheme.applyTheme'sNSApp.appearance-based theme machinery or the ~3,926VColor.Xconsumer call sites. Asset catalog remains the right long-term Apple-recommended pattern but is a separate workstream.What changed
adaptiveColor(light:dark:)previously did three things on every closure invocation:[NSAppearance.Name]literal ([.darkAqua, .aqua]) — the_allocateUninitializedArray<T>leaf in LUM-468,appearance.bestMatch(from:),NSColor(SwiftUI.Color)on whichever branch matched — theNSColorthunk culprit in eventd40d12be….AppKit invokes the dynamic provider whenever any method on the color needs component values (
currentDrawingAppearance,alphaComponent,cgColor, etc., per Apple'sNSColor.init(name:dynamicProvider:)docs) — i.e. on every SwiftUI render/layout pass that touches a design token. With ~56 adaptive tokens × multiple component queries per token per render pass, this dominates main-thread time and crosses the 2-second app-hang watchdog.This PR:
private let, so the closure no longer allocates a fresh[NSAppearance.Name]per invocation.Colorarguments toNSColoronce at token-init time, each under the appropriate drawing appearance viaNSAppearance.performAsCurrentDrawingAppearance(_:). The closure body reduces to onebestMatch+ ternary picking between two captured references — the shape Apple'sNSColor.init(name:dynamicProvider:)docs use as the canonical example.performAsCurrentDrawingAppearanceso thatMeadowTokensentries which compose nested adaptive arguments (VColor.surfaceOverlay.opacity(…),VColor.surfaceBase.opacity(…),VColor.surfaceActive.opacity(…)) still resolve their inner dynamic providers in the correct branch's appearance instead of whatever ambient appearance happens to be set at module-init time.Diff is +29/-3 in one file.
Why this is safe
VTheme.applyTheme(_:)mutatesNSApp.appearance(and each window'sappearance) to implement the user's Light/Dark/System override. That mechanism propagates the chosen appearance intocurrentDrawingAppearanceat draw time, which the (still-dynamic) outerNSColorconsumes viabestMatch. Behavior is preserved end-to-end — per-window dark mode, system theme switching, and the user's manual setting all still work.NSColors are immutable and captured by reference in the closure.NSColoris documented thread-safe.ColorTokens.swift(whose arguments areColor(hex:)/Color(.sRGB, …)— appearance-independent), the pre-bridge produces the sameNSColorregardless of drawing appearance.MeadowTokensentries (panelBackground,panelBorder, and the captionText egg-glow pair),performAsCurrentDrawingAppearanceensures each branch resolves its inner dynamic provider under the right appearance.@Observable-store reads, no newLayouttypes — none of the failure modes from recent perf PRs in this area (perf(layout): override explicitAlignment to stop O(n×depth) cascade in custom Layout wrappers (LUM-1167) #28691 LUM-1167, Cache derived properties on ConversationListStore to eliminate observation cascades #29898) apply.NotificationCentercontracts are touched — safe under bothold macOS + new platformandnew macOS + old platformskews.Test plan
bunx tsc, repo lint, design-token guardrail (clients/scripts/check-design-tokens.sh— passes locally; we don't change any token values).public-API access-level issues and AppKit/SwiftUI compile errors only surface in a local build. The diff is small and access levels are unchanged (adaptiveColorstayspublic, new helpers areprivate).VTheme.applyTheme("light"/"dark"/"system")while the gallery is open, to confirm:MeadowTokens.panelBackground/panelBorder/captionTextstill pick the rightVColor.surface*opacity in dark mode (these are the nested-adaptive cases theperformAsCurrentDrawingAppearancewrapper is designed to preserve).Root cause analysis
How did the code get into this state? The function was introduced in #2957 ("feat: add light theme support, redesign onboarding, and use full-window panels", 2026-02-15, Anita Kirkovska) when the design system gained light-theme support. The closure body is functionally correct — it just inlines the per-call work that Apple's documented example does once. Untouched since.
What mistakes or decisions led to it? Apple's
NSColor.init(name:dynamicProvider:)docs say the value is "calculated on first use," which reads as if AppKit will cache. The clarification underneath ("When methods on a color need component values, AppKit calls the provider withcurrentDrawingAppearance") is easy to miss — and in practice means the closure is the SwiftUI render hot path. The original implementation reasonably assumed it would only fire on theme changes.Were there warning signs we missed? LUM-468 (
_allocateUninitializedArray<T>insideadaptiveColor's closure) has been live since at least 2026-03; the team treated it as a duplicate of LUM-709 once both surfaced. The_allocateUninitializedArray<T>leaf was the strongest signal that the closure body was running far more often than expected — anything per-call allocating on the render hot path warrants a closer look.What can prevent recurrence? The pattern to internalize: bodies of
NSColor(name:dynamicProvider:)/UIColor(dynamicProvider:)closures should be trivially fast (precomputed-reference selection only) because AppKit/UIKit invoke them on every component-getter call during draw — they are not memoized for you. The same rule applies to any other AppKit/UIKit "provider" closure that AppKit calls during layout/render (e.g.NSTextStorageattribute providers,CAMetalLayer.drawableSizeblocks).Should we add a guideline to
clients/AGENTS.md? Yes — a one-sentence note in the existing "Performance and Resource Management → View Bodies and Rendering" section is enough, since that section already establishes the "view-graph hot path must stay trivial" principle. Proposed addition (not in this PR; happy to fold in if reviewers want):Apple refs checked (2026-05-11):
NSColor.init(name:dynamicProvider:)NSAppearance.performAsCurrentDrawingAppearance(_:)NSAppearanceoverviewColoroverviewCloses LUM-709
Resolves duplicate fingerprint LUM-468
Link to Devin session: https://app.devin.ai/sessions/27fcd477b2c84786b37accab3c37b9a6
Requested by: @ashleeradka