Refactor: Replace RenderNotifier.current global with dependency injection

- Make RenderNotifier.current optional (nil by default) to support DI
- Add RenderNotifierKey and renderNotifier property to EnvironmentValues
- Initialize renderNotifier in RenderLoop.buildEnvironment()
- Migrate Spinner animation loop to read from environment
- Migrate NotificationHostModifier to read from environment via parameter
- Update StateBox.didSet to use optional chaining fallback
- Update AppStorage.wrappedValue setter to use optional chaining fallback
- Update NotificationService.post() to use optional chaining fallback
- Update test comments to reflect optional chaining pattern
- All 1069 tests pass, no new compiler warnings

Phase 3 implementation of P4.16 dependency injection refactor complete.
This commit is contained in:
phranck
2026-02-14 17:50:14 +01:00
parent 91891a9ea7
commit 0148366bf6
9 changed files with 34 additions and 10 deletions
+1
View File
@@ -351,6 +351,7 @@ extension RenderLoop {
environment.keyEventDispatcher = tuiContext.keyEventDispatcher
environment.renderCache = tuiContext.renderCache
environment.preferenceStorage = tuiContext.preferences
environment.renderNotifier = RenderNotifier.current
return environment
}
@@ -4,6 +4,14 @@
// Created by LAYERED.work
// License: MIT
// MARK: - Render Notifier (AppState)
/// EnvironmentKey for the AppState reference used to trigger re-renders.
/// Render-time consumers (Spinner, NotificationHostModifier) read from here.
private struct RenderNotifierKey: EnvironmentKey {
static let defaultValue: AppState? = nil
}
// MARK: - Lifecycle Manager
/// EnvironmentKey for view lifecycle tracking (appear/disappear/task).
@@ -57,6 +65,13 @@ private struct ActiveFocusSectionKey: EnvironmentKey {
extension EnvironmentValues {
/// The AppState reference for triggering re-renders.
/// Used by render-time consumers (Spinner, NotificationHostModifier) to signal render needs.
var renderNotifier: AppState? {
get { self[RenderNotifierKey.self] }
set { self[RenderNotifierKey.self] = newValue }
}
/// View lifecycle tracking (appear, disappear, task management).
var lifecycle: LifecycleManager? {
get { self[LifecycleKey.self] }
@@ -54,7 +54,8 @@ extension NotificationHostModifier: Renderable {
// Start the animation timer if not already running.
startAnimationTask(
entries: activeEntries,
lifecycle: context.environment.lifecycle!
lifecycle: context.environment.lifecycle!,
renderNotifier: context.environment.renderNotifier
)
let now = Date().timeIntervalSinceReferenceDate
@@ -149,7 +150,8 @@ private extension NotificationHostModifier {
/// The task stops automatically when no notifications are active.
func startAnimationTask(
entries: [NotificationEntry],
lifecycle: LifecycleManager
lifecycle: LifecycleManager,
renderNotifier: AppState?
) {
let token = "notification-host-animation"
@@ -171,12 +173,12 @@ private extension NotificationHostModifier {
}
try? await Task.sleep(nanoseconds: triggerNanos)
guard !Task.isCancelled else { break }
RenderNotifier.current.setNeedsRender()
renderNotifier?.setNeedsRender()
}
// Final render to clear expired notifications.
lifecycle.resetAppearance(token: token)
RenderNotifier.current.setNeedsRender()
renderNotifier?.setNeedsRender()
}
}
}
@@ -115,7 +115,8 @@ extension NotificationService {
lock.lock()
entries.append(entry)
lock.unlock()
RenderNotifier.current.setNeedsRender()
// Property wrapper setters lack render context, so fall back to global
RenderNotifier.current?.setNeedsRender()
}
/// Returns a snapshot of all currently active notifications.
+2 -1
View File
@@ -281,7 +281,8 @@ public struct AppStorage<Value: Codable>: @unchecked Sendable {
}
nonmutating set {
storage.setValue(newValue, forKey: key)
RenderNotifier.current.setNeedsRender()
// Property wrapper setters lack render context, so fall back to global
RenderNotifier.current?.setNeedsRender()
}
}
+2 -1
View File
@@ -283,11 +283,12 @@ private struct _SpinnerCore: View, Renderable {
_ = lifecycle.recordAppear(token: token) {}
let triggerNanos: UInt64 = 28_000_000 // 28ms matches run loop poll rate (~35 FPS)
let renderNotifier = context.environment.renderNotifier
lifecycle.startTask(token: token, priority: .medium) {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: triggerNanos)
guard !Task.isCancelled else { break }
RenderNotifier.current.setNeedsRender()
renderNotifier?.setNeedsRender()
}
}
} else {
+3 -1
View File
@@ -141,7 +141,9 @@ public enum RenderNotifier {
/// No concurrent access is possible: they are initialized once and then
/// read/written only during rendering.
///
public nonisolated(unsafe) static var current = AppState()
/// Optional fallback for property wrapper setters that lack render context.
/// Render-time consumers should read from EnvironmentValues.renderNotifier instead.
public nonisolated(unsafe) static var current: AppState?
/// The active render cache for subtree memoization.
///
+2 -1
View File
@@ -158,7 +158,8 @@ public final class StateBox<Value>: @unchecked Sendable {
} else {
RenderNotifier.renderCache?.clearAll()
}
RenderNotifier.current.setNeedsRender()
// Property wrapper setters lack render context, so fall back to global
RenderNotifier.current?.setNeedsRender()
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ struct StatePropertyWrapperTests {
@Test("State mutation triggers render via RenderNotifier")
func stateTriggerRender() {
// StateBox.didSet calls RenderNotifier.current.setNeedsRender().
// StateBox.didSet calls RenderNotifier.current?.setNeedsRender() (optional chaining).
// We swap in a fresh AppState, mutate, and check immediately.
// This is a single-expression sequence with no yield points,
// so no parallel test can interfere between set and check.