From 676760d34b010219dda998fbe8c878add7950179 Mon Sep 17 00:00:00 2001 From: Carson Katri Date: Tue, 5 Jul 2022 18:04:28 -0400 Subject: [PATCH] Add support for preferences, `@StateObject`, `@EnvironmentObject`, and custom `DynamicProperty` types (#501) * Pass preferences up the Fiber tree * Working preferences (except for backgroundPreferenceValue) * Initial StateObject support * Fix layout caching bug * Support StateObject/EnvironmentObject/custom DynamicProperty * Add doc comments for bindProperties and updateDynamicProperties * Use immediate scheduling in static HTML and test renderers * Add preferences test * Add state tests and improve testing API * Attempt to fix tests * Fix preference tests * Attempt to fix tests when Dispatch is unavailable * #if out on os(WASI) * Add check for WASI to test * Add check for WASI to TestViewProxy * #if out of import Dispatch when os == WASI * Remove all Dispatch code * Remove address from debugDescription * Move TestViewProxy to TokamakTestRenderer * Add memory address to Fiber.debugDescription * Fix copyright year * Add several missing types to Core.swift * Add missing LayoutValueKey to Core.swift * Fix issues with view trait propagation * Enable App/SceneStorage in DOMFiberRenderer * Address review comments * Revise preference implementation --- .../Fiber+CustomDebugStringConvertible.swift | 5 +- Sources/TokamakCore/Fiber/Fiber.swift | 194 ++++++++++-- .../Fiber/FiberReconciler+TreeReducer.swift | 30 +- .../TokamakCore/Fiber/FiberReconciler.swift | 75 +++-- Sources/TokamakCore/Fiber/FiberRenderer.swift | 27 ++ .../Fiber/Layout/LayoutSubviews.swift | 7 +- .../Fiber/Layout/StackLayout.swift | 1 - .../Fiber/Passes/FiberReconcilerPass.swift | 10 +- .../TokamakCore/Fiber/Passes/LayoutPass.swift | 2 +- .../Fiber/Passes/ReconcilePass.swift | 46 +-- Sources/TokamakCore/Fiber/ViewArguments.swift | 24 +- Sources/TokamakCore/Fiber/walk.swift | 22 +- .../Preferences/PreferenceKey.swift | 99 +++++- .../_PreferenceActionModifier.swift | 16 +- .../Preferences/_PreferenceReadingView.swift | 23 +- .../_PreferenceTransformModifier.swift | 12 + .../_PreferenceWritingModifier.swift | 8 + Sources/TokamakDOM/Core.swift | 10 + Sources/TokamakDOM/DOMFiberRenderer.swift | 12 + .../StaticHTMLFiberRenderer.swift | 4 + .../TestFiberRenderer.swift | 4 + .../TokamakTestRenderer/TestViewProxy.swift | 103 +++++++ .../PreferenceTests.swift | 65 ++++ .../TokamakReconcilerTests/VisitorTests.swift | 286 +++++++++--------- 24 files changed, 823 insertions(+), 262 deletions(-) create mode 100644 Sources/TokamakTestRenderer/TestViewProxy.swift create mode 100644 Tests/TokamakReconcilerTests/PreferenceTests.swift diff --git a/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift b/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift index 82b69a63..49f98bb0 100644 --- a/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift +++ b/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift @@ -17,12 +17,13 @@ extension FiberReconciler.Fiber: CustomDebugStringConvertible { public var debugDescription: String { + let memoryAddress = String(format: "%010p", unsafeBitCast(self, to: Int.self)) if case let .view(view, _) = content, let text = view as? Text { - return "Text(\"\(text.storage.rawText)\")" + return "Text(\"\(text.storage.rawText)\") (\(memoryAddress))" } - return typeInfo?.name ?? "Unknown" + return "\(typeInfo?.name ?? "Unknown") (\(memoryAddress))" } private func flush(level: Int = 0) -> String { diff --git a/Sources/TokamakCore/Fiber/Fiber.swift b/Sources/TokamakCore/Fiber/Fiber.swift index c9c0e50e..ac3ece28 100644 --- a/Sources/TokamakCore/Fiber/Fiber.swift +++ b/Sources/TokamakCore/Fiber/Fiber.swift @@ -16,7 +16,9 @@ // import Foundation +import OpenCombineShim +// swiftlint:disable type_body_length @_spi(TokamakCore) public extension FiberReconciler { /// A manager for a single `View`. @@ -83,22 +85,36 @@ public extension FiberReconciler { /// Parent references are `unowned` (as opposed to `weak`) /// because the parent will always exist if a child does. /// If the parent is released, the child is released with it. - unowned var parent: Fiber? + @_spi(TokamakCore) + public unowned var parent: Fiber? /// The nearest parent that can be mounted on. unowned var elementParent: Fiber? + /// The nearest parent that receives preferences. + unowned var preferenceParent: Fiber? + /// The cached type information for the underlying `View`. var typeInfo: TypeInfo? /// Boxes that store `State` data. var state: [PropertyInfo: MutableStorage] = [:] + /// Subscribed `Cancellable`s keyed with the property contained the observable. + /// + /// Each time properties are bound, a new subscription could be created. + /// When the subscription is overridden, the old cancellable is released. + var subscriptions: [PropertyInfo: AnyCancellable] = [:] + + /// Storage for `PreferenceKey` values as they are passed up the tree. + var preferences: _PreferenceStore? + /// The computed dimensions and origin. var geometry: ViewGeometry? /// The WIP node if this is current, or the current node if this is WIP. - weak var alternate: Fiber? + @_spi(TokamakCore) + public weak var alternate: Fiber? var createAndBindAlternate: (() -> Fiber?)? @@ -129,6 +145,7 @@ public extension FiberReconciler { element: Renderer.ElementType?, parent: Fiber?, elementParent: Fiber?, + preferenceParent: Fiber?, elementIndex: Int?, traits: _ViewTraitStore?, reconciler: FiberReconciler? @@ -138,17 +155,24 @@ public extension FiberReconciler { sibling = nil self.parent = parent self.elementParent = elementParent + self.preferenceParent = preferenceParent typeInfo = TokamakCore.typeInfo(of: V.self) let environment = parent?.outputs.environment ?? .init(.init()) - state = bindProperties(to: &view, typeInfo, environment.environment) + bindProperties(to: &view, typeInfo, environment.environment) + var updateView = view let viewInputs = ViewInputs( content: view, + updateContent: { $0(&updateView) }, environment: environment, - traits: traits + traits: traits, + preferenceStore: preferences ) outputs = V._makeView(viewInputs) - + if let preferenceStore = outputs.preferenceStore { + preferences = preferenceStore + } + view = updateView content = content(for: view) if let element = element { @@ -175,6 +199,8 @@ public extension FiberReconciler { let alternate = Fiber( bound: alternateView, state: self.state, + subscriptions: self.subscriptions, + preferences: self.preferences, layout: self.layout, alternate: self, outputs: self.outputs, @@ -182,6 +208,7 @@ public extension FiberReconciler { element: self.element, parent: self.parent?.alternate, elementParent: self.elementParent?.alternate, + preferenceParent: self.preferenceParent?.alternate, reconciler: reconciler ) self.alternate = alternate @@ -205,6 +232,8 @@ public extension FiberReconciler { init( bound view: V, state: [PropertyInfo: MutableStorage], + subscriptions: [PropertyInfo: AnyCancellable], + preferences: _PreferenceStore?, layout: AnyLayout!, alternate: Fiber, outputs: ViewOutputs, @@ -212,6 +241,7 @@ public extension FiberReconciler { element: Renderer.ElementType?, parent: FiberReconciler.Fiber?, elementParent: Fiber?, + preferenceParent: Fiber?, reconciler: FiberReconciler? ) { self.alternate = alternate @@ -221,9 +251,12 @@ public extension FiberReconciler { sibling = nil self.parent = parent self.elementParent = elementParent + self.preferenceParent = preferenceParent self.typeInfo = typeInfo self.outputs = outputs self.state = state + self.subscriptions = subscriptions + self.preferences = preferences if element != nil { self.layout = layout } @@ -234,36 +267,98 @@ public extension FiberReconciler { to content: inout T, _ typeInfo: TypeInfo?, _ environment: EnvironmentValues - ) -> [PropertyInfo: MutableStorage] { - guard let typeInfo = typeInfo else { return [:] } + ) { + var erased: Any = content + bindProperties(to: &erased, typeInfo, environment) + // swiftlint:disable:next force_cast + content = erased as! T + } + + /// Collect `DynamicProperty`s and link their state changes to the reconciler. + private func bindProperties( + to content: inout Any, + _ typeInfo: TypeInfo?, + _ environment: EnvironmentValues + ) { + guard let typeInfo = typeInfo else { return } - var state: [PropertyInfo: MutableStorage] = [:] for property in typeInfo.properties where property.type is DynamicProperty.Type { var value = property.get(from: content) + // Bind nested properties. + bindProperties(to: &value, TokamakCore.typeInfo(of: property.type), environment) + // Create boxes for `@State` and other mutable properties. if var storage = value as? WritableValueStorage { let box = self.state[property] ?? MutableStorage( initialValue: storage.anyInitialValue, onSet: { [weak self] in guard let self = self else { return } - self.reconciler?.reconcile(from: self) + self.reconciler?.fiberChanged(self) } ) state[property] = box storage.getter = { box.value } storage.setter = { box.setValue($0, with: $1) } value = storage + // Create boxes for `@StateObject` and other immutable properties. + } else if var storage = value as? ValueStorage { + let box = self.state[property] ?? MutableStorage( + initialValue: storage.anyInitialValue, + onSet: {} + ) + state[property] = box + storage.getter = { box.value } + value = storage + // Read from the environment. } else if var environmentReader = value as? EnvironmentReader { environmentReader.setContent(from: environment) value = environmentReader } + // Subscribe to observable properties. + if let observed = value as? ObservedProperty { + subscriptions[property] = observed.objectWillChange.sink { [weak self] _ in + guard let self = self else { return } + self.reconciler?.fiberChanged(self) + } + } property.set(value: value, on: &content) } if var environmentReader = content as? EnvironmentReader { environmentReader.setContent(from: environment) - // swiftlint:disable:next force_cast - content = environmentReader as! T + content = environmentReader + } + } + + /// Call `update()` on each `DynamicProperty` in the type. + private func updateDynamicProperties( + of content: inout Any, + _ typeInfo: TypeInfo? + ) { + guard let typeInfo = typeInfo else { return } + for property in typeInfo.properties where property.type is DynamicProperty.Type { + var value = property.get(from: content) + // Update nested properties. + updateDynamicProperties(of: &value, TokamakCore.typeInfo(of: property.type)) + // swiftlint:disable:next force_cast + var dynamicProperty = value as! DynamicProperty + dynamicProperty.update() + property.set(value: dynamicProperty, on: &content) + } + } + + /// Update each `DynamicProperty` in our content. + func updateDynamicProperties() { + guard let content = content else { return } + switch content { + case .app(var app, let visit): + updateDynamicProperties(of: &app, typeInfo) + self.content = .app(app, visit: visit) + case .scene(var scene, let visit): + updateDynamicProperties(of: &scene, typeInfo) + self.content = .scene(scene, visit: visit) + case .view(var view, let visit): + updateDynamicProperties(of: &view, typeInfo) + self.content = .view(view, visit: visit) } - return state } func update( @@ -276,14 +371,20 @@ public extension FiberReconciler { self.elementIndex = elementIndex let environment = parent?.outputs.environment ?? .init(.init()) - state = bindProperties(to: &view, typeInfo, environment.environment) - content = content(for: view) + bindProperties(to: &view, typeInfo, environment.environment) + var updateView = view let inputs = ViewInputs( content: view, + updateContent: { + $0(&updateView) + }, environment: environment, - traits: traits + traits: traits, + preferenceStore: preferences ) outputs = V._makeView(inputs) + view = updateView + content = content(for: view) if element != nil { layout = (view as? _AnyLayout)?._erased() ?? DefaultLayout.shared @@ -308,13 +409,26 @@ public extension FiberReconciler { // `App`s are always the root, so they can have no parent. parent = nil elementParent = nil + preferenceParent = nil element = rootElement typeInfo = TokamakCore.typeInfo(of: A.self) - state = bindProperties(to: &app, typeInfo, rootEnvironment) + bindProperties(to: &app, typeInfo, rootEnvironment) + var updateApp = app outputs = .init( - inputs: .init(content: app, environment: .init(rootEnvironment), traits: .init()) + inputs: .init( + content: app, + updateContent: { + $0(&updateApp) + }, + environment: .init(rootEnvironment), + traits: .init(), + preferenceStore: preferences + ) ) - + if let preferenceStore = outputs.preferenceStore { + preferences = preferenceStore + } + app = updateApp content = content(for: app) layout = .init(RootLayout(renderer: reconciler.renderer)) @@ -326,6 +440,8 @@ public extension FiberReconciler { let alternate = Fiber( bound: alternateApp, state: self.state, + subscriptions: self.subscriptions, + preferences: self.preferences, layout: self.layout, alternate: self, outputs: self.outputs, @@ -341,6 +457,8 @@ public extension FiberReconciler { init( bound app: A, state: [PropertyInfo: MutableStorage], + subscriptions: [PropertyInfo: AnyCancellable], + preferences: _PreferenceStore?, layout: AnyLayout?, alternate: Fiber, outputs: SceneOutputs, @@ -355,9 +473,12 @@ public extension FiberReconciler { sibling = nil parent = nil elementParent = nil + preferenceParent = nil self.typeInfo = typeInfo self.outputs = outputs self.state = state + self.subscriptions = subscriptions + self.preferences = preferences self.layout = layout content = content(for: app) } @@ -367,6 +488,7 @@ public extension FiberReconciler { element: Renderer.ElementType?, parent: Fiber?, elementParent: Fiber?, + preferenceParent: Fiber?, environment: EnvironmentBox?, reconciler: FiberReconciler? ) { @@ -376,18 +498,27 @@ public extension FiberReconciler { self.parent = parent self.elementParent = elementParent self.element = element + self.preferenceParent = preferenceParent typeInfo = TokamakCore.typeInfo(of: S.self) let environment = environment ?? parent?.outputs.environment ?? .init(.init()) - state = bindProperties(to: &scene, typeInfo, environment.environment) + bindProperties(to: &scene, typeInfo, environment.environment) + var updateScene = scene outputs = S._makeScene( .init( content: scene, + updateContent: { + $0(&updateScene) + }, environment: environment, - traits: .init() + traits: .init(), + preferenceStore: preferences ) ) - + if let preferenceStore = outputs.preferenceStore { + preferences = preferenceStore + } + scene = updateScene content = content(for: scene) if element != nil { @@ -401,6 +532,8 @@ public extension FiberReconciler { let alternate = Fiber( bound: alternateScene, state: self.state, + subscriptions: self.subscriptions, + preferences: self.preferences, layout: self.layout, alternate: self, outputs: self.outputs, @@ -408,6 +541,7 @@ public extension FiberReconciler { element: self.element, parent: self.parent?.alternate, elementParent: self.elementParent?.alternate, + preferenceParent: self.preferenceParent?.alternate, reconciler: reconciler ) self.alternate = alternate @@ -431,6 +565,8 @@ public extension FiberReconciler { init( bound scene: S, state: [PropertyInfo: MutableStorage], + subscriptions: [PropertyInfo: AnyCancellable], + preferences: _PreferenceStore?, layout: AnyLayout!, alternate: Fiber, outputs: SceneOutputs, @@ -438,6 +574,7 @@ public extension FiberReconciler { element: Renderer.ElementType?, parent: FiberReconciler.Fiber?, elementParent: Fiber?, + preferenceParent: Fiber?, reconciler: FiberReconciler? ) { self.alternate = alternate @@ -447,9 +584,12 @@ public extension FiberReconciler { sibling = nil self.parent = parent self.elementParent = elementParent + self.preferenceParent = preferenceParent self.typeInfo = typeInfo self.outputs = outputs self.state = state + self.subscriptions = subscriptions + self.preferences = preferences if element != nil { self.layout = layout } @@ -462,13 +602,19 @@ public extension FiberReconciler { typeInfo = TokamakCore.typeInfo(of: S.self) let environment = parent?.outputs.environment ?? .init(.init()) - state = bindProperties(to: &scene, typeInfo, environment.environment) - content = content(for: scene) + bindProperties(to: &scene, typeInfo, environment.environment) + var updateScene = scene outputs = S._makeScene(.init( content: scene, + updateContent: { + $0(&updateScene) + }, environment: environment, - traits: .init() + traits: .init(), + preferenceStore: preferences )) + scene = updateScene + content = content(for: scene) if element != nil { layout = (scene as? _AnyLayout)?._erased() ?? DefaultLayout.shared diff --git a/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift b/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift index 46db9e2d..1d85dc54 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift @@ -29,12 +29,12 @@ extension FiberReconciler { var sibling: Result? var newContent: Renderer.ElementType.Content? var elementIndices: [ObjectIdentifier: Int] + var nextTraits: _ViewTraitStore // For reducing var lastSibling: Result? var nextExisting: Fiber? var nextExistingAlternate: Fiber? - var pendingTraits: _ViewTraitStore init( fiber: Fiber?, @@ -44,7 +44,7 @@ extension FiberReconciler { alternateChild: Fiber?, newContent: Renderer.ElementType.Content? = nil, elementIndices: [ObjectIdentifier: Int], - pendingTraits: _ViewTraitStore + nextTraits: _ViewTraitStore ) { self.fiber = fiber self.visitChildren = visitChildren @@ -53,7 +53,7 @@ extension FiberReconciler { nextExistingAlternate = alternateChild self.newContent = newContent self.elementIndices = elementIndices - self.pendingTraits = pendingTraits + self.nextTraits = nextTraits } } @@ -61,12 +61,13 @@ extension FiberReconciler { Self.reduce( into: &partialResult, nextValue: nextScene, - createFiber: { scene, element, parent, elementParent, _, _, reconciler in + createFiber: { scene, element, parent, elementParent, preferenceParent, _, _, reconciler in Fiber( &scene, element: element, parent: parent, elementParent: elementParent, + preferenceParent: preferenceParent, environment: nil, reconciler: reconciler ) @@ -82,12 +83,16 @@ extension FiberReconciler { Self.reduce( into: &partialResult, nextValue: nextView, - createFiber: { view, element, parent, elementParent, elementIndex, traits, reconciler in + createFiber: { + view, element, + parent, elementParent, preferenceParent, elementIndex, + traits, reconciler in Fiber( &view, element: element, parent: parent, elementParent: elementParent, + preferenceParent: preferenceParent, elementIndex: elementIndex, traits: traits, reconciler: reconciler @@ -114,6 +119,7 @@ extension FiberReconciler { Renderer.ElementType?, Fiber?, Fiber?, + Fiber?, Int?, _ViewTraitStore, FiberReconciler? @@ -123,6 +129,7 @@ extension FiberReconciler { ) { // Create the node and its element. var nextValue = nextValue + let resultChild: Result if let existing = partialResult.nextExisting { // If a fiber already exists, simply update it with the new view. @@ -136,7 +143,7 @@ extension FiberReconciler { existing, &nextValue, key.map { partialResult.elementIndices[$0, default: 0] }, - partialResult.pendingTraits + partialResult.nextTraits ) resultChild = Result( fiber: existing, @@ -146,7 +153,7 @@ extension FiberReconciler { alternateChild: existing.alternate?.child, newContent: newContent, elementIndices: partialResult.elementIndices, - pendingTraits: existing.element != nil ? .init() : partialResult.pendingTraits + nextTraits: existing.element != nil ? .init() : partialResult.nextTraits ) partialResult.nextExisting = existing.sibling partialResult.nextExistingAlternate = partialResult.nextExistingAlternate?.sibling @@ -154,6 +161,9 @@ extension FiberReconciler { let elementParent = partialResult.fiber?.element != nil ? partialResult.fiber : partialResult.fiber?.elementParent + let preferenceParent = partialResult.fiber?.preferences != nil + ? partialResult.fiber + : partialResult.fiber?.preferenceParent let key: ObjectIdentifier? if let elementParent = elementParent { key = ObjectIdentifier(elementParent) @@ -166,10 +176,12 @@ extension FiberReconciler { partialResult.nextExistingAlternate?.element, partialResult.fiber, elementParent, + preferenceParent, key.map { partialResult.elementIndices[$0, default: 0] }, - partialResult.pendingTraits, + partialResult.nextTraits, partialResult.fiber?.reconciler ) + // If a fiber already exists for an alternate, link them. if let alternate = partialResult.nextExistingAlternate { fiber.alternate = alternate @@ -182,7 +194,7 @@ extension FiberReconciler { child: nil, alternateChild: fiber.alternate?.child, elementIndices: partialResult.elementIndices, - pendingTraits: fiber.element != nil ? .init() : partialResult.pendingTraits + nextTraits: fiber.element != nil ? .init() : partialResult.nextTraits ) } // Get the last child element we've processed, and add the new child as its sibling. diff --git a/Sources/TokamakCore/Fiber/FiberReconciler.swift b/Sources/TokamakCore/Fiber/FiberReconciler.swift index 8dafeb0e..150a7860 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler.swift @@ -39,6 +39,13 @@ public final class FiberReconciler { private let caches: Caches private var isReconciling = false + /// The identifiers for each `Fiber` that changed state during the last run loop. + /// + /// The reconciler loop starts at the root of the `View` hierarchy + /// to ensure all preference values are passed down correctly. + /// To help mitigate performance issues related to this, we only perform reconcile + /// checks when we reach a changed `Fiber`. + private var changedFibers = Set() public var afterReconcileActions = [() -> ()]() struct RootView: View { @@ -105,13 +112,14 @@ public final class FiberReconciler { element: renderer.rootElement, parent: nil, elementParent: nil, + preferenceParent: nil, elementIndex: 0, traits: nil, reconciler: self ) // Start by building the initial tree. alternate = current.createAndBindAlternate?() - reconcile(from: current) + fiberChanged(current) } public init(_ renderer: Renderer, _ app: A) { @@ -135,19 +143,20 @@ public final class FiberReconciler { ) // Start by building the initial tree. alternate = current.createAndBindAlternate?() - reconcile(from: current) + fiberChanged(current) } /// A visitor that performs each pass used by the `FiberReconciler`. final class ReconcilerVisitor: AppVisitor, SceneVisitor, ViewVisitor { let root: Fiber - let reconcileRoot: Fiber + /// Any `Fiber`s that changed state during the last run loop. + let changedFibers: Set unowned let reconciler: FiberReconciler var mutations = [Mutation]() - init(root: Fiber, reconcileRoot: Fiber, reconciler: FiberReconciler) { + init(root: Fiber, changedFibers: Set, reconciler: FiberReconciler) { self.root = root - self.reconcileRoot = reconcileRoot + self.changedFibers = changedFibers self.reconciler = reconciler } @@ -173,13 +182,6 @@ public final class FiberReconciler { } else { alternateRoot = root.createAndBindAlternate?() } - let alternateReconcileRoot: Fiber? - if let alternate = reconcileRoot.alternate { - alternateReconcileRoot = alternate - } else { - alternateReconcileRoot = reconcileRoot.createAndBindAlternate?() - } - guard let alternateReconcileRoot = alternateReconcileRoot else { return } let rootResult = TreeReducer.Result( fiber: alternateRoot, // The alternate is the WIP node. visitChildren: visitChildren, @@ -187,14 +189,14 @@ public final class FiberReconciler { child: alternateRoot?.child, alternateChild: root.child, elementIndices: [:], - pendingTraits: .init() + nextTraits: .init() ) reconciler.caches.clear() for pass in reconciler.passes { pass.run( in: reconciler, root: rootResult, - reconcileRoot: alternateReconcileRoot, + changedFibers: changedFibers, caches: reconciler.caches ) } @@ -211,18 +213,31 @@ public final class FiberReconciler { afterReconcileActions.append(action) } - func reconcile(from updateRoot: Fiber) { - isReconciling = true - let root: Fiber - if renderer.useDynamicLayout { - // We need to re-layout from the top down when using dynamic layout. - root = current - } else { - root = updateRoot + /// Called by any `Fiber` that experiences a state change. + /// + /// Reconciliation only runs after every change during the current run loop has been performed. + func fiberChanged(_ fiber: Fiber) { + guard let alternate = fiber.alternate ?? fiber.createAndBindAlternate?() + else { return } + let shouldSchedule = changedFibers.isEmpty + changedFibers.insert(ObjectIdentifier(alternate)) + if shouldSchedule { + renderer.schedule { [weak self] in + self?.reconcile() + } } + } + + /// Perform each `FiberReconcilerPass` given the `changedFibers`. + /// + /// A `reconcile()` call is queued from `fiberChanged` once per run loop. + func reconcile() { + isReconciling = true + let changedFibers = changedFibers + self.changedFibers.removeAll() // Create a list of mutations. - let visitor = ReconcilerVisitor(root: root, reconcileRoot: updateRoot, reconciler: self) - switch root.content { + let visitor = ReconcilerVisitor(root: current, changedFibers: changedFibers, reconciler: self) + switch current.content { case let .view(_, visit): visit(visitor) case let .scene(_, visit): @@ -240,15 +255,9 @@ public final class FiberReconciler { // Essentially, making the work in progress tree the current, // and leaving the current available to be the work in progress // on our next update. - if root === current { - let alternate = alternate - self.alternate = current - current = alternate - } else { - let child = root.child - root.child = root.alternate?.child - root.alternate?.child = child - } + let alternate = alternate + self.alternate = current + current = alternate isReconciling = false diff --git a/Sources/TokamakCore/Fiber/FiberRenderer.swift b/Sources/TokamakCore/Fiber/FiberRenderer.swift index 66dcc039..03f93c5b 100644 --- a/Sources/TokamakCore/Fiber/FiberRenderer.swift +++ b/Sources/TokamakCore/Fiber/FiberRenderer.swift @@ -57,6 +57,33 @@ public protocol FiberRenderer { proposal: ProposedViewSize, in environment: EnvironmentValues ) -> CGSize + + /// Run `action` on the next run loop. + /// + /// Called by the `FiberReconciler` to perform reconciliation after all changed Fibers are collected. + /// + /// For example, take the following sample `View`: + /// + /// struct DuelOfTheStates: View { + /// @State private var hits1 = 0 + /// @State private var hits2 = 0 + /// + /// var body: some View { + /// Button("Hit") { + /// hits1 += 1 + /// hits2 += 2 + /// } + /// } + /// } + /// + /// When the button is pressed, both `hits1` and `hits2` are updated. + /// If reconciliation was done on every state change, we would needlessly run it twice, + /// once for `hits1` and again for `hits2`. + /// + /// Instead, we create a list of changed fibers + /// (in this case just `DuelOfTheStates` as both properties were on it), + /// and reconcile after all changes have been collected. + func schedule(_ action: @escaping () -> ()) } public extension FiberRenderer { diff --git a/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift b/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift index b71ce34e..2f129877 100644 --- a/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift +++ b/Sources/TokamakCore/Fiber/Layout/LayoutSubviews.swift @@ -141,8 +141,9 @@ public struct LayoutSubview: Equatable { ) cache.sizeThatFits[request] = size if let alternate = fiber.alternate { - caches.updateLayoutCache(for: alternate) { cache in - cache.sizeThatFits[request] = size + caches.updateLayoutCache(for: alternate) { alternateCache in + alternateCache.cache = cache.cache + alternateCache.sizeThatFits[request] = size } } return size @@ -246,6 +247,6 @@ public struct LayoutSubview: Equatable { } public static func == (lhs: LayoutSubview, rhs: LayoutSubview) -> Bool { - lhs.id == rhs.id + lhs.storage === rhs.storage } } diff --git a/Sources/TokamakCore/Fiber/Layout/StackLayout.swift b/Sources/TokamakCore/Fiber/Layout/StackLayout.swift index b8da37c3..4edacae5 100644 --- a/Sources/TokamakCore/Fiber/Layout/StackLayout.swift +++ b/Sources/TokamakCore/Fiber/Layout/StackLayout.swift @@ -117,7 +117,6 @@ public extension StackLayout { /// The aggregate minimum size of each `View` with a given priority. var prioritySize = [Double: CGFloat]() - let measuredSubviews = subviews.enumerated().map { index, view -> MeasuredSubview in priorityCount[view.priority, default: 0] += 1 diff --git a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift index 9111d734..7c005336 100644 --- a/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift +++ b/Sources/TokamakCore/Fiber/Passes/FiberReconcilerPass.swift @@ -61,9 +61,9 @@ extension FiberReconciler { } func clear() { - elementIndices = [:] - layoutSubviews = [:] - mutations = [] + elementIndices.removeAll() + layoutSubviews.removeAll() + mutations.removeAll() } func layoutCache(for fiber: Fiber) -> LayoutCache? { @@ -123,14 +123,14 @@ protocol FiberReconcilerPass { /// - Parameter root: The node to start the pass from. /// The top of the `View` hierarchy when `useDynamicLayout` is enabled. /// Otherwise, the same as `reconcileRoot`. - /// - Parameter reconcileRoot: The topmost node that needs reconciliation. + /// - Parameter reconcileRoot: A list of topmost nodes that need reconciliation. /// When `useDynamicLayout` is enabled, this can be used to limit /// the number of operations performed during reconciliation. /// - Parameter caches: The shared cache data for this and other passes. func run( in reconciler: FiberReconciler, root: FiberReconciler.TreeReducer.Result, - reconcileRoot: FiberReconciler.Fiber, + changedFibers: Set, caches: FiberReconciler.Caches ) } diff --git a/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift b/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift index 7ef73d31..d36623cc 100644 --- a/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift +++ b/Sources/TokamakCore/Fiber/Passes/LayoutPass.swift @@ -22,7 +22,7 @@ struct LayoutPass: FiberReconcilerPass { func run( in reconciler: FiberReconciler, root: FiberReconciler.TreeReducer.Result, - reconcileRoot: FiberReconciler.Fiber, + changedFibers: Set, caches: FiberReconciler.Caches ) where R: FiberRenderer { guard let root = root.fiber else { return } diff --git a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift index c70205ea..c2efdef4 100644 --- a/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift +++ b/Sources/TokamakCore/Fiber/Passes/ReconcilePass.swift @@ -63,7 +63,7 @@ struct ReconcilePass: FiberReconcilerPass { func run( in reconciler: FiberReconciler, root: FiberReconciler.TreeReducer.Result, - reconcileRoot: FiberReconciler.Fiber, + changedFibers: Set, caches: FiberReconciler.Caches ) where R: FiberRenderer { var node = root @@ -71,12 +71,17 @@ struct ReconcilePass: FiberReconcilerPass { // Enabled when we reach the `reconcileRoot`. var shouldReconcile = false - // Traits that should be attached to the nearest rendered child. - var pendingTraits = _ViewTraitStore() - while true { - if node.fiber === reconcileRoot || node.fiber?.alternate === reconcileRoot { - shouldReconcile = true + if !shouldReconcile { + if let fiber = node.fiber, + changedFibers.contains(ObjectIdentifier(fiber)) + { + shouldReconcile = true + } else if let alternate = node.fiber?.alternate, + changedFibers.contains(ObjectIdentifier(alternate)) + { + shouldReconcile = true + } } // If this fiber has an element, set its `elementIndex` @@ -94,27 +99,23 @@ struct ReconcilePass: FiberReconcilerPass { caches.mutations.append(mutation) } + // Ensure the `TreeReducer` can access any necessary state. + node.elementIndices = caches.elementIndices // Pass view traits down to the nearest element fiber. if let traits = node.fiber?.outputs.traits, !traits.values.isEmpty { - if node.fiber?.element == nil { - pendingTraits = traits - } - } - // Clear the pending traits once they have been applied to the target. - if node.fiber?.element != nil && !pendingTraits.values.isEmpty { - pendingTraits = .init() + node.nextTraits.values.merge(traits.values, uniquingKeysWith: { $1 }) } - // Ensure the `TreeReducer` can access any necessary state. - node.elementIndices = caches.elementIndices - node.pendingTraits = pendingTraits - + // Update `DynamicProperty`s before accessing the `View`'s body. + node.fiber?.updateDynamicProperties() // Compute the children of the node. let reducer = FiberReconciler.TreeReducer.SceneVisitor(initialResult: node) node.visitChildren(reducer) + node.fiber?.preferences?.reset() + if reconciler.renderer.useDynamicLayout, let fiber = node.fiber { @@ -124,7 +125,7 @@ struct ReconcilePass: FiberReconcilerPass { let parentKey = ObjectIdentifier(elementParent) let subview = LayoutSubview( id: ObjectIdentifier(fiber), - traits: node.fiber?.outputs.traits, + traits: fiber.outputs.traits, fiber: fiber, element: element, caches: caches @@ -177,6 +178,15 @@ struct ReconcilePass: FiberReconcilerPass { propagateCacheInvalidation(for: fiber, in: reconciler, caches: caches) } + if let preferences = node.fiber?.preferences { + if let action = node.fiber?.outputs.preferenceAction { + action(preferences) + } + if let parentPreferences = node.fiber?.preferenceParent?.preferences { + parentPreferences.merge(preferences) + } + } + var alternateSibling = node.fiber?.alternate?.sibling // The alternate had siblings that no longer exist. while alternateSibling != nil { diff --git a/Sources/TokamakCore/Fiber/ViewArguments.swift b/Sources/TokamakCore/Fiber/ViewArguments.swift index 332c37bd..3a2d316e 100644 --- a/Sources/TokamakCore/Fiber/ViewArguments.swift +++ b/Sources/TokamakCore/Fiber/ViewArguments.swift @@ -21,10 +21,17 @@ import Foundation public struct ViewInputs { public let content: V + /// Mutate the underlying content with the given inputs. + /// + /// Used to inject values such as environment values, traits, and preferences into the `View` type. + public let updateContent: ((inout V) -> ()) -> () + @_spi(TokamakCore) public let environment: EnvironmentBox public let traits: _ViewTraitStore? + + public let preferenceStore: _PreferenceStore? } /// Data used to reconcile and render a `View` and its children. @@ -33,7 +40,12 @@ public struct ViewOutputs { /// This is stored as a reference to avoid copying the environment when unnecessary. let environment: EnvironmentBox - let preferences: _PreferenceStore + let preferenceStore: _PreferenceStore? + + /// An action to perform after all preferences values have been reduced. + /// + /// Called when walking back up the tree in the `ReconcilePass`. + let preferenceAction: ((_PreferenceStore) -> ())? let traits: _ViewTraitStore? } @@ -51,13 +63,15 @@ public extension ViewOutputs { init( inputs: ViewInputs, environment: EnvironmentValues? = nil, - preferences: _PreferenceStore? = nil, + preferenceStore: _PreferenceStore? = nil, + preferenceAction: ((_PreferenceStore) -> ())? = nil, traits: _ViewTraitStore? = nil ) { // Only replace the `EnvironmentBox` when we change the environment. // Otherwise the same box can be reused. self.environment = environment.map(EnvironmentBox.init) ?? inputs.environment - self.preferences = preferences ?? .init() + self.preferenceStore = preferenceStore + self.preferenceAction = preferenceAction self.traits = traits ?? inputs.traits } } @@ -74,8 +88,10 @@ public extension ModifiedContent where Content: View, Modifier: ViewModifier { static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { Modifier._makeView(.init( content: inputs.content.modifier, + updateContent: { _ in }, environment: inputs.environment, - traits: inputs.traits + traits: inputs.traits, + preferenceStore: inputs.preferenceStore )) } diff --git a/Sources/TokamakCore/Fiber/walk.swift b/Sources/TokamakCore/Fiber/walk.swift index 91ec887f..c26060fd 100644 --- a/Sources/TokamakCore/Fiber/walk.swift +++ b/Sources/TokamakCore/Fiber/walk.swift @@ -15,20 +15,24 @@ // Created by Carson Katri on 2/11/22. // -enum WalkWorkResult { +@_spi(TokamakCore) +public enum WalkWorkResult { case `continue` case `break`(with: Success) case pause } -enum WalkResult { +@_spi(TokamakCore) +public enum WalkResult { case success(Success) case finished case paused(at: FiberReconciler.Fiber) } +/// Walk a fiber tree from `root` until the `work` predicate returns `false`. +@_spi(TokamakCore) @discardableResult -func walk( +public func walk( _ root: FiberReconciler.Fiber, _ work: @escaping (FiberReconciler.Fiber) throws -> Bool ) rethrows -> WalkResult { @@ -38,7 +42,17 @@ func walk( } /// Parent-first depth-first traversal of a `Fiber` tree. -func walk( +/// `work` is called with each `Fiber` in the tree as they are entered. +/// +/// Traversal uses the following process: +/// 1. Perform work on the current `Fiber`. +/// 2. If the `Fiber` has a child, repeat from (1) with the child. +/// 3. If the `Fiber` does not have a sibling, walk up until we find a `Fiber` that does have one. +/// 4. Walk across to the sibling. +/// +/// When the `root` is reached, the loop exits. +@_spi(TokamakCore) +public func walk( _ root: FiberReconciler.Fiber, _ work: @escaping (FiberReconciler.Fiber) throws -> WalkWorkResult ) rethrows -> WalkResult { diff --git a/Sources/TokamakCore/Preferences/PreferenceKey.swift b/Sources/TokamakCore/Preferences/PreferenceKey.swift index d8ca9a65..913be61d 100644 --- a/Sources/TokamakCore/Preferences/PreferenceKey.swift +++ b/Sources/TokamakCore/Preferences/PreferenceKey.swift @@ -25,12 +25,41 @@ public extension PreferenceKey where Self.Value: ExpressibleByNilLiteral { static var defaultValue: Value { nil } } -public struct _PreferenceValue where Key: PreferenceKey { +final class _PreferenceValueStorage: CustomDebugStringConvertible { /// Every value the `Key` has had. - var valueList: [Key.Value] + var valueList: [Any] + + var debugDescription: String { + valueList.debugDescription + } + + init(_ key: Key.Type = Key.self) { + valueList = [] + } + + init(valueList: [Any]) { + self.valueList = valueList + } + + func merge(_ other: _PreferenceValueStorage) { + valueList.append(contentsOf: other.valueList) + } + + func reset() { + valueList = [] + } +} + +public struct _PreferenceValue where Key: PreferenceKey { + var storage: _PreferenceValueStorage + + init(storage: _PreferenceValueStorage) { + self.storage = storage + } + /// The latest value. public var value: Key.Value { - reduce(valueList) + reduce(storage.valueList.compactMap { $0 as? Key.Value }) } func reduce(_ values: [Key.Value]) -> Key.Value { @@ -48,30 +77,80 @@ public extension _PreferenceValue { } } -public final class _PreferenceStore { +public final class _PreferenceStore: CustomDebugStringConvertible { + /// The values of the `_PreferenceStore` on the last update. + private var previousValues: [ObjectIdentifier: _PreferenceValueStorage] /// The backing values of the `_PreferenceStore`. - private var values: [String: Any] + private var values: [ObjectIdentifier: _PreferenceValueStorage] weak var parent: _PreferenceStore? - public init(values: [String: Any] = [:]) { + public var debugDescription: String { + "Preferences (\(ObjectIdentifier(self))): \(values)" + } + + init(values: [ObjectIdentifier: _PreferenceValueStorage] = [:]) { + previousValues = [:] self.values = values } + /// Retrieve a late-binding token for `key`, or save the default value if it does not yet exist. public func value(forKey key: Key.Type = Key.self) -> _PreferenceValue where Key: PreferenceKey { - values[String(reflecting: key)] as? _PreferenceValue - ?? _PreferenceValue(valueList: [Key.defaultValue]) + let keyID = ObjectIdentifier(key) + let storage: _PreferenceValueStorage + if let existing = values[keyID] { + storage = existing + } else { + storage = .init(key) + values[keyID] = storage + } + return _PreferenceValue(storage: storage) + } + + /// Retrieve the value `Key` had on the last update. + /// + /// Used to check if the value changed during the last update. + func previousValue(forKey key: Key.Type = Key.self) -> _PreferenceValue + where Key: PreferenceKey + { + _PreferenceValue(storage: previousValues[ObjectIdentifier(key)] ?? .init(key)) } public func insert(_ value: Key.Value, forKey key: Key.Type = Key.self) where Key: PreferenceKey { - let previousValues = self.value(forKey: key).valueList - values[String(reflecting: key)] = _PreferenceValue(valueList: previousValues + [value]) + let keyID = ObjectIdentifier(key) + if !values.keys.contains(keyID) { + values[keyID] = .init(key) + } + values[keyID]?.valueList.append(value) parent?.insert(value, forKey: key) } + + func merge(_ other: _PreferenceStore) { + for (key, otherStorage) in other.values { + if let storage = values[key] { + storage.merge(otherStorage) + } else { + values[key] = .init(valueList: otherStorage.valueList) + } + } + } + + /// Copies `values` to `previousValues`, and clears `values`. + /// + /// Each reconcile pass the preferences are collected from scratch, so we need to + /// clear out the old values. + func reset() { + previousValues = values.mapValues { + _PreferenceValueStorage(valueList: $0.valueList) + } + for storage in values.values { + storage.reset() + } + } } /// A protocol that allows a `View` to read values from the current `_PreferenceStore`. diff --git a/Sources/TokamakCore/Preferences/_PreferenceActionModifier.swift b/Sources/TokamakCore/Preferences/_PreferenceActionModifier.swift index 0356ee41..3ea1d89c 100644 --- a/Sources/TokamakCore/Preferences/_PreferenceActionModifier.swift +++ b/Sources/TokamakCore/Preferences/_PreferenceActionModifier.swift @@ -25,12 +25,26 @@ public struct _PreferenceActionModifier: _PreferenceWritingModifierProtocol public func body(_ content: Content, with preferenceStore: inout _PreferenceStore) -> AnyView { let value = preferenceStore.value(forKey: Key.self) - let previousValue = value.reduce(value.valueList.dropLast()) + let previousValue = value.reduce((value.storage.valueList as? [Key.Value] ?? []).dropLast()) if previousValue != value.value { action(value.value) } return content.view } + + public static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { + .init( + inputs: inputs, + preferenceStore: inputs.preferenceStore ?? .init(), + preferenceAction: { + let value = $0.value(forKey: Key.self).value + let previousValue = $0.previousValue(forKey: Key.self).value + if value != previousValue { + inputs.content.action(value) + } + } + ) + } } public extension View { diff --git a/Sources/TokamakCore/Preferences/_PreferenceReadingView.swift b/Sources/TokamakCore/Preferences/_PreferenceReadingView.swift index fdda2552..00bbec5f 100644 --- a/Sources/TokamakCore/Preferences/_PreferenceReadingView.swift +++ b/Sources/TokamakCore/Preferences/_PreferenceReadingView.swift @@ -21,11 +21,11 @@ public struct _DelayedPreferenceView: View, _PreferenceReadingView where Key: PreferenceKey, Content: View { @State - private var resolvedValue: _PreferenceValue = _PreferenceValue( - valueList: [Key.defaultValue] - ) + private var resolvedValue: _PreferenceValue = _PreferenceValue(storage: .init(Key.self)) public let transform: (_PreferenceValue) -> Content + private var valueReference: _PreferenceValue? + public init(transform: @escaping (_PreferenceValue) -> Content) { self.transform = transform } @@ -35,7 +35,18 @@ public struct _DelayedPreferenceView: View, _PreferenceReadingView } public var body: some View { - transform(resolvedValue) + transform(valueReference ?? resolvedValue) + } + + public static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { + let preferenceStore = inputs.preferenceStore ?? .init() + inputs.updateContent { + $0.valueReference = preferenceStore.value(forKey: Key.self) + } + return .init( + inputs: inputs, + preferenceStore: preferenceStore + ) } } @@ -70,7 +81,7 @@ public extension View { ) -> some View where Key: PreferenceKey, T: View { - Key._delay { self.overlay(transform($0.value)) } + Key._delay { self.overlay($0._force(transform)) } } func backgroundPreferenceValue( @@ -79,6 +90,6 @@ public extension View { ) -> some View where Key: PreferenceKey, T: View { - Key._delay { self.background(transform($0.value)) } + Key._delay { self.background($0._force(transform)) } } } diff --git a/Sources/TokamakCore/Preferences/_PreferenceTransformModifier.swift b/Sources/TokamakCore/Preferences/_PreferenceTransformModifier.swift index 9fbc0ea3..84102951 100644 --- a/Sources/TokamakCore/Preferences/_PreferenceTransformModifier.swift +++ b/Sources/TokamakCore/Preferences/_PreferenceTransformModifier.swift @@ -34,6 +34,18 @@ public struct _PreferenceTransformModifier: _PreferenceWritingModifierProto preferenceStore.insert(newValue, forKey: Key.self) return content.view } + + public static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { + .init( + inputs: inputs, + preferenceStore: inputs.preferenceStore ?? .init(), + preferenceAction: { + var value = $0.value(forKey: Key.self).value + inputs.content.transform(&value) + $0.insert(value, forKey: Key.self) + } + ) + } } public extension View { diff --git a/Sources/TokamakCore/Preferences/_PreferenceWritingModifier.swift b/Sources/TokamakCore/Preferences/_PreferenceWritingModifier.swift index 0eec368c..cca6ce1f 100644 --- a/Sources/TokamakCore/Preferences/_PreferenceWritingModifier.swift +++ b/Sources/TokamakCore/Preferences/_PreferenceWritingModifier.swift @@ -27,6 +27,14 @@ public struct _PreferenceWritingModifier: _PreferenceWritingModifierProtoco preferenceStore.insert(value, forKey: Key.self) return content.view } + + public static func _makeView(_ inputs: ViewInputs) -> ViewOutputs { + .init( + inputs: inputs, + preferenceStore: inputs.preferenceStore ?? .init(), + preferenceAction: { $0.insert(inputs.content.value, forKey: Key.self) } + ) + } } extension _PreferenceWritingModifier: Equatable where Key.Value: Equatable { diff --git a/Sources/TokamakDOM/Core.swift b/Sources/TokamakDOM/Core.swift index 803616c5..30abdc7e 100644 --- a/Sources/TokamakDOM/Core.swift +++ b/Sources/TokamakDOM/Core.swift @@ -19,6 +19,8 @@ import TokamakCore // MARK: Environment & State +public typealias DynamicProperty = TokamakCore.DynamicProperty + public typealias Environment = TokamakCore.Environment public typealias EnvironmentKey = TokamakCore.EnvironmentKey public typealias EnvironmentObject = TokamakCore.EnvironmentObject @@ -180,8 +182,16 @@ public typealias View = TokamakCore.View public typealias AnyView = TokamakCore.AnyView public typealias EmptyView = TokamakCore.EmptyView +// MARK: Layout + public typealias Layout = TokamakCore.Layout public typealias AnyLayout = TokamakCore.AnyLayout +public typealias LayoutProperties = TokamakCore.LayoutProperties +public typealias LayoutSubviews = TokamakCore.LayoutSubviews +public typealias LayoutSubview = TokamakCore.LayoutSubview +public typealias LayoutValueKey = TokamakCore.LayoutValueKey +public typealias ProposedViewSize = TokamakCore.ProposedViewSize +public typealias ViewSpacing = TokamakCore.ViewSpacing // MARK: Toolbars diff --git a/Sources/TokamakDOM/DOMFiberRenderer.swift b/Sources/TokamakDOM/DOMFiberRenderer.swift index f9d21da3..db5a50da 100644 --- a/Sources/TokamakDOM/DOMFiberRenderer.swift +++ b/Sources/TokamakDOM/DOMFiberRenderer.swift @@ -17,6 +17,7 @@ import Foundation import JavaScriptKit +import OpenCombineJS @_spi(TokamakCore) import TokamakCore @_spi(TokamakStaticHTML) @@ -88,6 +89,8 @@ public struct DOMFiberRenderer: FiberRenderer { public var defaultEnvironment: EnvironmentValues { var environment = EnvironmentValues() environment[_ColorSchemeKey.self] = .light + environment._defaultAppStorage = LocalStorage.standard + _DefaultSceneStorageProvider.default = SessionStorage.standard return environment } @@ -117,6 +120,10 @@ public struct DOMFiberRenderer: FiberRenderer { _ = reference.style.setProperty("width", "100vw") _ = reference.style.setProperty("height", "100vh") _ = reference.style.setProperty("position", "relative") + } else { + let style = document.createElement!("style").object! + style.innerHTML = .string(TokamakStaticHTML.tokamakStyles) + _ = document.head.appendChild(style) } } @@ -299,6 +306,11 @@ public struct DOMFiberRenderer: FiberRenderer { } } } + + private let scheduler = JSScheduler() + public func schedule(_ action: @escaping () -> ()) { + scheduler.schedule(options: nil, action) + } } extension _PrimitiveButtonStyleBody: DOMNodeConvertible { diff --git a/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift b/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift index 8a5c1bda..0b259b70 100644 --- a/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift +++ b/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift @@ -253,4 +253,8 @@ public struct StaticHTMLFiberRenderer: FiberRenderer { """ } + + public func schedule(_ action: @escaping () -> ()) { + action() + } } diff --git a/Sources/TokamakTestRenderer/TestFiberRenderer.swift b/Sources/TokamakTestRenderer/TestFiberRenderer.swift index 33663820..5713d24a 100644 --- a/Sources/TokamakTestRenderer/TestFiberRenderer.swift +++ b/Sources/TokamakTestRenderer/TestFiberRenderer.swift @@ -176,4 +176,8 @@ public struct TestFiberRenderer: FiberRenderer { } } } + + public func schedule(_ action: @escaping () -> ()) { + action() + } } diff --git a/Sources/TokamakTestRenderer/TestViewProxy.swift b/Sources/TokamakTestRenderer/TestViewProxy.swift new file mode 100644 index 00000000..34dd51c7 --- /dev/null +++ b/Sources/TokamakTestRenderer/TestViewProxy.swift @@ -0,0 +1,103 @@ +// Copyright 2022 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 6/30/22. +// + +import Foundation + +@_spi(TokamakCore) +import TokamakCore + +/// A proxy for an identified view in the `TestFiberRenderer`. +/// +/// The properties are evaluated on access, +/// so you will never unintentionally access an `alternate` value. +@dynamicMemberLookup +public struct TestViewProxy { + /// The id to lookup. + let id: AnyHashable + + /// The active reconciler instance to search in. + let reconciler: FiberReconciler + + /// Searches for a `Fiber` representing `id`. + /// + /// - Note: This returns the child of the `identified(by:)` modifier, + /// not the `IdentifiedView` itself. + @_spi(TokamakCore) + public var fiber: FiberReconciler.Fiber? { + let id = AnyHashable(id) + let result = TokamakCore.walk( + reconciler.current + ) { fiber -> WalkWorkResult.Fiber?> in + guard case let .view(view, _) = fiber.content, + !(view is AnyOptional), + (view as? IdentifiedViewProtocol)?.id == AnyHashable(id), + let child = fiber.child + else { return WalkWorkResult.continue } + return WalkWorkResult.break(with: child) + } + guard case let .success(fiber) = result else { return nil } + return fiber + } + + /// The `fiber`'s content casted to `V`. + public var view: V? { + guard case let .view(view, _) = fiber?.content else { return nil } + return view as? V + } + + /// Access properties from the `view` without specifying `.view` manually. + public subscript(dynamicMember member: KeyPath) -> T? { + self.view?[keyPath: member] + } +} + +/// An erased `IdentifiedView`. +protocol IdentifiedViewProtocol { + var id: AnyHashable { get } +} + +/// A wrapper that identifies a `View` in a test. +struct IdentifiedView: View, IdentifiedViewProtocol { + let id: AnyHashable + let content: Content + + var body: some View { + content + } +} + +public extension View { + /// Identifies a `View` in a test. + /// + /// You can access this view from the `FiberReconciler` with `findView(id:as:)`. + func identified(by id: ID) -> some View { + IdentifiedView(id: id, content: self) + } +} + +public extension FiberReconciler where Renderer == TestFiberRenderer { + /// Find the `View` identified by `ID`. + /// + /// - Note: This returns a proxy to the child of the `identified(by:)` modifier, + /// not the `IdentifiedView` itself. + func findView( + id: ID, + as type: V.Type = V.self + ) -> TestViewProxy { + TestViewProxy(id: id, reconciler: self) + } +} diff --git a/Tests/TokamakReconcilerTests/PreferenceTests.swift b/Tests/TokamakReconcilerTests/PreferenceTests.swift new file mode 100644 index 00000000..a12ffeab --- /dev/null +++ b/Tests/TokamakReconcilerTests/PreferenceTests.swift @@ -0,0 +1,65 @@ +// Copyright 2021 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Carson Katri on 6/29/22. +// + +import XCTest + +@_spi(TokamakCore) @testable import TokamakCore +import TokamakTestRenderer + +private enum TestKey: PreferenceKey { + static let defaultValue: Int = 0 + static func reduce(value: inout Int, nextValue: () -> Int) { + value += nextValue() + } +} + +final class PreferenceTests: XCTestCase { + func testPreferenceAction() { + struct TestView: View { + public var body: some View { + Text("") + .preference(key: TestKey.self, value: 2) + .preference(key: TestKey.self, value: 3) + .onPreferenceChange(TestKey.self) { + XCTAssertEqual($0, 5) + } + } + } + let reconciler = TestFiberRenderer(.root, size: .init(width: 500, height: 500)) + .render(TestView()) + reconciler.fiberChanged(reconciler.current) + } + + func testOverlay() { + struct TestView: View { + var body: some View { + Text("") + .preference(key: TestKey.self, value: 2) + .preference(key: TestKey.self, value: 3) + .overlayPreferenceValue(TestKey.self) { + Text("\($0)") + .identified(by: "overlay") + } + } + } + + let reconciler = TestFiberRenderer(.root, size: .init(width: 500, height: 500)) + .render(TestView()) + + XCTAssertEqual(reconciler.findView(id: "overlay").view, Text("5")) + } +} diff --git a/Tests/TokamakReconcilerTests/VisitorTests.swift b/Tests/TokamakReconcilerTests/VisitorTests.swift index ecafc85e..f0b3a974 100644 --- a/Tests/TokamakReconcilerTests/VisitorTests.swift +++ b/Tests/TokamakReconcilerTests/VisitorTests.swift @@ -20,40 +20,6 @@ import XCTest @_spi(TokamakCore) @testable import TokamakCore import TokamakTestRenderer -extension FiberReconciler { - /// Expect a `Fiber` to represent a particular `View` type. - func expect( - _ fiber: Fiber?, - represents viewType: V.Type, - _ message: String? = nil - ) where V: View { - guard case let .view(view, _) = fiber?.content else { - return XCTAssert(false, "Fiber does not exit") - } - if let message = message { - XCTAssert(type(of: view) == viewType, message) - } else { - XCTAssert(type(of: view) == viewType) - } - } - - /// Expect a `Fiber` to represent a `View` matching`testView`. - func expect( - _ fiber: Fiber?, - equals testView: V, - _ message: String? = nil - ) where V: View & Equatable { - guard case let .view(fiberView, _) = fiber?.content else { - return XCTAssert(false, "Fiber does not exit") - } - if let message = message { - XCTAssertEqual(fiberView as? V, testView, message) - } else { - XCTAssertEqual(fiberView as? V, testView) - } - } -} - final class VisitorTests: XCTestCase { func testCounter() { struct TestView: View { @@ -64,16 +30,19 @@ final class VisitorTests: XCTestCase { var body: some View { VStack { Text("\(count)") + .identified(by: "count") HStack { if count > 0 { Button("Decrement") { count -= 1 } + .identified(by: "decrement") } if count < 5 { Button("Increment") { count += 1 } + .identified(by: "increment") } } } @@ -84,75 +53,28 @@ final class VisitorTests: XCTestCase { Counter() } } - let reconciler = TestFiberRenderer(.root, size: .init(width: 500, height: 500)) - .render(TestView()) - var hStack: FiberReconciler.Fiber? { - reconciler.current // RootView - .child? // LayoutView - .child? // ModifiedContent - .child? // _ViewModifier_Content - .child? // TestView - .child? // Counter - .child? // VStack - .child? // TupleView - .child?.sibling? // HStack - .child // TupleView - } - var text: FiberReconciler.Fiber? { - reconciler.current // RootView - .child? // LayoutView - .child? // ModifiedContent - .child? // _ViewModifier_Content - .child? // TestView - .child? // Counter - .child? // VStack - .child? // TupleView - .child // Text - } - var decrementButton: FiberReconciler.Fiber? { - hStack? - .child? // Optional - .child // Button - } - var incrementButton: FiberReconciler.Fiber? { - hStack? - .child?.sibling? // Optional - .child // Button - } - func decrement() { - guard case let .view(view, _) = decrementButton?.content - else { return } - (view as? Button)?.action() - } - func increment() { - guard case let .view(view, _) = incrementButton?.content - else { return } - (view as? Button)?.action() - } + let reconciler = TestFiberRenderer(.root, size: .zero).render(TestView()) + + let incrementButton = reconciler.findView(id: "increment", as: Button.self) + let countText = reconciler.findView(id: "count", as: Text.self) + let decrementButton = reconciler.findView(id: "decrement", as: Button.self) // The decrement button is removed when count is < 0 - XCTAssertNil(decrementButton, "'Decrement' should be hidden when count <= 0") + XCTAssertNil(decrementButton.view, "'Decrement' should be hidden when count <= 0") + XCTAssertNotNil(incrementButton.view, "'Increment' should be visible when count < 5") // Count up to 5 for i in 0..<5 { - reconciler.expect(text, equals: Text("\(i)")) - increment() + XCTAssertEqual(countText.view, Text("\(i)")) + incrementButton.action?() } - XCTAssertNil(incrementButton, "'Increment' should be hidden when count >= 5") - reconciler.expect( - decrementButton, - represents: Button.self, - "'Decrement' should be visible when count > 0" - ) + XCTAssertNil(incrementButton.view, "'Increment' should be hidden when count >= 5") + XCTAssertNotNil(decrementButton.view, "'Decrement' should be visible when count > 0") // Count down to 0. for i in 0..<5 { - reconciler.expect(text, equals: Text("\(5 - i)")) - decrement() + XCTAssertEqual(countText.view, Text("\(5 - i)")) + decrementButton.action?() } - XCTAssertNil(decrementButton, "'Decrement' should be hidden when count <= 0") - reconciler.expect( - incrementButton, - represents: Button.self, - "'Increment' should be visible when count < 5" - ) + XCTAssertNil(decrementButton.view, "'Decrement' should be hidden when count <= 0") + XCTAssertNotNil(incrementButton.view, "'Increment' should be visible when count < 5") } func testForEach() { @@ -163,61 +85,143 @@ final class VisitorTests: XCTestCase { var body: some View { VStack { Button("Add Item") { count += 1 } + .identified(by: "addItem") ForEach(Array(0...Fiber? { - reconciler.current // RootView - .child? // LayoutView - .child? // ModifiedContent - .child? // _ViewModifier_Content - .child? // TestView - .child? // VStack - .child? // TupleView - .child // Button + let reconciler = TestFiberRenderer(.root, size: .zero).render(TestView()) + + let addItemButton = reconciler.findView(id: "addItem", as: Button.self) + XCTAssertNotNil(addItemButton) + for i in 0..<10 { + addItemButton.action?() + XCTAssertEqual(reconciler.findView(id: i).view, Text("Item \(i)")) } - var forEachFiber: FiberReconciler.Fiber? { - reconciler.current // RootView - .child? // LayoutView - .child? // ModifiedContent - .child? // _ViewModifier_Content - .child? // TestView - .child? // VStack - .child? // TupleView - .child?.sibling // ForEach + } + + func testDynamicProperties() { + enum DynamicPropertyTest: Hashable { + case state + case environment + case stateObject + case observedObject + case environmentObject } - func item(at index: Int) -> FiberReconciler.Fiber? { - var node = forEachFiber?.child - for _ in 0..)?.action() - } - reconciler.expect(addItemFiber, represents: Button.self) - reconciler.expect(forEachFiber, represents: ForEach<[Int], Int, Text>.self) - addItem() - reconciler.expect(item(at: 0), equals: Text("Item 0")) - XCTAssertEqual(reconciler.renderer.rootElement.children[0].children.count, 2) - addItem() - reconciler.expect(item(at: 1), equals: Text("Item 1")) - XCTAssertEqual(reconciler.renderer.rootElement.children[0].children.count, 3) - addItem() - reconciler.expect(item(at: 2), equals: Text("Item 2")) - XCTAssertEqual(reconciler.renderer.rootElement.children[0].children.count, 4) + + let reconciler = TestFiberRenderer(.root, size: .zero).render(TestView()) + + // State + let button = reconciler.findView(id: DynamicPropertyTest.state, as: Button.self) + XCTAssertEqual(button.label, Text("0")) + button.action?() + XCTAssertEqual(button.label, Text("1")) + + // Environment + XCTAssertEqual( + reconciler.findView(id: DynamicPropertyTest.environment).view, + Text("5") + ) + + // StateObject + let stateObjectButton = reconciler.findView( + id: DynamicPropertyTest.stateObject, + as: Button.self + ) + XCTAssertEqual(stateObjectButton.label, Text("0")) + stateObjectButton.action?() + stateObjectButton.action?() + XCTAssertEqual(stateObjectButton.label, Text("5")) + + XCTAssertEqual( + reconciler.findView(id: DynamicPropertyTest.observedObject).view, + Text("5") + ) + + XCTAssertEqual( + reconciler.findView(id: DynamicPropertyTest.environmentObject).view, + Text("5") + ) } }