From 0fa144f09fab3859129aed1cd26542b2eb09dbe2 Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:28:28 +0100 Subject: [PATCH 01/17] WIP: makeNodes in commit phase --- .../Animation/View+Animation.swift | 4 +- .../Data/Environment/View+Envionment.swift | 4 +- .../Data/Lifecycle/View+LifecycleEvents.swift | 12 +- .../Data/Lifecycle/View+OnChange.swift | 6 +- .../AnimateChildrenView.swift | 4 +- .../ElementModifiers/AttributeModifier.swift | 2 +- .../ElementModifiers/BindingModifiers.swift | 2 +- .../ElementModifiers/DOMElementModifier.swift | 2 +- .../ElementModifiers/EventModifier.swift | 2 +- .../ElementModifiers/FocusModifier.swift | 2 +- .../HTMLViews/HTMLElement+Mountable.swift | 8 +- .../HTMLViews/HTMLText+Mountable.swift | 4 +- .../HTMLViews/HTMLVoidElement+Mountable.swift | 6 +- .../StyleModifiers/FilterModifier.swift | 2 +- .../StyleModifiers/OpacityModifer.swift | 2 +- .../StyleModifiers/TransformModifier.swift | 2 +- .../ElementaryUI/HTMLViews/View+Binding.swift | 6 +- .../_AttributedElement+Mountable.swift | 6 +- .../ElementaryUI/HTMLViews/_ElementNode.swift | 33 +- .../ElementaryUI/HTMLViews/_TextNode.swift | 11 +- .../Reconciling/ApplicationRuntime.swift | 33 +- .../Reconciling/_ViewContext.swift | 332 ++++++++++++++++++ .../StructureViews/EmptyHTML+Mountable.swift | 2 +- .../StructureViews/ForEach+Mountable.swift | 4 +- .../StructureViews/Group+Mountable.swift | 4 +- .../StructureViews/KeyedView.swift | 26 +- .../StructureViews/Optional+Mountable.swift | 20 +- .../PlaceholderContentView.swift | 8 +- .../StructureViews/Tuples+Mountable.swift | 58 +-- .../StructureViews/_ConditionalNode.swift | 150 ++++++-- .../StructureViews/_ForEachNode.swift | 66 +++- .../StructureViews/_HTMLArray+Mountable.swift | 41 ++- .../_HTMLConditional+Mountable.swift | 24 +- .../StructureViews/_KeyedNode.swift | 207 ++++++----- .../Transition/_TransitionNode.swift | 151 +++----- .../Transition/_TransitionView.swift | 6 +- .../Views/Function/_FunctionNode.swift | 21 +- .../Views/Function/_FunctionView.swift | 4 +- .../ElementaryUI/Views/View+Mountable.swift | 4 +- .../Reconciler/DeinitSniffer.swift | 4 +- .../Reconciler/TestDOM.swift | 17 +- .../Reconciler/TransitionMountRootTests.swift | 197 +++++++++++ 42 files changed, 1091 insertions(+), 408 deletions(-) create mode 100644 Tests/ElementaryUITests/Reconciler/TransitionMountRootTests.swift diff --git a/Sources/ElementaryUI/Animation/View+Animation.swift b/Sources/ElementaryUI/Animation/View+Animation.swift index 7f9eab4..569a249 100644 --- a/Sources/ElementaryUI/Animation/View+Animation.swift +++ b/Sources/ElementaryUI/Animation/View+Animation.swift @@ -14,9 +14,9 @@ struct _TransactionModifierView: View { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { - let node = Wrapped._makeNode(view.view, context: context, tx: &tx) + let node = Wrapped._makeNode(view.view, context: context, ctx: &ctx) return .init(state: .init(value: view.value), child: node) } diff --git a/Sources/ElementaryUI/Data/Environment/View+Envionment.swift b/Sources/ElementaryUI/Data/Environment/View+Envionment.swift index 160d635..cb95e4c 100644 --- a/Sources/ElementaryUI/Data/Environment/View+Envionment.swift +++ b/Sources/ElementaryUI/Data/Environment/View+Envionment.swift @@ -132,13 +132,13 @@ public struct _EnvironmentView: View { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { var context = copy context let box = EnvironmentValues._Box(view.value) context.environment.boxes[view.key.propertyID] = box - return .init(state: box, child: Wrapped._makeNode(view.wrapped, context: context, tx: &tx)) + return .init(state: box, child: Wrapped._makeNode(view.wrapped, context: context, ctx: &ctx)) } public static func _patchNode( diff --git a/Sources/ElementaryUI/Data/Lifecycle/View+LifecycleEvents.swift b/Sources/ElementaryUI/Data/Lifecycle/View+LifecycleEvents.swift index c214771..4d66cc5 100644 --- a/Sources/ElementaryUI/Data/Lifecycle/View+LifecycleEvents.swift +++ b/Sources/ElementaryUI/Data/Lifecycle/View+LifecycleEvents.swift @@ -105,15 +105,15 @@ struct _LifecycleEventView: View { final class LifecycleState: Unmountable { var onUnmount: (() -> Void)? - init(hook: LifecycleHook, tx: borrowing _TransactionContext) { + init(hook: LifecycleHook, scheduler: Scheduler) { switch hook { case .onAppear(let onAppear): - tx.scheduler.addEffect { onAppear() } + scheduler.addEffect { onAppear() } case .onDisappear(let callback): self.onUnmount = callback case .onAppearReturningCancelFunction(let onAppearReturningCancelFunction): - tx.scheduler.addEffect { + scheduler.addEffect { let cancelFunc = onAppearReturningCancelFunction() self.onUnmount = cancelFunc } @@ -131,10 +131,10 @@ struct _LifecycleEventView: View { static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { - let state = LifecycleState(hook: view.listener, tx: tx) - let child = Wrapped._makeNode(view.wrapped, context: context, tx: &tx) + let state = LifecycleState(hook: view.listener, scheduler: ctx.scheduler) + let child = Wrapped._makeNode(view.wrapped, context: context, ctx: &ctx) let node = _StatefulNode(state: state, child: child) return node diff --git a/Sources/ElementaryUI/Data/Lifecycle/View+OnChange.swift b/Sources/ElementaryUI/Data/Lifecycle/View+OnChange.swift index 17ac497..9b8cfa4 100644 --- a/Sources/ElementaryUI/Data/Lifecycle/View+OnChange.swift +++ b/Sources/ElementaryUI/Data/Lifecycle/View+OnChange.swift @@ -52,15 +52,15 @@ struct _OnChangeView: View { static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { let state = State(value: view.value, action: view.action) - let child = Wrapped._makeNode(view.wrapped, context: context, tx: &tx) + let child = Wrapped._makeNode(view.wrapped, context: context, ctx: &ctx) if view.initial { let initialValue = view.value let action = view.action - tx.scheduler.addEffect { + ctx.scheduler.addEffect { action(initialValue, initialValue) } } diff --git a/Sources/ElementaryUI/HTMLViews/ElementModifiers/AnimateChildrenView.swift b/Sources/ElementaryUI/HTMLViews/ElementModifiers/AnimateChildrenView.swift index f3232f5..2d386e1 100644 --- a/Sources/ElementaryUI/HTMLViews/ElementModifiers/AnimateChildrenView.swift +++ b/Sources/ElementaryUI/HTMLViews/ElementModifiers/AnimateChildrenView.swift @@ -8,7 +8,7 @@ struct AnimateContainerLayoutView: View { static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { let observer = FLIPLayoutObserver( @@ -20,7 +20,7 @@ struct AnimateContainerLayoutView: View { return _MountedNode( state: observer, - child: Wrapped._makeNode(view.wrapped, context: context, tx: &tx) + child: Wrapped._makeNode(view.wrapped, context: context, ctx: &ctx) ) } diff --git a/Sources/ElementaryUI/HTMLViews/ElementModifiers/AttributeModifier.swift b/Sources/ElementaryUI/HTMLViews/ElementModifiers/AttributeModifier.swift index 1aeb204..236ca46 100644 --- a/Sources/ElementaryUI/HTMLViews/ElementModifiers/AttributeModifier.swift +++ b/Sources/ElementaryUI/HTMLViews/ElementModifiers/AttributeModifier.swift @@ -14,7 +14,7 @@ public final class _AttributeModifier: DOMElementModifier, Invalidateable { return combined } - init(value: consuming Value, upstream: borrowing DOMElementModifiers, _ context: inout _TransactionContext) { + init(value: consuming Value, upstream: borrowing DOMElementModifiers) { self.lastValue = value self.upstream = upstream[_AttributeModifier.key] self.upstream?.tracker.addDependency(self) diff --git a/Sources/ElementaryUI/HTMLViews/ElementModifiers/BindingModifiers.swift b/Sources/ElementaryUI/HTMLViews/ElementModifiers/BindingModifiers.swift index 5434b28..d8eb672 100644 --- a/Sources/ElementaryUI/HTMLViews/ElementModifiers/BindingModifiers.swift +++ b/Sources/ElementaryUI/HTMLViews/ElementModifiers/BindingModifiers.swift @@ -9,7 +9,7 @@ final class BindingModifier: DOMElementModifier, Unmountable wher var accessor: DOM.PropertyAccessor? var isDirty: Bool = false - init(value: consuming Value, upstream: borrowing DOMElementModifiers, _ context: inout _TransactionContext) { + init(value: consuming Value, upstream: borrowing DOMElementModifiers) { self.lastValue = value.wrappedValue self.binding = value } diff --git a/Sources/ElementaryUI/HTMLViews/ElementModifiers/DOMElementModifier.swift b/Sources/ElementaryUI/HTMLViews/ElementModifiers/DOMElementModifier.swift index d77b4a9..8251f05 100644 --- a/Sources/ElementaryUI/HTMLViews/ElementModifiers/DOMElementModifier.swift +++ b/Sources/ElementaryUI/HTMLViews/ElementModifiers/DOMElementModifier.swift @@ -3,7 +3,7 @@ protocol DOMElementModifier: AnyObject { static var key: DOMElementModifiers.Key { get } - init(value: consuming Value, upstream: borrowing DOMElementModifiers, _ context: inout _TransactionContext) + init(value: consuming Value, upstream: borrowing DOMElementModifiers) func updateValue(_ value: consuming Value, _ context: inout _TransactionContext) func mount(_ node: DOM.Node, _ context: inout _CommitContext) -> AnyUnmountable diff --git a/Sources/ElementaryUI/HTMLViews/ElementModifiers/EventModifier.swift b/Sources/ElementaryUI/HTMLViews/ElementModifiers/EventModifier.swift index 54cf599..f861b0a 100644 --- a/Sources/ElementaryUI/HTMLViews/ElementModifiers/EventModifier.swift +++ b/Sources/ElementaryUI/HTMLViews/ElementModifiers/EventModifier.swift @@ -11,7 +11,7 @@ final class EventModifier: DOMElementModifier { private var value: Value - init(value: consuming @escaping Value, upstream: borrowing DOMElementModifiers, _ context: inout _TransactionContext) { + init(value: consuming @escaping Value, upstream: borrowing DOMElementModifiers) { self.value = value self.upstream = upstream[EventModifier.key] } diff --git a/Sources/ElementaryUI/HTMLViews/ElementModifiers/FocusModifier.swift b/Sources/ElementaryUI/HTMLViews/ElementModifiers/FocusModifier.swift index 1fbed46..6a92383 100644 --- a/Sources/ElementaryUI/HTMLViews/ElementModifiers/FocusModifier.swift +++ b/Sources/ElementaryUI/HTMLViews/ElementModifiers/FocusModifier.swift @@ -2,7 +2,7 @@ final class FocusModifier: DOMElementModifier, Unmountable private var binding: Binding private var focusAccessor: DOM.FocusAccessor? - init(value: consuming Binding, upstream: borrowing DOMElementModifiers, _ context: inout _TransactionContext) { + init(value: consuming Binding, upstream: borrowing DOMElementModifiers) { self.binding = value } diff --git a/Sources/ElementaryUI/HTMLViews/HTMLElement+Mountable.swift b/Sources/ElementaryUI/HTMLViews/HTMLElement+Mountable.swift index 965e362..92fe572 100644 --- a/Sources/ElementaryUI/HTMLViews/HTMLElement+Mountable.swift +++ b/Sources/ElementaryUI/HTMLViews/HTMLElement+Mountable.swift @@ -4,9 +4,9 @@ extension HTMLElement: _Mountable, View where Content: _Mountable { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { - let attributeModifier = _AttributeModifier(value: view._attributes, upstream: context.modifiers, &tx) + let attributeModifier = _AttributeModifier(value: view._attributes, upstream: context.modifiers) var context = copy context context.modifiers[_AttributeModifier.key] = attributeModifier @@ -16,8 +16,8 @@ extension HTMLElement: _Mountable, View where Content: _Mountable { child: _ElementNode( tag: self.Tag.name, viewContext: context, - tx: &tx, - makeChild: { viewContext, r in AnyReconcilable(Content._makeNode(view.content, context: viewContext, tx: &r)) } + ctx: &ctx, + makeChild: { viewContext, c in AnyReconcilable(Content._makeNode(view.content, context: viewContext, ctx: &c)) } ) ) } diff --git a/Sources/ElementaryUI/HTMLViews/HTMLText+Mountable.swift b/Sources/ElementaryUI/HTMLViews/HTMLText+Mountable.swift index 754a7f3..711229c 100644 --- a/Sources/ElementaryUI/HTMLViews/HTMLText+Mountable.swift +++ b/Sources/ElementaryUI/HTMLViews/HTMLText+Mountable.swift @@ -4,9 +4,9 @@ extension HTMLText: _Mountable, View { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { - _MountedNode(view.text, viewContext: context, context: &tx) + _MountedNode(view.text, viewContext: context, context: &ctx) } public static func _patchNode( diff --git a/Sources/ElementaryUI/HTMLViews/HTMLVoidElement+Mountable.swift b/Sources/ElementaryUI/HTMLViews/HTMLVoidElement+Mountable.swift index 35c982f..b4046b9 100644 --- a/Sources/ElementaryUI/HTMLViews/HTMLVoidElement+Mountable.swift +++ b/Sources/ElementaryUI/HTMLViews/HTMLVoidElement+Mountable.swift @@ -4,9 +4,9 @@ extension HTMLVoidElement: _Mountable, View { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { - let attributeModifier = _AttributeModifier(value: view._attributes, upstream: context.modifiers, &tx) + let attributeModifier = _AttributeModifier(value: view._attributes, upstream: context.modifiers) var context = copy context context.modifiers[_AttributeModifier.key] = attributeModifier @@ -16,7 +16,7 @@ extension HTMLVoidElement: _Mountable, View { child: _ElementNode( tag: self.Tag.name, viewContext: context, - tx: &tx, + ctx: &ctx, makeChild: { _, _ in AnyReconcilable(_EmptyNode()) } ) ) diff --git a/Sources/ElementaryUI/HTMLViews/StyleModifiers/FilterModifier.swift b/Sources/ElementaryUI/HTMLViews/StyleModifiers/FilterModifier.swift index 91782f7..60ecaa9 100644 --- a/Sources/ElementaryUI/HTMLViews/StyleModifiers/FilterModifier.swift +++ b/Sources/ElementaryUI/HTMLViews/StyleModifiers/FilterModifier.swift @@ -6,7 +6,7 @@ final class FilterModifier: DOMElementModifier { var value: CSSFilter.AnyFunction.ValueSource - init(value: consuming Value, upstream: borrowing DOMElementModifiers, _ context: inout _TransactionContext) { + init(value: consuming Value, upstream: borrowing DOMElementModifiers) { self.value = value.makeSource() self.upstream = upstream[FilterModifier.key] self.layerNumber = (self.upstream?.layerNumber ?? 0) + 1 diff --git a/Sources/ElementaryUI/HTMLViews/StyleModifiers/OpacityModifer.swift b/Sources/ElementaryUI/HTMLViews/StyleModifiers/OpacityModifer.swift index 921ef8e..0c2b980 100644 --- a/Sources/ElementaryUI/HTMLViews/StyleModifiers/OpacityModifer.swift +++ b/Sources/ElementaryUI/HTMLViews/StyleModifiers/OpacityModifer.swift @@ -6,7 +6,7 @@ final class OpacityModifier: DOMElementModifier { var value: CSSValueSource - init(value: consuming Value, upstream: borrowing DOMElementModifiers, _ context: inout _TransactionContext) { + init(value: consuming Value, upstream: borrowing DOMElementModifiers) { self.value = CSSValueSource(value: value) self.upstream = upstream[OpacityModifier.key] self.layerNumber = (self.upstream?.layerNumber ?? 0) + 1 diff --git a/Sources/ElementaryUI/HTMLViews/StyleModifiers/TransformModifier.swift b/Sources/ElementaryUI/HTMLViews/StyleModifiers/TransformModifier.swift index 2368243..d486218 100644 --- a/Sources/ElementaryUI/HTMLViews/StyleModifiers/TransformModifier.swift +++ b/Sources/ElementaryUI/HTMLViews/StyleModifiers/TransformModifier.swift @@ -6,7 +6,7 @@ final class TransformModifier: DOMElementModifier { var value: CSSTransform.AnyFunction.ValueSource - init(value: consuming Value, upstream: borrowing DOMElementModifiers, _ context: inout _TransactionContext) { + init(value: consuming Value, upstream: borrowing DOMElementModifiers) { self.value = value.makeSource() self.upstream = upstream[TransformModifier.key] self.layerNumber = (self.upstream?.layerNumber ?? 0) + 1 diff --git a/Sources/ElementaryUI/HTMLViews/View+Binding.swift b/Sources/ElementaryUI/HTMLViews/View+Binding.swift index b940764..6ef19a8 100644 --- a/Sources/ElementaryUI/HTMLViews/View+Binding.swift +++ b/Sources/ElementaryUI/HTMLViews/View+Binding.swift @@ -91,9 +91,9 @@ struct DOMEffectView: View { static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { - let effect = Effect(value: view.value, upstream: context.modifiers, &tx) + let effect = Effect(value: view.value, upstream: context.modifiers) #if hasFeature(Embedded) && compiler(<6.3) if __omg_this_was_annoying_I_am_false { @@ -111,7 +111,7 @@ struct DOMEffectView: View { var context = copy context context.modifiers[Effect.key] = effect - return .init(state: effect, child: Wrapped._makeNode(view.wrapped, context: context, tx: &tx)) + return .init(state: effect, child: Wrapped._makeNode(view.wrapped, context: context, ctx: &ctx)) } static func _patchNode( diff --git a/Sources/ElementaryUI/HTMLViews/_AttributedElement+Mountable.swift b/Sources/ElementaryUI/HTMLViews/_AttributedElement+Mountable.swift index 091dbc2..4cb8bc5 100644 --- a/Sources/ElementaryUI/HTMLViews/_AttributedElement+Mountable.swift +++ b/Sources/ElementaryUI/HTMLViews/_AttributedElement+Mountable.swift @@ -4,16 +4,16 @@ extension _AttributedElement: _Mountable, View where Content: _Mountable { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { - let attributeModifier = _AttributeModifier(value: view.attributes, upstream: context.modifiers, &tx) + let attributeModifier = _AttributeModifier(value: view.attributes, upstream: context.modifiers) var context = copy context context.modifiers[_AttributeModifier.key] = attributeModifier return _MountedNode( state: attributeModifier, - child: Content._makeNode(view.content, context: context, tx: &tx) + child: Content._makeNode(view.content, context: context, ctx: &ctx) ) } diff --git a/Sources/ElementaryUI/HTMLViews/_ElementNode.swift b/Sources/ElementaryUI/HTMLViews/_ElementNode.swift index b941245..3f97834 100644 --- a/Sources/ElementaryUI/HTMLViews/_ElementNode.swift +++ b/Sources/ElementaryUI/HTMLViews/_ElementNode.swift @@ -18,39 +18,37 @@ public final class _ElementNode: _Reconcilable { init( tag: String, viewContext: borrowing _ViewContext, - tx: inout _TransactionContext, - makeChild: (borrowing _ViewContext, inout _TransactionContext) -> AnyReconcilable + ctx: inout _CommitContext, + makeChild: (borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable ) { precondition(viewContext.parentElement != nil, "parent element must be set") self.parentNode = viewContext.parentElement self.identifier = "\(tag):\(ObjectIdentifier(self))" logTrace("created element \(identifier) in \(viewContext.parentElement!.identifier)") - viewContext.parentElement!.reportChangedChildren(.elementAdded, tx: &tx) + viewContext.parentElement!.reportChangedChildren(.elementAdded, ctx: &ctx) var viewContext = copy viewContext viewContext.parentElement = self let modifiers = viewContext.takeModifiers() self.layoutObservers = viewContext.takeLayoutObservers() - tx.scheduler.addCommitAction { [self] context in - precondition(self.domNode == nil, "element already has a DOM node") - let ref = context.dom.createElement(tag) - self.domNode = ManagedDOMReference(reference: ref, status: .added) + precondition(self.domNode == nil, "element already has a DOM node") + let ref = ctx.dom.createElement(tag) + self.domNode = ManagedDOMReference(reference: ref, status: .added) - self.mountedModifieres = modifiers.reversed().map { - $0.mount(ref, &context) - } + self.mountedModifieres = modifiers.reversed().map { + $0.mount(ref, &ctx) } - self.child = makeChild(viewContext, &tx) + self.child = makeChild(viewContext, &ctx) } init( root: DOM.Node, viewContext: consuming _ViewContext, - tx: inout _TransactionContext, - makeChild: (borrowing _ViewContext, inout _TransactionContext) -> AnyReconcilable + ctx: inout _CommitContext, + makeChild: (borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable ) { self.domNode = .init(reference: root, status: .unchanged) self.identifier = "\("_root_"):\(ObjectIdentifier(self))" @@ -63,7 +61,7 @@ public final class _ElementNode: _Reconcilable { self.layoutObservers = layoutObservers } - self.child = makeChild(viewContext, &tx) + self.child = makeChild(viewContext, &ctx) } func updateChild( @@ -105,6 +103,13 @@ public final class _ElementNode: _Reconcilable { } } + func reportChangedChildren(_ change: ElementNodeChildrenChange, ctx: inout _CommitContext) { + if change.requiresChildrenUpdate, !childrenLayoutStatus.isDirty { + childrenLayoutStatus.isDirty = true + ctx.scheduler.addPlacementAction(performLayout(_:)) + } + } + public func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { assert(domNode != nil, "unitialized element in layout pass") self.domNode?.collectLayoutChanges(&ops, type: .element) diff --git a/Sources/ElementaryUI/HTMLViews/_TextNode.swift b/Sources/ElementaryUI/HTMLViews/_TextNode.swift index f6dce88..e99ed39 100644 --- a/Sources/ElementaryUI/HTMLViews/_TextNode.swift +++ b/Sources/ElementaryUI/HTMLViews/_TextNode.swift @@ -4,18 +4,13 @@ public final class _TextNode: _Reconcilable { var isDirty: Bool = false var parentElement: _ElementNode? - init(_ newValue: String, viewContext: borrowing _ViewContext, context: inout _TransactionContext) { + init(_ newValue: String, viewContext: borrowing _ViewContext, context: inout _CommitContext) { self.value = newValue self.domNode = nil self.parentElement = viewContext.parentElement - self.parentElement?.reportChangedChildren(.elementAdded, tx: &context) - - isDirty = true - context.scheduler.addCommitAction { [self] context in - self.domNode = ManagedDOMReference(reference: context.dom.createText(newValue), status: .added) - self.isDirty = false - } + self.parentElement?.reportChangedChildren(.elementAdded, ctx: &context) + self.domNode = ManagedDOMReference(reference: context.dom.createText(newValue), status: .added) } func patch(_ newValue: String, context: inout _TransactionContext) { diff --git a/Sources/ElementaryUI/Reconciling/ApplicationRuntime.swift b/Sources/ElementaryUI/Reconciling/ApplicationRuntime.swift index 6626956..bc153a6 100644 --- a/Sources/ElementaryUI/Reconciling/ApplicationRuntime.swift +++ b/Sources/ElementaryUI/Reconciling/ApplicationRuntime.swift @@ -18,21 +18,26 @@ final class ApplicationRuntime { tx.withModifiedTransaction { $0.disablesAnimation = true } run: { tx in - self.rootNode = - _ElementNode( - root: domRoot, - viewContext: _ViewContext(), - tx: &tx, - makeChild: { [rootView] viewContext, tx in - AnyReconcilable( - RootView._makeNode( - rootView, - context: viewContext, - tx: &tx + var rootViewContext = _ViewContext() + rootViewContext.mountRoot = MountRoot.from(tx.transaction) + + tx.scheduler.addCommitAction { [self, rootView, rootViewContext] ctx in + self.rootNode = + _ElementNode( + root: domRoot, + viewContext: rootViewContext, + ctx: &ctx, + makeChild: { [rootView] viewContext, ctx in + AnyReconcilable( + RootView._makeNode( + rootView, + context: viewContext, + ctx: &ctx + ) ) - ) - } - ) + } + ) + } } } } diff --git a/Sources/ElementaryUI/Reconciling/_ViewContext.swift b/Sources/ElementaryUI/Reconciling/_ViewContext.swift index 4abfb36..239b571 100644 --- a/Sources/ElementaryUI/Reconciling/_ViewContext.swift +++ b/Sources/ElementaryUI/Reconciling/_ViewContext.swift @@ -1,3 +1,334 @@ +public final class MountRoot { + enum PendingOpsPolicy { + case assertInDebug + } + + enum MountState { + case pending + case mounted + case unmounted + } + + struct TransitionParticipant { + let patchPhase: (TransitionPhase, inout _TransactionContext) -> Void + let defaultAnimation: Animation? + let isStillMounted: () -> Bool + } + + nonisolated(unsafe) private static var nextID: UInt64 = 0 + nonisolated(unsafe) private static var nextTransitionClaimID: UInt64 = 0 + + let id: UInt64 + + private(set) var mountState: MountState + private var node: AnyReconcilable? + private var seedContext: _ViewContext? + private var createClosure: ((borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable)? + + private let pendingOpsPolicy: PendingOpsPolicy + + private var transitionSignal: TransitionPhase? + var transactionAnimation: Animation? + var transactionDisablesAnimation: Bool + private var transitionParticipantClaimID: UInt64? + private var transitionParticipant: TransitionParticipant? + private var pendingEnterAnimation: Animation? + private var removalToken: Double? + + init( + transitionPhase: TransitionPhase? = nil, + transactionAnimation: Animation? = nil, + transactionDisablesAnimation: Bool = false, + pendingOpsPolicy: PendingOpsPolicy = .assertInDebug + ) { + MountRoot.nextID &+= 1 + self.id = MountRoot.nextID + self.mountState = .mounted + self.pendingOpsPolicy = pendingOpsPolicy + self.transitionSignal = transitionPhase + self.transactionAnimation = transactionAnimation + self.transactionDisablesAnimation = transactionDisablesAnimation + } + + private init( + seedContext: borrowing _ViewContext, + transaction: Transaction, + transitionSignal: TransitionPhase?, + pendingOpsPolicy: PendingOpsPolicy, + create: @escaping (borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable + ) { + MountRoot.nextID &+= 1 + self.id = MountRoot.nextID + self.mountState = .pending + self.node = nil + self.seedContext = copy seedContext + self.createClosure = create + self.pendingOpsPolicy = pendingOpsPolicy + self.transitionSignal = transitionSignal + self.transactionAnimation = transaction.animation + self.transactionDisablesAnimation = transaction.disablesAnimation + } + + static func from(_ transaction: Transaction, transitionPhase: TransitionPhase? = nil) -> MountRoot { + MountRoot( + transitionPhase: transitionPhase, + transactionAnimation: transaction.animation, + transactionDisablesAnimation: transaction.disablesAnimation + ) + } + + static func pending( + seedContext: borrowing _ViewContext, + transaction: Transaction, + transitionPhase: TransitionPhase? = nil, + create: @escaping (borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable + ) -> MountRoot { + MountRoot( + seedContext: seedContext, + transaction: transaction, + transitionSignal: transitionPhase, + pendingOpsPolicy: .assertInDebug, + create: create + ) + } + + static func materialized( + seedContext: borrowing _ViewContext, + transaction: Transaction? = nil, + transitionPhase: TransitionPhase? = nil, + ctx: inout _CommitContext, + create: @escaping (borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable + ) -> MountRoot { + let effectiveTransaction: Transaction + if let transaction { + effectiveTransaction = transaction + } else { + var inherited = Transaction(animation: seedContext.mountRoot.transactionAnimation) + inherited.disablesAnimation = seedContext.mountRoot.transactionDisablesAnimation + effectiveTransaction = inherited + } + + let root = MountRoot.pending( + seedContext: seedContext, + transaction: effectiveTransaction, + transitionPhase: transitionPhase, + create: create + ) + root.materialize(&ctx) + return root + } + + static func mounted(_ node: consuming AnyReconcilable) -> MountRoot { + let root = MountRoot() + root.node = node + return root + } + + var isPending: Bool { + mountState == .pending + } + + func updatePendingCreate( + seedContext: borrowing _ViewContext, + transaction: Transaction, + create: @escaping (borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable + ) { + guard mountState == .pending else { return } + self.seedContext = copy seedContext + self.createClosure = create + self.transactionAnimation = transaction.animation + self.transactionDisablesAnimation = transaction.disablesAnimation + } + + func materialize(_ ctx: inout _CommitContext) { + guard mountState == .pending else { return } + guard let createClosure, let seedContext else { + preconditionFailure("pending MountRoot missing create context") + } + + var childContext = seedContext + childContext.mountRoot = self + + node = createClosure(childContext, &ctx) + self.seedContext = nil + self.createClosure = nil + self.mountState = .mounted + } + + func consumeTransitionPhase(defaultAnimation: Animation?) -> TransitionPhase { + let signal = transitionSignal + transitionSignal = nil + + guard signal == .willAppear else { return signal ?? .identity } + guard let animation = effectiveAnimation(defaultAnimation: defaultAnimation) else { + return .identity + } + + pendingEnterAnimation = animation + return .willAppear + } + + func reserveTransitionParticipant() -> UInt64? { + guard transitionParticipant == nil, transitionParticipantClaimID == nil else { return nil } + MountRoot.nextTransitionClaimID &+= 1 + let claimID = MountRoot.nextTransitionClaimID + transitionParticipantClaimID = claimID + return claimID + } + + func registerTransitionParticipant( + claimID: UInt64, + defaultAnimation: Animation?, + patchPhase: @escaping (TransitionPhase, inout _TransactionContext) -> Void, + isStillMounted: @escaping () -> Bool, + ctx: inout _CommitContext + ) { + guard transitionParticipant == nil, transitionParticipantClaimID == claimID else { return } + transitionParticipantClaimID = nil + transitionParticipant = .init( + patchPhase: patchPhase, + defaultAnimation: defaultAnimation, + isStillMounted: isStillMounted + ) + + guard let enterAnimation = pendingEnterAnimation else { return } + pendingEnterAnimation = nil + + ctx.scheduler.scheduleUpdate { tx in + guard let participant = self.transitionParticipant, participant.isStillMounted() else { return } + tx.withModifiedTransaction({ + $0.animation = enterAnimation + }, run: { tx in + participant.patchPhase(.identity, &tx) + }) + } + } + + func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { + switch mountState { + case .pending: + applyPending(op) + return + case .unmounted: + return + case .mounted: + break + } + + switch op { + case .startRemoval: + startRemoval(&tx) + case .cancelRemoval: + cancelRemoval(&tx) + case .markAsMoved, .markAsLeaving: + node?.apply(op, &tx) + } + } + + func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { + switch mountState { + case .mounted: + guard let node else { + preconditionFailure("mounted MountRoot missing node") + } + node.collectChildren(&ops, &context) + case .pending: + preconditionFailure("pending MountRoot reached collectChildren") + case .unmounted: + return + } + } + + func unmount(_ context: inout _CommitContext) { + switch mountState { + case .mounted: + node?.unmount(&context) + case .pending, .unmounted: + break + } + + node = nil + seedContext = nil + createClosure = nil + transitionParticipantClaimID = nil + transitionParticipant = nil + pendingEnterAnimation = nil + removalToken = nil + mountState = .unmounted + } + + @discardableResult + func withMountedNode( + as type: Node.Type = Node.self, + _ body: (inout Node) -> Void + ) -> Bool { + guard mountState == .mounted, let node else { return false } + node.modify(as: Node.self, body) + return true + } + + private func applyPending(_ op: _ReconcileOp) { + switch op { + case .startRemoval: + node = nil + seedContext = nil + createClosure = nil + transitionParticipantClaimID = nil + mountState = .unmounted + case .cancelRemoval, .markAsMoved, .markAsLeaving: + switch pendingOpsPolicy { + case .assertInDebug: + assertionFailure("apply(\(op)) on pending MountRoot") + } + } + } + + private func startRemoval(_ tx: inout _TransactionContext) { + guard let participant = transitionParticipant, participant.isStillMounted() else { + node?.apply(.startRemoval, &tx) + return + } + + guard let transitionAnimation = effectiveAnimation( + defaultAnimation: participant.defaultAnimation, + transaction: tx.transaction + ) else { + node?.apply(.startRemoval, &tx) + return + } + + tx.withModifiedTransaction({ + $0.animation = transitionAnimation + }, run: { tx in + node?.apply(.markAsLeaving, &tx) + participant.patchPhase(.didDisappear, &tx) + }) + + removalToken = tx.currentFrameTime + tx.transaction.addAnimationCompletion(criteria: .removed) { [scheduler = tx.scheduler, frameTime = removalToken] in + guard self.removalToken == frameTime else { return } + scheduler.scheduleUpdate { tx in + self.node?.apply(.startRemoval, &tx) + } + } + } + + private func cancelRemoval(_ tx: inout _TransactionContext) { + removalToken = nil + node?.apply(.cancelRemoval, &tx) + + guard let participant = transitionParticipant, participant.isStillMounted() else { return } + participant.patchPhase(.identity, &tx) + } + + private func effectiveAnimation(defaultAnimation: Animation?, transaction: Transaction? = nil) -> Animation? { + let disablesAnimation = transaction?.disablesAnimation ?? transactionDisablesAnimation + guard !disablesAnimation else { return nil } + + return transaction?.animation ?? transactionAnimation ?? defaultAnimation + } +} + // TODO: think about a better name for this... maybe _EnvironmentContext? public struct _ViewContext { var environment: EnvironmentValues = .init() @@ -7,6 +338,7 @@ public struct _ViewContext { var layoutObservers: DOMLayoutObservers = .init() var functionDepth: Int = 0 var parentElement: _ElementNode? + var mountRoot: MountRoot = .init() mutating func takeModifiers() -> [any DOMElementModifier] { modifiers.take() diff --git a/Sources/ElementaryUI/StructureViews/EmptyHTML+Mountable.swift b/Sources/ElementaryUI/StructureViews/EmptyHTML+Mountable.swift index d7dbf02..9d8342a 100644 --- a/Sources/ElementaryUI/StructureViews/EmptyHTML+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/EmptyHTML+Mountable.swift @@ -4,7 +4,7 @@ extension EmptyHTML: _Mountable, View { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { _EmptyNode() } diff --git a/Sources/ElementaryUI/StructureViews/ForEach+Mountable.swift b/Sources/ElementaryUI/StructureViews/ForEach+Mountable.swift index 75addeb..9692637 100644 --- a/Sources/ElementaryUI/StructureViews/ForEach+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/ForEach+Mountable.swift @@ -27,13 +27,13 @@ extension ForEach: _Mountable, View where Content: _KeyReadableView, Data: Colle public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { _MountedNode( data: view._data, contentBuilder: view._contentBuilder, context: context, - tx: &tx + ctx: &ctx ) } diff --git a/Sources/ElementaryUI/StructureViews/Group+Mountable.swift b/Sources/ElementaryUI/StructureViews/Group+Mountable.swift index 1aff54b..9eb1363 100644 --- a/Sources/ElementaryUI/StructureViews/Group+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/Group+Mountable.swift @@ -5,9 +5,9 @@ extension Group: _Mountable where Content: _Mountable { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { - Content._makeNode(view.content, context: context, tx: &tx) + Content._makeNode(view.content, context: context, ctx: &ctx) } public static func _patchNode( diff --git a/Sources/ElementaryUI/StructureViews/KeyedView.swift b/Sources/ElementaryUI/StructureViews/KeyedView.swift index fb49897..64588ec 100644 --- a/Sources/ElementaryUI/StructureViews/KeyedView.swift +++ b/Sources/ElementaryUI/StructureViews/KeyedView.swift @@ -8,11 +8,18 @@ public struct _KeyedView: View { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { - .init( - key: view.key, - child: Value._makeNode(view.value, context: context, tx: &tx), + let childRoot = MountRoot.materialized( + seedContext: context, + ctx: &ctx, + create: { context, ctx in + AnyReconcilable(Value._makeNode(view.value, context: context, ctx: &ctx)) + } + ) + return .init( + keys: [view.key], + children: [childRoot], context: context ) } @@ -26,12 +33,11 @@ public struct _KeyedView: View { key: view.key, context: &tx, as: Value._MountedNode.self, - makeOrPatchNode: { node, context, tx in - if node == nil { - node = Value._makeNode(view.value, context: context, tx: &tx) - } else { - Value._patchNode(view.value, node: &node!, tx: &tx) - } + makeNode: { context, ctx in + Value._makeNode(view.value, context: context, ctx: &ctx) + }, + patchNode: { node, tx in + Value._patchNode(view.value, node: &node, tx: &tx) } ) } diff --git a/Sources/ElementaryUI/StructureViews/Optional+Mountable.swift b/Sources/ElementaryUI/StructureViews/Optional+Mountable.swift index c7aa14f..efed0ba 100644 --- a/Sources/ElementaryUI/StructureViews/Optional+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/Optional+Mountable.swift @@ -5,13 +5,25 @@ extension Optional: _Mountable where Wrapped: _Mountable { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { switch view { case let .some(view): - return .init(a: Wrapped._makeNode(view, context: context, tx: &tx), context: context) + let aRoot = MountRoot.materialized( + seedContext: context, + ctx: &ctx, + create: { context, ctx in + AnyReconcilable(Wrapped._makeNode(view, context: context, ctx: &ctx)) + } + ) + return .init(aRoot: aRoot, context: context) case .none: - return .init(b: _EmptyNode(), context: context) + let bRoot = MountRoot.materialized( + seedContext: context, + ctx: &ctx, + create: { _, _ in AnyReconcilable(_EmptyNode()) } + ) + return .init(bRoot: bRoot, context: context) } } @@ -24,7 +36,7 @@ extension Optional: _Mountable where Wrapped: _Mountable { case let .some(view): node.patchWithA( tx: &tx, - makeNode: { c, tx in Wrapped._makeNode(view, context: c, tx: &tx) }, + makeNode: { c, ctx in Wrapped._makeNode(view, context: c, ctx: &ctx) }, updateNode: { node, tx in Wrapped._patchNode(view, node: &node, tx: &tx) } ) case .none: diff --git a/Sources/ElementaryUI/StructureViews/PlaceholderContentView.swift b/Sources/ElementaryUI/StructureViews/PlaceholderContentView.swift index 7f6cd60..bce71ec 100644 --- a/Sources/ElementaryUI/StructureViews/PlaceholderContentView.swift +++ b/Sources/ElementaryUI/StructureViews/PlaceholderContentView.swift @@ -14,9 +14,9 @@ /// } /// ``` public struct PlaceholderContentView: View { - private var makeNodeFn: (borrowing _ViewContext, inout _TransactionContext) -> _PlaceholderNode + private var makeNodeFn: (borrowing _ViewContext, inout _CommitContext) -> _PlaceholderNode - init(makeNodeFn: @escaping (borrowing _ViewContext, inout _TransactionContext) -> _PlaceholderNode) { + init(makeNodeFn: @escaping (borrowing _ViewContext, inout _CommitContext) -> _PlaceholderNode) { self.makeNodeFn = makeNodeFn } } @@ -27,9 +27,9 @@ extension PlaceholderContentView: _Mountable { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { - view.makeNodeFn(context, &tx) + view.makeNodeFn(context, &ctx) } public static func _patchNode( diff --git a/Sources/ElementaryUI/StructureViews/Tuples+Mountable.swift b/Sources/ElementaryUI/StructureViews/Tuples+Mountable.swift index b4a7912..ad22e96 100644 --- a/Sources/ElementaryUI/StructureViews/Tuples+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/Tuples+Mountable.swift @@ -5,11 +5,11 @@ extension _HTMLTuple2: _Mountable where V0: _Mountable, V1: _Mountable { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { _MountedNode( - V0._makeNode(view.v0, context: context, tx: &tx), - V1._makeNode(view.v1, context: context, tx: &tx) + V0._makeNode(view.v0, context: context, ctx: &ctx), + V1._makeNode(view.v1, context: context, ctx: &ctx) ) } @@ -30,12 +30,12 @@ extension _HTMLTuple3: _Mountable where V0: _Mountable, V1: _Mountable, V2: _Mou public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { _MountedNode( - V0._makeNode(view.v0, context: context, tx: &tx), - V1._makeNode(view.v1, context: context, tx: &tx), - V2._makeNode(view.v2, context: context, tx: &tx) + V0._makeNode(view.v0, context: context, ctx: &ctx), + V1._makeNode(view.v1, context: context, ctx: &ctx), + V2._makeNode(view.v2, context: context, ctx: &ctx) ) } @@ -57,13 +57,13 @@ extension _HTMLTuple4: _Mountable where V0: _Mountable, V1: _Mountable, V2: _Mou public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { _MountedNode( - V0._makeNode(view.v0, context: context, tx: &tx), - V1._makeNode(view.v1, context: context, tx: &tx), - V2._makeNode(view.v2, context: context, tx: &tx), - V3._makeNode(view.v3, context: context, tx: &tx) + V0._makeNode(view.v0, context: context, ctx: &ctx), + V1._makeNode(view.v1, context: context, ctx: &ctx), + V2._makeNode(view.v2, context: context, ctx: &ctx), + V3._makeNode(view.v3, context: context, ctx: &ctx) ) } @@ -86,14 +86,14 @@ extension _HTMLTuple5: _Mountable where V0: _Mountable, V1: _Mountable, V2: _Mou public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { _MountedNode( - V0._makeNode(view.v0, context: context, tx: &tx), - V1._makeNode(view.v1, context: context, tx: &tx), - V2._makeNode(view.v2, context: context, tx: &tx), - V3._makeNode(view.v3, context: context, tx: &tx), - V4._makeNode(view.v4, context: context, tx: &tx) + V0._makeNode(view.v0, context: context, ctx: &ctx), + V1._makeNode(view.v1, context: context, ctx: &ctx), + V2._makeNode(view.v2, context: context, ctx: &ctx), + V3._makeNode(view.v3, context: context, ctx: &ctx), + V4._makeNode(view.v4, context: context, ctx: &ctx) ) } @@ -119,15 +119,15 @@ extension _HTMLTuple6: _Mountable where V0: _Mountable, V1: _Mountable, V2: _Mou public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { _MountedNode( - V0._makeNode(view.v0, context: context, tx: &tx), - V1._makeNode(view.v1, context: context, tx: &tx), - V2._makeNode(view.v2, context: context, tx: &tx), - V3._makeNode(view.v3, context: context, tx: &tx), - V4._makeNode(view.v4, context: context, tx: &tx), - V5._makeNode(view.v5, context: context, tx: &tx) + V0._makeNode(view.v0, context: context, ctx: &ctx), + V1._makeNode(view.v1, context: context, ctx: &ctx), + V2._makeNode(view.v2, context: context, ctx: &ctx), + V3._makeNode(view.v3, context: context, ctx: &ctx), + V4._makeNode(view.v4, context: context, ctx: &ctx), + V5._makeNode(view.v5, context: context, ctx: &ctx) ) } @@ -154,13 +154,13 @@ extension _HTMLTuple: _Mountable where repeat each Child: _Mountable { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { _MountedNode( repeat makeNode( each view.value, context: context, - tx: &tx + ctx: &ctx ) ) } @@ -195,9 +195,9 @@ private func __noop_goshDarnValuePacksAreAnnoyingAF(_ v: inout some _Mountable) private func makeNode( _ view: consuming V, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> V._MountedNode { - V._makeNode(view, context: context, tx: &tx) + V._makeNode(view, context: context, ctx: &ctx) } private func patchNode( diff --git a/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift b/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift index 84c64a7..d6fabcc 100644 --- a/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift +++ b/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift @@ -1,27 +1,35 @@ public struct _ConditionalNode { enum State { - case a(AnyReconcilable) - case b(AnyReconcilable) - case aWithBLeaving(AnyReconcilable, AnyReconcilable) - case bWithALeaving(AnyReconcilable, AnyReconcilable) + case a(MountRoot) + case b(MountRoot) + case aWithBLeaving(MountRoot, MountRoot) + case bWithALeaving(MountRoot, MountRoot) } private var state: State private var context: _ViewContext - init(a: consuming AnyReconcilable? = nil, b: consuming AnyReconcilable? = nil, context: borrowing _ViewContext) { - switch (a, b) { + init(aRoot: MountRoot? = nil, bRoot: MountRoot? = nil, context: borrowing _ViewContext) { + switch (aRoot, bRoot) { case (let .some(a), nil): self.state = .a(a) case (nil, let .some(b)): self.state = .b(b) default: - preconditionFailure("either a or b must be provided") + preconditionFailure("either aRoot or bRoot must be provided") } self.context = copy context } + init(a: consuming AnyReconcilable? = nil, b: consuming AnyReconcilable? = nil, context: borrowing _ViewContext) { + self.init( + aRoot: a.map { MountRoot.mounted($0) }, + bRoot: b.map { MountRoot.mounted($0) }, + context: context + ) + } + init(a: consuming some _Reconcilable, context: borrowing _ViewContext) { self.init(a: AnyReconcilable(a), context: context) } @@ -32,71 +40,136 @@ public struct _ConditionalNode { mutating func patchWithA( tx: inout _TransactionContext, - makeNode: (borrowing _ViewContext, inout _TransactionContext) -> NodeA, + makeNode: @escaping (borrowing _ViewContext, inout _CommitContext) -> NodeA, updateNode: (inout NodeA, inout _TransactionContext) -> Void ) { switch state { case .a(let a): - let a = a - a.modify(as: NodeA.self) { node in - updateNode(&node, &tx) - } + patchActiveRoot( + a, + tx: &tx, + makeNode: makeNode, + updateNode: updateNode + ) state = .a(a) case .b(let b): - let a = AnyReconcilable(makeNode(context, &tx)) + context.parentElement?.reportChangedChildren(.elementAdded, tx: &tx) + let a = makePendingRoot(transaction: tx.transaction, makeNode: makeNode) + scheduleMaterialization([a], tx: &tx) + b.apply(.startRemoval, &tx) - self.context.parentElement?.reportChangedChildren(.elementMoved, tx: &tx) + context.parentElement?.reportChangedChildren(.elementMoved, tx: &tx) state = .aWithBLeaving(a, b) case .aWithBLeaving(let a, let b): - let a = a - a.modify(as: NodeA.self) { node in - updateNode(&node, &tx) - } + patchActiveRoot( + a, + tx: &tx, + makeNode: makeNode, + updateNode: updateNode + ) state = .aWithBLeaving(a, b) case .bWithALeaving(let b, let a): - let a = a - a.modify(as: NodeA.self) { node in - updateNode(&node, &tx) - } + patchActiveRoot( + a, + tx: &tx, + makeNode: makeNode, + updateNode: updateNode + ) a.apply(.cancelRemoval, &tx) b.apply(.startRemoval, &tx) - self.context.parentElement?.reportChangedChildren(.elementMoved, tx: &tx) + context.parentElement?.reportChangedChildren(.elementMoved, tx: &tx) state = .aWithBLeaving(a, b) } } mutating func patchWithB( tx: inout _TransactionContext, - makeNode: (borrowing _ViewContext, inout _TransactionContext) -> NodeB, + makeNode: @escaping (borrowing _ViewContext, inout _CommitContext) -> NodeB, updateNode: (inout NodeB, inout _TransactionContext) -> Void ) { switch state { case .b(let b): - let b = b - b.modify(as: NodeB.self) { node in - updateNode(&node, &tx) - } + patchActiveRoot( + b, + tx: &tx, + makeNode: makeNode, + updateNode: updateNode + ) state = .b(b) case .a(let a): - let b = AnyReconcilable(makeNode(context, &tx)) + context.parentElement?.reportChangedChildren(.elementAdded, tx: &tx) + let b = makePendingRoot(transaction: tx.transaction, makeNode: makeNode) + scheduleMaterialization([b], tx: &tx) + a.apply(.startRemoval, &tx) - self.context.parentElement?.reportChangedChildren(.elementMoved, tx: &tx) + context.parentElement?.reportChangedChildren(.elementMoved, tx: &tx) state = .bWithALeaving(b, a) case .aWithBLeaving(let a, let b): - let b = b - b.modify(as: NodeB.self) { node in - updateNode(&node, &tx) - } + patchActiveRoot( + b, + tx: &tx, + makeNode: makeNode, + updateNode: updateNode + ) state = .bWithALeaving(b, a) case .bWithALeaving(let b, let a): - let b = b - b.modify(as: NodeB.self) { node in - updateNode(&node, &tx) - } + patchActiveRoot( + b, + tx: &tx, + makeNode: makeNode, + updateNode: updateNode + ) state = .bWithALeaving(b, a) } } + private mutating func makePendingRoot( + transaction: Transaction, + makeNode: @escaping (borrowing _ViewContext, inout _CommitContext) -> Node + ) -> MountRoot { + MountRoot.pending( + seedContext: context, + transaction: transaction, + transitionPhase: .willAppear, + create: { viewContext, ctx in + AnyReconcilable(makeNode(viewContext, &ctx)) + } + ) + } + + private mutating func patchActiveRoot( + _ root: MountRoot, + tx: inout _TransactionContext, + makeNode: @escaping (borrowing _ViewContext, inout _CommitContext) -> Node, + updateNode: (inout Node, inout _TransactionContext) -> Void + ) { + if root.isPending { + root.updatePendingCreate( + seedContext: context, + transaction: tx.transaction, + create: { viewContext, ctx in + AnyReconcilable(makeNode(viewContext, &ctx)) + } + ) + scheduleMaterialization([root], tx: &tx) + return + } + + let patched = root.withMountedNode(as: Node.self) { node in + updateNode(&node, &tx) + } + precondition(patched, "expected mounted conditional branch") + } + + private func scheduleMaterialization(_ roots: [MountRoot], tx: inout _TransactionContext) { + guard !roots.isEmpty else { return } + + tx.scheduler.addCommitAction { ctx in + for root in roots { + root.materialize(&ctx) + } + } + } } extension _ConditionalNode: _Reconcilable { @@ -118,7 +191,6 @@ extension _ConditionalNode: _Reconcilable { state = .a(a) } case .bWithALeaving(let b, let a): - // NOTE: ordering of a before b is important because we don't want to track moves here let isRemovalCompleted = ops.withRemovalTracking { ops in a.collectChildren(&ops, &context) } diff --git a/Sources/ElementaryUI/StructureViews/_ForEachNode.swift b/Sources/ElementaryUI/StructureViews/_ForEachNode.swift index 9f2ff6a..0b640d0 100644 --- a/Sources/ElementaryUI/StructureViews/_ForEachNode.swift +++ b/Sources/ElementaryUI/StructureViews/_ForEachNode.swift @@ -15,7 +15,7 @@ where Data: Collection, Content: _KeyReadableView, Content.Value: _Mountable { data: consuming Data, contentBuilder: @escaping @Sendable (Data.Element) -> Content, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) { self.data = data self.contentBuilder = contentBuilder @@ -23,7 +23,7 @@ where Data: Collection, Content: _KeyReadableView, Content.Value: _Mountable { self.depthInTree = context.functionDepth self.asFunctionNode = AnyFunctionNode(self) - runFunction(tx: &tx) + runFunctionInitial(ctx: &ctx) } func patch( @@ -60,29 +60,61 @@ where Data: Collection, Content: _KeyReadableView, Content.Value: _Mountable { self.trackingSession = session if keyedNode == nil { - var children: [AnyReconcilable?] = [] - children.reserveCapacity(views.count) - - for view in views { - let node = Content.Value._makeNode(view._value, context: context, tx: &tx) - children.append(AnyReconcilable(node)) - } - - keyedNode = _KeyedNode(keys: keys, children: children, context: context) - return + keyedNode = _KeyedNode(keys: [], children: [], context: context) } keyedNode!.patch( keys, context: &tx, as: Content.Value._MountedNode.self, - ) { index, node, context, tx in - if node == nil { - node = Content.Value._makeNode(views[index]._value, context: context, tx: &tx) - } else { - Content.Value._patchNode(views[index]._value, node: &node!, tx: &tx) + makeNode: { index, context, ctx in + Content.Value._makeNode(views[index]._value, context: context, ctx: &ctx) + }, + patchNode: { index, node, tx in + Content.Value._patchNode(views[index]._value, node: &node, tx: &tx) + } + ) + } + + private func runFunctionInitial(ctx: inout _CommitContext) { + self.trackingSession.take()?.cancel() + + let ((views, keys), session) = withReactiveTrackingSession { + var views: [Content] = [] + var keys: [_ViewKey] = [] + let estimatedCount = data.underestimatedCount + views.reserveCapacity(estimatedCount) + keys.reserveCapacity(estimatedCount) + + for value in data { + let view = contentBuilder(value) + views.append(view) + keys.append(view._key) } + + return (views, keys) + } onWillSet: { [scheduler = ctx.scheduler, asFunctionNode = asFunctionNode!] in + scheduler.invalidateFunction(asFunctionNode) } + + self.trackingSession = session + + var children: [MountRoot] = [] + children.reserveCapacity(views.count) + + for view in views { + children.append( + .materialized( + seedContext: context, + ctx: &ctx, + create: { context, ctx in + AnyReconcilable(Content.Value._makeNode(view._value, context: context, ctx: &ctx)) + } + ) + ) + } + + keyedNode = _KeyedNode(keys: keys, children: children, context: context) } public func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { diff --git a/Sources/ElementaryUI/StructureViews/_HTMLArray+Mountable.swift b/Sources/ElementaryUI/StructureViews/_HTMLArray+Mountable.swift index 373f809..b1fcf84 100644 --- a/Sources/ElementaryUI/StructureViews/_HTMLArray+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/_HTMLArray+Mountable.swift @@ -4,17 +4,27 @@ extension _HTMLArray: _Mountable, View where Element: View { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { - _MountedNode( - view.value.enumerated().map { (index, element) in - ( - key: _ViewKey(String(index)), - node: Element._makeNode(element, context: context, tx: &tx) - ) - }, - context: context - ) + var keys: [_ViewKey] = [] + var children: [MountRoot] = [] + let estimatedCount = view.value.underestimatedCount + keys.reserveCapacity(estimatedCount) + children.reserveCapacity(estimatedCount) + + for (index, element) in view.value.enumerated() { + keys.append(_ViewKey(String(index))) + let root = MountRoot.materialized( + seedContext: context, + ctx: &ctx, + create: { context, ctx in + AnyReconcilable(Element._makeNode(element, context: context, ctx: &ctx)) + } + ) + children.append(root) + } + + return _MountedNode(keys: keys, children: children, context: context) } public static func _patchNode( @@ -30,12 +40,11 @@ extension _HTMLArray: _Mountable, View where Element: View { indexes, context: &tx, as: Element._MountedNode.self, - makeOrPatchNode: { index, node, context, tx in - if node == nil { - node = Element._makeNode(view.value[index], context: context, tx: &tx) - } else { - Element._patchNode(view.value[index], node: &node!, tx: &tx) - } + makeNode: { index, context, ctx in + Element._makeNode(view.value[index], context: context, ctx: &ctx) + }, + patchNode: { index, node, tx in + Element._patchNode(view.value[index], node: &node, tx: &tx) } ) diff --git a/Sources/ElementaryUI/StructureViews/_HTMLConditional+Mountable.swift b/Sources/ElementaryUI/StructureViews/_HTMLConditional+Mountable.swift index a559dd3..3c61d22 100644 --- a/Sources/ElementaryUI/StructureViews/_HTMLConditional+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/_HTMLConditional+Mountable.swift @@ -5,13 +5,27 @@ extension _HTMLConditional: _Mountable where TrueContent: _Mountable, FalseConte public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { switch view.value { case let .trueContent(content): - return .init(a: TrueContent._makeNode(content, context: context, tx: &tx), context: context) + let aRoot = MountRoot.materialized( + seedContext: context, + ctx: &ctx, + create: { context, ctx in + AnyReconcilable(TrueContent._makeNode(content, context: context, ctx: &ctx)) + } + ) + return .init(aRoot: aRoot, context: context) case let .falseContent(content): - return .init(b: FalseContent._makeNode(content, context: context, tx: &tx), context: context) + let bRoot = MountRoot.materialized( + seedContext: context, + ctx: &ctx, + create: { context, ctx in + AnyReconcilable(FalseContent._makeNode(content, context: context, ctx: &ctx)) + } + ) + return .init(bRoot: bRoot, context: context) } } @@ -24,13 +38,13 @@ extension _HTMLConditional: _Mountable where TrueContent: _Mountable, FalseConte case let .trueContent(content): node.patchWithA( tx: &tx, - makeNode: { c, tx in TrueContent._makeNode(content, context: c, tx: &tx) }, + makeNode: { c, ctx in TrueContent._makeNode(content, context: c, ctx: &ctx) }, updateNode: { node, tx in TrueContent._patchNode(content, node: &node, tx: &tx) } ) case let .falseContent(content): node.patchWithB( tx: &tx, - makeNode: { c, tx in FalseContent._makeNode(content, context: c, tx: &tx) }, + makeNode: { c, ctx in FalseContent._makeNode(content, context: c, ctx: &ctx) }, updateNode: { node, tx in FalseContent._patchNode(content, node: &node, tx: &tx) } ) } diff --git a/Sources/ElementaryUI/StructureViews/_KeyedNode.swift b/Sources/ElementaryUI/StructureViews/_KeyedNode.swift index 854897a..0f17b98 100644 --- a/Sources/ElementaryUI/StructureViews/_KeyedNode.swift +++ b/Sources/ElementaryUI/StructureViews/_KeyedNode.swift @@ -1,10 +1,10 @@ public struct _KeyedNode { private var keys: [_ViewKey] - private var children: [AnyReconcilable?] + private var children: [MountRoot] private var leavingChildren: LeavingChildrenTracker = .init() private let viewContext: _ViewContext - init(keys: [_ViewKey], children: [AnyReconcilable?], context: borrowing _ViewContext) { + init(keys: [_ViewKey], children: [MountRoot], context: borrowing _ViewContext) { assert(keys.count == children.count) self.keys = keys self.children = children @@ -14,7 +14,7 @@ public struct _KeyedNode { init(_ value: some Sequence<(key: _ViewKey, node: some _Reconcilable)>, context: borrowing _ViewContext) { self.init( keys: value.map { $0.key }, - children: value.map { AnyReconcilable($0.node) }, + children: value.map { MountRoot.mounted(AnyReconcilable($0.node)) }, context: context ) } @@ -27,12 +27,14 @@ public struct _KeyedNode { key: _ViewKey, context: inout _TransactionContext, as: Node.Type = Node.self, - makeOrPatchNode: (inout Node?, borrowing _ViewContext, inout _TransactionContext) -> Void + makeNode: @escaping (borrowing _ViewContext, inout _CommitContext) -> Node, + patchNode: (inout Node, inout _TransactionContext) -> Void ) { patch( CollectionOfOne(key), context: &context, - makeOrPatchNode: { _, node, context, r in makeOrPatchNode(&node, context, &r) } + makeNode: { _, context, ctx in makeNode(context, &ctx) }, + patchNode: { _, node, tx in patchNode(&node, &tx) } ) } @@ -40,40 +42,63 @@ public struct _KeyedNode { _ newKeys: some BidirectionalCollection<_ViewKey>, context: inout _TransactionContext, as: Node.Type = Node.self, - makeOrPatchNode: (Int, inout Node?, borrowing _ViewContext, inout _TransactionContext) -> Void + makeNode: @escaping (Int, borrowing _ViewContext, inout _CommitContext) -> Node, + patchNode: (Int, inout Node, inout _TransactionContext) -> Void ) { - // NOTE: we want the fastest possible algorithm here - // TODO: clean this up guard !newKeys.isEmpty else { fastRemoveAll(context: &context) return } let newKeysArray = Array(newKeys) + var pendingMaterializations: [MountRoot] = [] - // TODO: clean this up guard !(keys.isEmpty && leavingChildren.entries.isEmpty) else { - // fast add-all case keys = newKeysArray - children = Array(repeating: nil, count: keys.count) + children.removeAll(keepingCapacity: true) + children.reserveCapacity(keys.count) for index in keys.indices { - children.withNode(index: index, as: Node.self) { node in - makeOrPatchNode(index, &node, self.viewContext, &context) - } + viewContext.parentElement?.reportChangedChildren(.elementAdded, tx: &context) + let root = MountRoot.pending( + seedContext: viewContext, + transaction: context.transaction, + transitionPhase: .willAppear, + create: { viewContext, ctx in + AnyReconcilable(makeNode(index, viewContext, &ctx)) + } + ) + children.append(root) + pendingMaterializations.append(root) } + scheduleMaterializationIfNeeded(&pendingMaterializations, context: &context) return } if leavingChildren.entries.isEmpty, keys.count == newKeysArray.count, keys.elementsEqual(newKeysArray) { - // Common ForEach path: structural identity unchanged. for index in children.indices { - children.withNode(index: index, as: Node.self) { node in - makeOrPatchNode(index, &node, self.viewContext, &context) + let child = children[index] + + if child.isPending { + child.updatePendingCreate( + seedContext: viewContext, + transaction: context.transaction, + create: { viewContext, ctx in + AnyReconcilable(makeNode(index, viewContext, &ctx)) + } + ) + pendingMaterializations.append(child) + continue } - assert(children[index] != nil, "unexpected nil child on collection") + + let patched = child.withMountedNode(as: Node.self) { node in + patchNode(index, &node, &context) + } + precondition(patched, "expected mounted child during stable keyed patch") } + + scheduleMaterializationIfNeeded(&pendingMaterializations, context: &context) return } @@ -81,93 +106,122 @@ public struct _KeyedNode { keys = newKeysArray if !diff.isEmpty { - var moversCache: [Int: AnyReconcilable] = [:] - - // is there a way to completely do this in-place? - // is there a way to do this more sub-rangy? - // anyway, this way the "move" case is a bit worse, but the rest is in place + var moversCache: [Int: MountRoot] = [:] for change in diff { switch change { case let .remove(offset, element: key, associatedWith: movedTo): - guard let node = children.remove(at: offset) else { - fatalError("unexpected nil child on collection") - } + let root = children.remove(at: offset) if movedTo != nil { - node.apply(.markAsMoved, &context) - moversCache[offset] = consume node + root.apply(.markAsMoved, &context) + moversCache[offset] = root } else { - node.apply(.startRemoval, &context) - self.viewContext.parentElement?.reportChangedChildren(.elementMoved, tx: &context) - leavingChildren.append(key, atIndex: offset, value: node) + root.apply(.startRemoval, &context) + viewContext.parentElement?.reportChangedChildren(.elementMoved, tx: &context) + leavingChildren.append(key, atIndex: offset, value: root) } case let .insert(offset, element: key, associatedWith: movedFrom): - var node: AnyReconcilable? = nil + let root: MountRoot if let movedFrom { logTrace("move \(key) from \(movedFrom) to \(offset)") - node = moversCache.removeValue(forKey: movedFrom) - precondition(node != nil, "mover not found in cache") + guard let moved = moversCache.removeValue(forKey: movedFrom) else { + preconditionFailure("mover not found in cache") + } + root = moved + } else { + viewContext.parentElement?.reportChangedChildren(.elementAdded, tx: &context) + root = MountRoot.pending( + seedContext: viewContext, + transaction: context.transaction, + transitionPhase: .willAppear, + create: { viewContext, ctx in + AnyReconcilable(makeNode(offset, viewContext, &ctx)) + } + ) } - children.insert(node, at: offset) + children.insert(root, at: offset) leavingChildren.reflectInsertionAt(offset) } } + precondition(moversCache.isEmpty, "mover cache is not empty") } - // run update / patch functions on all nodes for index in children.indices { - children.withNode(index: index, as: Node.self) { node in - makeOrPatchNode(index, &node, self.viewContext, &context) + let child = children[index] + + if child.isPending { + child.updatePendingCreate( + seedContext: viewContext, + transaction: context.transaction, + create: { viewContext, ctx in + AnyReconcilable(makeNode(index, viewContext, &ctx)) + } + ) + pendingMaterializations.append(child) + continue + } + + let patched = child.withMountedNode(as: Node.self) { node in + patchNode(index, &node, &context) } - assert(children[index] != nil, "unexpected nil child on collection") + precondition(patched, "expected mounted child during keyed patch") } + + scheduleMaterializationIfNeeded(&pendingMaterializations, context: &context) } mutating func fastRemoveAll(context: inout _TransactionContext) { - self.viewContext.parentElement?.reportChangedChildren(.elementMoved, tx: &context) + viewContext.parentElement?.reportChangedChildren(.elementMoved, tx: &context) for index in children.indices { - guard let node = children[index].take() else { - fatalError("unexpected nil child on collection") - } - - node.apply(.startRemoval, &context) - leavingChildren.append(keys[index], atIndex: index, value: node) + let root = children[index] + root.apply(.startRemoval, &context) + leavingChildren.append(keys[index], atIndex: index, value: root) } keys.removeAll() children.removeAll() } + + private mutating func scheduleMaterializationIfNeeded( + _ pendingMaterializations: inout [MountRoot], + context: inout _TransactionContext + ) { + guard !pendingMaterializations.isEmpty else { return } + + let roots = pendingMaterializations + context.scheduler.addCommitAction { ctx in + for root in roots { + root.materialize(&ctx) + } + } + pendingMaterializations.removeAll(keepingCapacity: true) + } } extension _KeyedNode: _Reconcilable { public func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { - for index in children.indices { - children[index]?.apply(op, &tx) + for child in children { + child.apply(op, &tx) } } public mutating func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { - // the trick here is to efficiently interleave the leaving nodes with the active nodes to match the DOM order - // the other trick is to stay noncopyable compatible (one fine day we will have lists, associated types and stuff like that) - // in any case, we need to mutate in place var lIndex = 0 var nextInsertionPoint = leavingChildren.insertionIndex(for: 0) for cIndex in children.indices { - precondition(children[cIndex] != nil, "unexpected nil child on collection") - if nextInsertionPoint == cIndex { let removed = leavingChildren.commitAndCheckRemoval(at: lIndex, ops: &ops, context: &context) if !removed { lIndex += 1 } nextInsertionPoint = leavingChildren.insertionIndex(for: lIndex) } - children[cIndex]!.collectChildren(&ops, &context) + children[cIndex].collectChildren(&ops, &context) } while nextInsertionPoint != nil { @@ -178,11 +232,11 @@ extension _KeyedNode: _Reconcilable { } public consuming func unmount(_ context: inout _CommitContext) { - for index in children.indices { - children[index]?.unmount(&context) + for child in children { + child.unmount(&context) } - children.removeAll() + for entry in leavingChildren.entries { entry.value.unmount(&context) } @@ -191,25 +245,21 @@ extension _KeyedNode: _Reconcilable { } private extension _KeyedNode { - struct LeavingChildrenTracker { //: ~Copyable { + struct LeavingChildrenTracker { struct Entry { let key: _ViewKey var originalMountIndex: Int - var value: AnyReconcilable + var value: MountRoot } var entries: [Entry] = [] func insertionIndex(for index: Int) -> Int? { guard index < entries.count else { return nil } - return entries[index].originalMountIndex } - mutating func append(_ key: _ViewKey, atIndex index: Int, value: consuming AnyReconcilable) { - // insert in key order - // Perform a sorted insert by key - // maybe do it backwards? + mutating func append(_ key: _ViewKey, atIndex index: Int, value: MountRoot) { let newEntry = Entry(key: key, originalMountIndex: index, value: value) if let insertIndex = entries.firstIndex(where: { $0.originalMountIndex > index }) { entries.insert(newEntry, at: insertIndex) @@ -222,7 +272,11 @@ private extension _KeyedNode { shiftEntriesFromIndexUpwards(index, by: 1) } - mutating func commitAndCheckRemoval(at index: Int, ops: inout _ContainerLayoutPass, context: inout _CommitContext) -> Bool { + mutating func commitAndCheckRemoval( + at index: Int, + ops: inout _ContainerLayoutPass, + context: inout _CommitContext + ) -> Bool { let isRemovalCommitted = ops.withRemovalTracking { ops in entries[index].value.collectChildren(&ops, &context) } @@ -238,27 +292,8 @@ private extension _KeyedNode { } private mutating func shiftEntriesFromIndexUpwards(_ index: Int, by amount: Int) { - //TODO: span - for i in entries.indices { - if entries[i].originalMountIndex >= index { - entries[i].originalMountIndex += amount - } - } - } - } -} - -extension [AnyReconcilable?] { - mutating func withNode(index: Index, as type: Node.Type = Node.self, body: (inout Node?) -> Void) { - if self[index] == nil { - var slot: Node? - body(&slot) - self[index] = slot.map(AnyReconcilable.init) - } else { - self[index]?.modify(as: Node.self) { - var node: Node? = $0 - body(&node) - $0 = node! + for i in entries.indices where entries[i].originalMountIndex >= index { + entries[i].originalMountIndex += amount } } } diff --git a/Sources/ElementaryUI/Transition/_TransitionNode.swift b/Sources/ElementaryUI/Transition/_TransitionNode.swift index 4834bc9..9f60c49 100644 --- a/Sources/ElementaryUI/Transition/_TransitionNode.swift +++ b/Sources/ElementaryUI/Transition/_TransitionNode.swift @@ -9,74 +9,75 @@ public final class _TransitionNode: _Reconcilable { private var placeholderNode: _PlaceholderNode? // a transition can theoretically duplicate the content node, but it will be rare private var additionalPlaceholderNodes: [_PlaceholderNode] = [] - private var currentRemovalAnimationTime: Double? - init(view: consuming _TransitionView, context: borrowing _ViewContext, tx: inout _TransactionContext) { + init(view: consuming _TransitionView, context: borrowing _ViewContext, ctx: inout _CommitContext) { + let view = view + let defaultAnimation = view.animation self.value = view - placeholderView = PlaceholderContentView(makeNodeFn: self.makePlaceholderNode) - - let transitionAnimation = tx.transaction.disablesAnimation ? nil : tx.transaction.animation ?? value.animation - - // the idea is that with disablesAnimation set to true, only the top-level transition will be animated after a mount will be animated - guard let transitionAnimation else { - self.node = AnyReconcilable( - T.Body._makeNode( - self.value.transition.body(content: placeholderView!, phase: .identity), - context: context, - tx: &tx - ) + self.placeholderView = PlaceholderContentView(makeNodeFn: self.makePlaceholderNode) + + let participantClaimID = context.mountRoot.reserveTransitionParticipant() + let initialPhase = context.mountRoot.consumeTransitionPhase(defaultAnimation: defaultAnimation) + self.node = makeInitialNode(for: initialPhase, context: context, ctx: &ctx) + + if let participantClaimID { + context.mountRoot.registerTransitionParticipant( + claimID: participantClaimID, + defaultAnimation: defaultAnimation, + patchPhase: { phase, tx in + self.patchTransitionPhase(phase, tx: &tx) + }, + isStillMounted: { + return self.node != nil + }, + ctx: &ctx ) - return - } - - tx.withModifiedTransaction { - $0.disablesAnimation = true - $0.animation = transitionAnimation - } run: { tx in - self.node = AnyReconcilable( - T.Body._makeNode( - self.value.transition.body(content: placeholderView!, phase: .willAppear), - context: context, - tx: &tx - ) - ) - } - - // Schedule follow-up TX to patch to identity phase (triggers CSS transition) - tx.scheduler.scheduleUpdate { [self] tx in - guard let placeholderView = self.placeholderView else { return } - tx.withModifiedTransaction { - $0.animation = transitionAnimation - } run: { tx in - self.node?.modify(as: T.Body._MountedNode.self) { node in - T.Body._patchNode( - self.value.transition.body(content: placeholderView, phase: .identity), - node: &node, - tx: &tx - ) - } - } } } - func update(view: consuming _TransitionView, context: inout _TransactionContext) { + func patchWrappedContent(_ view: consuming _TransitionView, tx: inout _TransactionContext) { self.value = view if let placeholderNode { placeholderNode.node.modify(as: V._MountedNode.self) { node in - V._patchNode(self.value.wrapped, node: &node, tx: &context) + V._patchNode(self.value.wrapped, node: &node, tx: &tx) } } for placeholder in additionalPlaceholderNodes { placeholder.node.modify(as: V._MountedNode.self) { node in - V._patchNode(self.value.wrapped, node: &node, tx: &context) + V._patchNode(self.value.wrapped, node: &node, tx: &tx) } } } - private func makePlaceholderNode(context: borrowing _ViewContext, tx: inout _TransactionContext) -> _PlaceholderNode { - let node = _PlaceholderNode(node: AnyReconcilable(V._makeNode(value.wrapped, context: context, tx: &tx))) + func patchTransitionPhase(_ phase: TransitionPhase, tx: inout _TransactionContext) { + guard let placeholderView else { return } + node?.modify(as: T.Body._MountedNode.self) { node in + T.Body._patchNode( + value.transition.body(content: placeholderView, phase: phase), + node: &node, + tx: &tx + ) + } + } + + func makeInitialNode( + for phase: TransitionPhase, + context: borrowing _ViewContext, + ctx: inout _CommitContext + ) -> AnyReconcilable { + AnyReconcilable( + T.Body._makeNode( + value.transition.body(content: placeholderView!, phase: phase), + context: context, + ctx: &ctx + ) + ) + } + + private func makePlaceholderNode(context: borrowing _ViewContext, ctx: inout _CommitContext) -> _PlaceholderNode { + let node = _PlaceholderNode(node: AnyReconcilable(V._makeNode(value.wrapped, context: context, ctx: &ctx))) if placeholderNode == nil { placeholderNode = node } else { @@ -86,57 +87,7 @@ public final class _TransitionNode: _Reconcilable { } public func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { - guard let placeholderView = placeholderView else { return } - switch op { - case .startRemoval: - let transitionAnimation = tx.transaction.disablesAnimation ? nil : tx.transaction.animation ?? value.animation - - // if no animation is set, we just pass down removal op - guard let transitionAnimation else { - node?.apply(op, &tx) - return - } - - tx.withModifiedTransaction { - $0.animation = transitionAnimation - } run: { tx in - node?.apply(.markAsLeaving, &tx) - - // the patch does not go past the placeholder, so this only animates the transition - node?.modify(as: T.Body._MountedNode.self) { node in - T.Body._patchNode( - value.transition.body(content: placeholderView, phase: .didDisappear), - node: &node, - tx: &tx - ) - } - } - - currentRemovalAnimationTime = tx.currentFrameTime - - tx.transaction.addAnimationCompletion(criteria: .removed) { - [scheduler = tx.scheduler, frameTime = currentRemovalAnimationTime] in - guard let currentTime = self.currentRemovalAnimationTime, currentTime == frameTime else { return } - scheduler.scheduleUpdate { [self] tx in - self.node?.apply(.startRemoval, &tx) - } - } - case .cancelRemoval: - currentRemovalAnimationTime = nil - // TODO: check this, stuff is for sure missing for reversible transitions - node?.apply(.cancelRemoval, &tx) - node?.modify(as: T.Body._MountedNode.self) { node in - T.Body._patchNode( - value.transition.body(content: placeholderView, phase: .identity), - node: &node, - tx: &tx - ) - } - case .markAsMoved: - node?.apply(op, &tx) - case .markAsLeaving: - node?.apply(op, &tx) - } + node?.apply(op, &tx) } public func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { diff --git a/Sources/ElementaryUI/Transition/_TransitionView.swift b/Sources/ElementaryUI/Transition/_TransitionView.swift index 9cd71f9..7211098 100644 --- a/Sources/ElementaryUI/Transition/_TransitionView.swift +++ b/Sources/ElementaryUI/Transition/_TransitionView.swift @@ -15,9 +15,9 @@ public struct _TransitionView: View { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { - .init(view: view, context: context, tx: &tx) + .init(view: view, context: context, ctx: &ctx) } public static func _patchNode( @@ -25,6 +25,6 @@ public struct _TransitionView: View { node: inout _MountedNode, tx: inout _TransactionContext ) { - node.update(view: view, context: &tx) + node.patchWrappedContent(view, tx: &tx) } } diff --git a/Sources/ElementaryUI/Views/Function/_FunctionNode.swift b/Sources/ElementaryUI/Views/Function/_FunctionNode.swift index 54763ba..35fa387 100644 --- a/Sources/ElementaryUI/Views/Function/_FunctionNode.swift +++ b/Sources/ElementaryUI/Views/Function/_FunctionNode.swift @@ -25,7 +25,7 @@ where Value: __FunctionView, ChildNode: _Reconcilable, ChildNode == Value.Body._ init( value: consuming Value, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) { self.depthInTree = context.functionDepth @@ -34,7 +34,7 @@ where Value: __FunctionView, ChildNode: _Reconcilable, ChildNode == Value.Body._ Value.__applyContext(context, to: &value) Value.__restoreState(state!, in: &value) - self.value = value + self.value = copy value self.context = copy context self.context!.functionDepth += 1 @@ -42,7 +42,15 @@ where Value: __FunctionView, ChildNode: _Reconcilable, ChildNode == Value.Body._ logTrace("added function \(identifier)") - runFunction(tx: &tx) + let (newContent, session) = withReactiveTrackingSession { + value.body + } onWillSet: { [scheduler = ctx.scheduler, asFunctionNode = asFunctionNode!] in + scheduler.invalidateFunction(asFunctionNode) + } + + self.trackingSession = session + self.child = Value.Body._makeNode(newContent, context: self.context!, ctx: &ctx) + self.value = value } func patch(_ value: consuming Value, tx: inout _TransactionContext) { @@ -105,11 +113,8 @@ where Value: __FunctionView, ChildNode: _Reconcilable, ChildNode == Value.Body._ self.trackingSession = session - if child == nil { - self.child = Value.Body._makeNode(newContent, context: context!, tx: &tx) - } else { - Value.Body._patchNode(newContent, node: &child!, tx: &tx) - } + precondition(child != nil, "function child is missing during transaction update") + Value.Body._patchNode(newContent, node: &child!, tx: &tx) } } diff --git a/Sources/ElementaryUI/Views/Function/_FunctionView.swift b/Sources/ElementaryUI/Views/Function/_FunctionView.swift index 59560ff..bae27ea 100644 --- a/Sources/ElementaryUI/Views/Function/_FunctionView.swift +++ b/Sources/ElementaryUI/Views/Function/_FunctionView.swift @@ -17,12 +17,12 @@ public extension __FunctionView { static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { .init( value: view, context: context, - tx: &tx + ctx: &ctx ) } diff --git a/Sources/ElementaryUI/Views/View+Mountable.swift b/Sources/ElementaryUI/Views/View+Mountable.swift index ece1103..306e386 100644 --- a/Sources/ElementaryUI/Views/View+Mountable.swift +++ b/Sources/ElementaryUI/Views/View+Mountable.swift @@ -63,7 +63,7 @@ public protocol _Mountable { static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode static func _patchNode( @@ -79,7 +79,7 @@ extension Never: _Mountable { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _CommitContext ) -> _MountedNode { fatalError("This should never be called") } diff --git a/Tests/ElementaryUITests/Reconciler/DeinitSniffer.swift b/Tests/ElementaryUITests/Reconciler/DeinitSniffer.swift index c7cfa21..cafba72 100644 --- a/Tests/ElementaryUITests/Reconciler/DeinitSniffer.swift +++ b/Tests/ElementaryUITests/Reconciler/DeinitSniffer.swift @@ -3,8 +3,8 @@ import ElementaryUI struct DeinitSnifferView: View { static func _makeNode( _ view: consuming DeinitSnifferView, - context: consuming _ViewContext, - tx: inout _TransactionContext + context: borrowing _ViewContext, + ctx: inout _CommitContext ) -> _MountedNode { _MountedNode(callback: view.callback) } diff --git a/Tests/ElementaryUITests/Reconciler/TestDOM.swift b/Tests/ElementaryUITests/Reconciler/TestDOM.swift index f7b0cb4..a052bb8 100644 --- a/Tests/ElementaryUITests/Reconciler/TestDOM.swift +++ b/Tests/ElementaryUITests/Reconciler/TestDOM.swift @@ -147,11 +147,24 @@ final class TestDOM: DOM.Interactor { } func makeStyleAccessor(_ node: DOM.Node, cssName: String) -> DOM.StyleAccessor { - fatalError("Not implemented") + DOM.StyleAccessor( + get: { + guard case let .element(data) = node.value.kind else { return "" } + return data.inlineStyles[cssName] ?? "" + }, + set: { [self] value in + guard case let .element(data) = node.value.kind else { return } + data.inlineStyles[cssName] = value + ops.append(.setStyle(node: label(node), name: cssName, value: value)) + } + ) } func makeComputedStyleAccessor(_ node: DOM.Node) -> DOM.ComputedStyleAccessor { - fatalError("Not implemented") + DOM.ComputedStyleAccessor { cssName in + guard case let .element(data) = node.value.kind else { return "" } + return data.inlineStyles[cssName] ?? "" + } } func makeFocusAccessor(_ node: DOM.Node, onEvent: @escaping (DOM.FocusEvent) -> Void) -> DOM.FocusAccessor { diff --git a/Tests/ElementaryUITests/Reconciler/TransitionMountRootTests.swift b/Tests/ElementaryUITests/Reconciler/TransitionMountRootTests.swift new file mode 100644 index 0000000..3adae65 --- /dev/null +++ b/Tests/ElementaryUITests/Reconciler/TransitionMountRootTests.swift @@ -0,0 +1,197 @@ +import Reactivity +import Testing + +@testable import ElementaryUI + +@Suite(.serialized) +struct TransitionMountRootTests { + @Test + func firstTransitionConsumesMountRootTransitionPhase() { + let animation = Animation.linear(duration: 0.35) + let state = VisibilityState() + let recorder = TransitionPhaseRecorder() + let dom = TestDOM() + + dom.mount { + Group { + if state.value { + p {}.transition(RecordingFadeTransition(recorder: recorder), animation: animation) + } + } + } + dom.runNextFrame() + #expect(recorder.phases.isEmpty) + + state.value = true + dom.runNextFrame() + + #expect(recorder.phases.suffix(2).elementsEqual([.willAppear, .identity])) + } + + @Test + func nestedTransitionsOnlyFirstConsumesSignal() { + let animation = Animation.linear(duration: 0.35) + let state = VisibilityState() + let outerRecorder = TransitionPhaseRecorder() + let innerRecorder = TransitionPhaseRecorder() + let dom = TestDOM() + + dom.mount { + Group { + if state.value { + p {} + .transition(RecordingFadeTransition(recorder: innerRecorder), animation: animation) + .transition(RecordingFadeTransition(recorder: outerRecorder), animation: animation) + } + } + } + dom.runNextFrame() + + state.value = true + dom.runNextFrame() + + #expect(outerRecorder.phases.suffix(2).elementsEqual([.willAppear, .identity])) + #expect(!innerRecorder.phases.contains(.willAppear)) + #expect(innerRecorder.phases.first == .identity) + } + + @Test + func keyedInsertionWithTransitionDoesWillAppearToIdentity() { + let state = StringItemsState() + nonisolated(unsafe) let recorder = TransitionPhaseRecorder() + let dom = TestDOM() + + dom.mount { + ForEach(state.items, key: \.self) { item in + p { item }.transition(RecordingFadeTransition(recorder: recorder)) + } + } + dom.runNextFrame() + #expect(recorder.phases.isEmpty) + + withAnimation(.linear(duration: 0.35)) { + state.items = ["A"] + } + dom.runNextFrame() + + #expect(recorder.phases.suffix(2).elementsEqual([.willAppear, .identity])) + } + + @Test + func conditionalFlipKeepsRemovalCancelRemovalSemantics() { + let animation = Animation.linear(duration: 0.35) + let state = VisibilityState(true) + let recorder = TransitionPhaseRecorder() + let dom = TestDOM() + + dom.mount { + Group { + if state.value { + p {}.transition(RecordingFadeTransition(recorder: recorder), animation: animation) + } + } + } + dom.runNextFrame() + recorder.reset() + + dom.clearOps() + state.value = false + dom.runNextFrame() + #expect(recorder.phases.contains(.didDisappear)) + + dom.clearOps() + let previousCount = recorder.phases.count + state.value = true + dom.runNextFrame() + + let newPhases = Array(recorder.phases.dropFirst(previousCount)) + #expect(newPhases.contains(.identity)) + #expect(!newPhases.contains(.willAppear)) + #expect(!dom.ops.contains(.createElement("p"))) + } + + @Test + func noUninitializedNodesInPlacementCollection() { + let state = StringItemsState() + let dom = TestDOM() + + dom.mount { + ForEach(state.items, key: \.self) { item in + p { item } + } + } + dom.runNextFrame() + dom.clearOps() + + state.items = ["A", "B", "C"] + dom.runNextFrame() + + let createdPCount = dom.ops.filter { op in + if case .createElement("p") = op { return true } + return false + }.count + + #expect(createdPCount == 3) + #expect( + dom.ops.contains(.setChildren(parent: "<>", children: ["

", "

", "

"])) + ) + } + + // @Test + // func schedulesOneDeferredCreateCommitCallbackPerOwnerPatchPass() { + // let state = StringItemsState(["A"]) + // let dom = TestDOM() + + // dom.mount { + // ForEach(state.items, key: \.self) { item in + // p { item } + // } + // } + // dom.runNextFrame() + + // ReconcilerDebugCounters.reset() + // state.items = ["A", "B", "C", "D"] + // dom.runNextFrame() + + // #expect(ReconcilerDebugCounters.deferredCreateCommitCallbackCount == 1) + // } +} + +private final class TransitionPhaseRecorder { + private(set) var phases: [TransitionPhase] = [] + + func record(_ phase: TransitionPhase) { + phases.append(phase) + } + + func reset() { + phases.removeAll() + } +} + +private struct RecordingFadeTransition: Transition { + let recorder: TransitionPhaseRecorder + + func body(content: Content, phase: TransitionPhase) -> some View { + recorder.record(phase) + return content.opacity(phase.isIdentity ? 1 : 0) + } +} + +@Reactive +private final class VisibilityState { + var value: Bool + + init(_ value: Bool = false) { + self.value = value + } +} + +@Reactive +private final class StringItemsState { + var items: [String] + + init(_ items: [String] = []) { + self.items = items + } +} From 1008c2dc3c8a0de1a100bff1a7a29026afdf07a4 Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:45:33 +0100 Subject: [PATCH 02/17] WIP: MountRoot --- Package.swift | 1 + .../Reconciling/ApplicationRuntime.swift | 2 +- .../Reconciling/_ViewContext.swift | 333 +----------------- .../StructureViews/KeyedView.swift | 14 +- .../StructureViews/MountRoot.swift | 294 ++++++++++++++++ .../StructureViews/Optional+Mountable.swift | 20 +- .../StructureViews/_ConditionalNode.swift | 81 ++--- .../StructureViews/_ForEachNode.swift | 24 +- .../StructureViews/_HTMLArray+Mountable.swift | 21 +- .../_HTMLConditional+Mountable.swift | 22 +- .../StructureViews/_KeyedNode.swift | 107 ++++-- Sources/ElementaryUIMacros/ViewMacro.swift | 2 +- 12 files changed, 442 insertions(+), 479 deletions(-) create mode 100644 Sources/ElementaryUI/StructureViews/MountRoot.swift diff --git a/Package.swift b/Package.swift index 4197643..96c5935 100644 --- a/Package.swift +++ b/Package.swift @@ -31,6 +31,7 @@ let package = Package( .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("ConciseMagicFile"), .enableUpcomingFeature("ImplicitOpenExistentials"), + .enableExperimentalFeature("Lifetimes"), ] ), .target( diff --git a/Sources/ElementaryUI/Reconciling/ApplicationRuntime.swift b/Sources/ElementaryUI/Reconciling/ApplicationRuntime.swift index bc153a6..2e51e92 100644 --- a/Sources/ElementaryUI/Reconciling/ApplicationRuntime.swift +++ b/Sources/ElementaryUI/Reconciling/ApplicationRuntime.swift @@ -19,7 +19,7 @@ final class ApplicationRuntime { $0.disablesAnimation = true } run: { tx in var rootViewContext = _ViewContext() - rootViewContext.mountRoot = MountRoot.from(tx.transaction) + rootViewContext.mountRoot = MountRoot(mounted: nil, transaction: tx.transaction) tx.scheduler.addCommitAction { [self, rootView, rootViewContext] ctx in self.rootNode = diff --git a/Sources/ElementaryUI/Reconciling/_ViewContext.swift b/Sources/ElementaryUI/Reconciling/_ViewContext.swift index 239b571..4d72487 100644 --- a/Sources/ElementaryUI/Reconciling/_ViewContext.swift +++ b/Sources/ElementaryUI/Reconciling/_ViewContext.swift @@ -1,334 +1,3 @@ -public final class MountRoot { - enum PendingOpsPolicy { - case assertInDebug - } - - enum MountState { - case pending - case mounted - case unmounted - } - - struct TransitionParticipant { - let patchPhase: (TransitionPhase, inout _TransactionContext) -> Void - let defaultAnimation: Animation? - let isStillMounted: () -> Bool - } - - nonisolated(unsafe) private static var nextID: UInt64 = 0 - nonisolated(unsafe) private static var nextTransitionClaimID: UInt64 = 0 - - let id: UInt64 - - private(set) var mountState: MountState - private var node: AnyReconcilable? - private var seedContext: _ViewContext? - private var createClosure: ((borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable)? - - private let pendingOpsPolicy: PendingOpsPolicy - - private var transitionSignal: TransitionPhase? - var transactionAnimation: Animation? - var transactionDisablesAnimation: Bool - private var transitionParticipantClaimID: UInt64? - private var transitionParticipant: TransitionParticipant? - private var pendingEnterAnimation: Animation? - private var removalToken: Double? - - init( - transitionPhase: TransitionPhase? = nil, - transactionAnimation: Animation? = nil, - transactionDisablesAnimation: Bool = false, - pendingOpsPolicy: PendingOpsPolicy = .assertInDebug - ) { - MountRoot.nextID &+= 1 - self.id = MountRoot.nextID - self.mountState = .mounted - self.pendingOpsPolicy = pendingOpsPolicy - self.transitionSignal = transitionPhase - self.transactionAnimation = transactionAnimation - self.transactionDisablesAnimation = transactionDisablesAnimation - } - - private init( - seedContext: borrowing _ViewContext, - transaction: Transaction, - transitionSignal: TransitionPhase?, - pendingOpsPolicy: PendingOpsPolicy, - create: @escaping (borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable - ) { - MountRoot.nextID &+= 1 - self.id = MountRoot.nextID - self.mountState = .pending - self.node = nil - self.seedContext = copy seedContext - self.createClosure = create - self.pendingOpsPolicy = pendingOpsPolicy - self.transitionSignal = transitionSignal - self.transactionAnimation = transaction.animation - self.transactionDisablesAnimation = transaction.disablesAnimation - } - - static func from(_ transaction: Transaction, transitionPhase: TransitionPhase? = nil) -> MountRoot { - MountRoot( - transitionPhase: transitionPhase, - transactionAnimation: transaction.animation, - transactionDisablesAnimation: transaction.disablesAnimation - ) - } - - static func pending( - seedContext: borrowing _ViewContext, - transaction: Transaction, - transitionPhase: TransitionPhase? = nil, - create: @escaping (borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable - ) -> MountRoot { - MountRoot( - seedContext: seedContext, - transaction: transaction, - transitionSignal: transitionPhase, - pendingOpsPolicy: .assertInDebug, - create: create - ) - } - - static func materialized( - seedContext: borrowing _ViewContext, - transaction: Transaction? = nil, - transitionPhase: TransitionPhase? = nil, - ctx: inout _CommitContext, - create: @escaping (borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable - ) -> MountRoot { - let effectiveTransaction: Transaction - if let transaction { - effectiveTransaction = transaction - } else { - var inherited = Transaction(animation: seedContext.mountRoot.transactionAnimation) - inherited.disablesAnimation = seedContext.mountRoot.transactionDisablesAnimation - effectiveTransaction = inherited - } - - let root = MountRoot.pending( - seedContext: seedContext, - transaction: effectiveTransaction, - transitionPhase: transitionPhase, - create: create - ) - root.materialize(&ctx) - return root - } - - static func mounted(_ node: consuming AnyReconcilable) -> MountRoot { - let root = MountRoot() - root.node = node - return root - } - - var isPending: Bool { - mountState == .pending - } - - func updatePendingCreate( - seedContext: borrowing _ViewContext, - transaction: Transaction, - create: @escaping (borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable - ) { - guard mountState == .pending else { return } - self.seedContext = copy seedContext - self.createClosure = create - self.transactionAnimation = transaction.animation - self.transactionDisablesAnimation = transaction.disablesAnimation - } - - func materialize(_ ctx: inout _CommitContext) { - guard mountState == .pending else { return } - guard let createClosure, let seedContext else { - preconditionFailure("pending MountRoot missing create context") - } - - var childContext = seedContext - childContext.mountRoot = self - - node = createClosure(childContext, &ctx) - self.seedContext = nil - self.createClosure = nil - self.mountState = .mounted - } - - func consumeTransitionPhase(defaultAnimation: Animation?) -> TransitionPhase { - let signal = transitionSignal - transitionSignal = nil - - guard signal == .willAppear else { return signal ?? .identity } - guard let animation = effectiveAnimation(defaultAnimation: defaultAnimation) else { - return .identity - } - - pendingEnterAnimation = animation - return .willAppear - } - - func reserveTransitionParticipant() -> UInt64? { - guard transitionParticipant == nil, transitionParticipantClaimID == nil else { return nil } - MountRoot.nextTransitionClaimID &+= 1 - let claimID = MountRoot.nextTransitionClaimID - transitionParticipantClaimID = claimID - return claimID - } - - func registerTransitionParticipant( - claimID: UInt64, - defaultAnimation: Animation?, - patchPhase: @escaping (TransitionPhase, inout _TransactionContext) -> Void, - isStillMounted: @escaping () -> Bool, - ctx: inout _CommitContext - ) { - guard transitionParticipant == nil, transitionParticipantClaimID == claimID else { return } - transitionParticipantClaimID = nil - transitionParticipant = .init( - patchPhase: patchPhase, - defaultAnimation: defaultAnimation, - isStillMounted: isStillMounted - ) - - guard let enterAnimation = pendingEnterAnimation else { return } - pendingEnterAnimation = nil - - ctx.scheduler.scheduleUpdate { tx in - guard let participant = self.transitionParticipant, participant.isStillMounted() else { return } - tx.withModifiedTransaction({ - $0.animation = enterAnimation - }, run: { tx in - participant.patchPhase(.identity, &tx) - }) - } - } - - func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { - switch mountState { - case .pending: - applyPending(op) - return - case .unmounted: - return - case .mounted: - break - } - - switch op { - case .startRemoval: - startRemoval(&tx) - case .cancelRemoval: - cancelRemoval(&tx) - case .markAsMoved, .markAsLeaving: - node?.apply(op, &tx) - } - } - - func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { - switch mountState { - case .mounted: - guard let node else { - preconditionFailure("mounted MountRoot missing node") - } - node.collectChildren(&ops, &context) - case .pending: - preconditionFailure("pending MountRoot reached collectChildren") - case .unmounted: - return - } - } - - func unmount(_ context: inout _CommitContext) { - switch mountState { - case .mounted: - node?.unmount(&context) - case .pending, .unmounted: - break - } - - node = nil - seedContext = nil - createClosure = nil - transitionParticipantClaimID = nil - transitionParticipant = nil - pendingEnterAnimation = nil - removalToken = nil - mountState = .unmounted - } - - @discardableResult - func withMountedNode( - as type: Node.Type = Node.self, - _ body: (inout Node) -> Void - ) -> Bool { - guard mountState == .mounted, let node else { return false } - node.modify(as: Node.self, body) - return true - } - - private func applyPending(_ op: _ReconcileOp) { - switch op { - case .startRemoval: - node = nil - seedContext = nil - createClosure = nil - transitionParticipantClaimID = nil - mountState = .unmounted - case .cancelRemoval, .markAsMoved, .markAsLeaving: - switch pendingOpsPolicy { - case .assertInDebug: - assertionFailure("apply(\(op)) on pending MountRoot") - } - } - } - - private func startRemoval(_ tx: inout _TransactionContext) { - guard let participant = transitionParticipant, participant.isStillMounted() else { - node?.apply(.startRemoval, &tx) - return - } - - guard let transitionAnimation = effectiveAnimation( - defaultAnimation: participant.defaultAnimation, - transaction: tx.transaction - ) else { - node?.apply(.startRemoval, &tx) - return - } - - tx.withModifiedTransaction({ - $0.animation = transitionAnimation - }, run: { tx in - node?.apply(.markAsLeaving, &tx) - participant.patchPhase(.didDisappear, &tx) - }) - - removalToken = tx.currentFrameTime - tx.transaction.addAnimationCompletion(criteria: .removed) { [scheduler = tx.scheduler, frameTime = removalToken] in - guard self.removalToken == frameTime else { return } - scheduler.scheduleUpdate { tx in - self.node?.apply(.startRemoval, &tx) - } - } - } - - private func cancelRemoval(_ tx: inout _TransactionContext) { - removalToken = nil - node?.apply(.cancelRemoval, &tx) - - guard let participant = transitionParticipant, participant.isStillMounted() else { return } - participant.patchPhase(.identity, &tx) - } - - private func effectiveAnimation(defaultAnimation: Animation?, transaction: Transaction? = nil) -> Animation? { - let disablesAnimation = transaction?.disablesAnimation ?? transactionDisablesAnimation - guard !disablesAnimation else { return nil } - - return transaction?.animation ?? transactionAnimation ?? defaultAnimation - } -} - // TODO: think about a better name for this... maybe _EnvironmentContext? public struct _ViewContext { var environment: EnvironmentValues = .init() @@ -338,7 +7,7 @@ public struct _ViewContext { var layoutObservers: DOMLayoutObservers = .init() var functionDepth: Int = 0 var parentElement: _ElementNode? - var mountRoot: MountRoot = .init() + var mountRoot: MountRoot = .init(mounted: nil) mutating func takeModifiers() -> [any DOMElementModifier] { modifiers.take() diff --git a/Sources/ElementaryUI/StructureViews/KeyedView.swift b/Sources/ElementaryUI/StructureViews/KeyedView.swift index 64588ec..8222f77 100644 --- a/Sources/ElementaryUI/StructureViews/KeyedView.swift +++ b/Sources/ElementaryUI/StructureViews/KeyedView.swift @@ -10,18 +10,14 @@ public struct _KeyedView: View { context: borrowing _ViewContext, ctx: inout _CommitContext ) -> _MountedNode { - let childRoot = MountRoot.materialized( - seedContext: context, + .init( + key: view.key, + context: context, ctx: &ctx, - create: { context, ctx in - AnyReconcilable(Value._makeNode(view.value, context: context, ctx: &ctx)) + makeNode: { context, ctx in + Value._makeNode(view.value, context: context, ctx: &ctx) } ) - return .init( - keys: [view.key], - children: [childRoot], - context: context - ) } public static func _patchNode( diff --git a/Sources/ElementaryUI/StructureViews/MountRoot.swift b/Sources/ElementaryUI/StructureViews/MountRoot.swift new file mode 100644 index 0000000..51895fe --- /dev/null +++ b/Sources/ElementaryUI/StructureViews/MountRoot.swift @@ -0,0 +1,294 @@ +final class MountRoot { + private enum State { + case pending( + seedContext: _ViewContext, + create: (borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable + ) + case mounted(AnyReconcilable?) + case unmounted + } + + struct TransitionParticipant { + let patchPhase: (TransitionPhase, inout _TransactionContext) -> Void + let defaultAnimation: Animation? + let isStillMounted: () -> Bool + } + + nonisolated(unsafe) private static var nextTransitionClaimID: UInt64 = 0 + + private var state: State + + private var transitionSignal: TransitionPhase? + var transactionAnimation: Animation? + var transactionDisablesAnimation: Bool + private var transitionParticipantClaimID: UInt64? + private var transitionParticipant: TransitionParticipant? + private var pendingEnterAnimation: Animation? + private var removalToken: Double? + + init( + mounted node: consuming AnyReconcilable? = nil, + transaction: Transaction? = nil, + transitionPhase: TransitionPhase? = nil + ) { + self.state = .mounted(node) + self.transitionSignal = transitionPhase + self.transactionAnimation = transaction?.animation + self.transactionDisablesAnimation = transaction?.disablesAnimation ?? false + } + + init( + pending seedContext: borrowing _ViewContext, + transaction: Transaction, + transitionPhase: TransitionPhase? = nil, + create: @escaping (borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable + ) { + self.state = .pending(seedContext: copy seedContext, create: create) + self.transitionSignal = transitionPhase + self.transactionAnimation = transaction.animation + self.transactionDisablesAnimation = transaction.disablesAnimation + } + + init( + mountedFrom seedContext: borrowing _ViewContext, + transaction: Transaction, + transitionPhase: TransitionPhase? = nil, + ctx: inout _CommitContext, + create: (borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable + ) { + self.state = .mounted(nil) + self.transitionSignal = transitionPhase + self.transactionAnimation = transaction.animation + self.transactionDisablesAnimation = transaction.disablesAnimation + + var childContext = copy seedContext + childContext.mountRoot = self + self.state = .mounted(create(childContext, &ctx)) + } + + func inheritedTransaction() -> Transaction { + var transaction = Transaction(animation: transactionAnimation) + transaction.disablesAnimation = transactionDisablesAnimation + return transaction + } + + var isPending: Bool { + if case .pending = state { return true } + return false + } + + func mount(_ ctx: inout _CommitContext) { + guard case let .pending(seedContext, create) = state else { return } + + var childContext = seedContext + childContext.mountRoot = self + + state = .mounted(create(childContext, &ctx)) + } + + func consumeTransitionPhase(defaultAnimation: Animation?) -> TransitionPhase { + let signal = transitionSignal + transitionSignal = nil + + guard signal == .willAppear else { return signal ?? .identity } + guard let animation = effectiveAnimation(defaultAnimation: defaultAnimation) else { + return .identity + } + + pendingEnterAnimation = animation + return .willAppear + } + + func reserveTransitionParticipant() -> UInt64? { + guard transitionParticipant == nil, transitionParticipantClaimID == nil else { return nil } + MountRoot.nextTransitionClaimID &+= 1 + let claimID = MountRoot.nextTransitionClaimID + transitionParticipantClaimID = claimID + return claimID + } + + func registerTransitionParticipant( + claimID: UInt64, + defaultAnimation: Animation?, + patchPhase: @escaping (TransitionPhase, inout _TransactionContext) -> Void, + isStillMounted: @escaping () -> Bool, + ctx: inout _CommitContext + ) { + guard transitionParticipant == nil, transitionParticipantClaimID == claimID else { return } + transitionParticipantClaimID = nil + transitionParticipant = .init( + patchPhase: patchPhase, + defaultAnimation: defaultAnimation, + isStillMounted: isStillMounted + ) + + guard let enterAnimation = pendingEnterAnimation else { return } + pendingEnterAnimation = nil + + ctx.scheduler.scheduleUpdate { tx in + guard let participant = self.transitionParticipant, participant.isStillMounted() else { return } + tx.withModifiedTransaction( + { + $0.animation = enterAnimation + }, + run: { tx in + participant.patchPhase(.identity, &tx) + } + ) + } + } + + func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { + switch state { + case .pending: + applyPending(op) + return + case .unmounted: + return + case .mounted: + break + } + + switch op { + case .startRemoval: + startRemoval(&tx) + case .cancelRemoval: + cancelRemoval(&tx) + case .markAsMoved, .markAsLeaving: + mountedNode?.apply(op, &tx) + } + } + + func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { + switch state { + case .mounted(let node): + guard let node else { return } + node.collectChildren(&ops, &context) + case .pending: + preconditionFailure("pending MountRoot reached collectChildren") + case .unmounted: + return + } + } + + func unmount(_ context: inout _CommitContext) { + switch state { + case .mounted(let node): + node?.unmount(&context) + case .pending, .unmounted: + break + } + + state = .unmounted + transitionParticipantClaimID = nil + transitionParticipant = nil + pendingEnterAnimation = nil + removalToken = nil + } + + @discardableResult + func withMountedNode( + as type: Node.Type = Node.self, + _ body: (inout Node) -> Void + ) -> Bool { + guard case .mounted(let node) = state, let node else { return false } + node.modify(as: Node.self, body) + return true + } + + private func applyPending(_ op: _ReconcileOp) { + switch op { + case .startRemoval: + state = .unmounted + transitionParticipantClaimID = nil + case .cancelRemoval, .markAsMoved, .markAsLeaving: + assertionFailure("apply(\(op)) on pending MountRoot") + } + } + + private func startRemoval(_ tx: inout _TransactionContext) { + guard let participant = transitionParticipant, participant.isStillMounted() else { + mountedNode?.apply(.startRemoval, &tx) + return + } + + guard + let transitionAnimation = effectiveAnimation( + defaultAnimation: participant.defaultAnimation, + transaction: tx.transaction + ) + else { + mountedNode?.apply(.startRemoval, &tx) + return + } + + tx.withModifiedTransaction( + { + $0.animation = transitionAnimation + }, + run: { tx in + mountedNode?.apply(.markAsLeaving, &tx) + participant.patchPhase(.didDisappear, &tx) + } + ) + + removalToken = tx.currentFrameTime + tx.transaction.addAnimationCompletion(criteria: .removed) { [scheduler = tx.scheduler, frameTime = removalToken] in + guard self.removalToken == frameTime else { return } + scheduler.scheduleUpdate { tx in + self.mountedNode?.apply(.startRemoval, &tx) + } + } + } + + private func cancelRemoval(_ tx: inout _TransactionContext) { + removalToken = nil + mountedNode?.apply(.cancelRemoval, &tx) + + guard let participant = transitionParticipant, participant.isStillMounted() else { return } + participant.patchPhase(.identity, &tx) + } + + private var mountedNode: AnyReconcilable? { + guard case .mounted(let node) = state else { return nil } + return node + } + + private func effectiveAnimation(defaultAnimation: Animation?, transaction: Transaction? = nil) -> Animation? { + let disablesAnimation = transaction?.disablesAnimation ?? transactionDisablesAnimation + guard !disablesAnimation else { return nil } + + return transaction?.animation ?? transactionAnimation ?? defaultAnimation + } +} + +final class _MountRootList { + var entries: [_MountRootEntry] + + init() { + self.entries = [] + } +} + +struct _MountRootEntry { + var nodeContainer: NodeContainer +} + +enum MountedNode { + case mountRoot(_MountRootList) + case domNode(DOM.Node) +} + +struct NodeContainer { + enum Entry { + case staticNode(DOM.Node) + case dynamicNode(any DynamicNode) + } + + let nodes: [Entry] +} + +protocol DynamicNode { + var count: Int { get } + func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) +} diff --git a/Sources/ElementaryUI/StructureViews/Optional+Mountable.swift b/Sources/ElementaryUI/StructureViews/Optional+Mountable.swift index efed0ba..994f542 100644 --- a/Sources/ElementaryUI/StructureViews/Optional+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/Optional+Mountable.swift @@ -9,21 +9,17 @@ extension Optional: _Mountable where Wrapped: _Mountable { ) -> _MountedNode { switch view { case let .some(view): - let aRoot = MountRoot.materialized( - seedContext: context, - ctx: &ctx, - create: { context, ctx in - AnyReconcilable(Wrapped._makeNode(view, context: context, ctx: &ctx)) - } + return .init( + a: view, + context: context, + ctx: &ctx ) - return .init(aRoot: aRoot, context: context) case .none: - let bRoot = MountRoot.materialized( - seedContext: context, - ctx: &ctx, - create: { _, _ in AnyReconcilable(_EmptyNode()) } + return .init( + b: EmptyHTML(), + context: context, + ctx: &ctx ) - return .init(bRoot: bRoot, context: context) } } diff --git a/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift b/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift index d6fabcc..261ecb2 100644 --- a/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift +++ b/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift @@ -9,33 +9,41 @@ public struct _ConditionalNode { private var state: State private var context: _ViewContext - init(aRoot: MountRoot? = nil, bRoot: MountRoot? = nil, context: borrowing _ViewContext) { - switch (aRoot, bRoot) { - case (let .some(a), nil): - self.state = .a(a) - case (nil, let .some(b)): - self.state = .b(b) - default: - preconditionFailure("either aRoot or bRoot must be provided") - } - + private init(state: State, context: borrowing _ViewContext) { + self.state = state self.context = copy context } - init(a: consuming AnyReconcilable? = nil, b: consuming AnyReconcilable? = nil, context: borrowing _ViewContext) { - self.init( - aRoot: a.map { MountRoot.mounted($0) }, - bRoot: b.map { MountRoot.mounted($0) }, - context: context + init( + a view: A, + context: borrowing _ViewContext, + ctx: inout _CommitContext + ) { + let root = MountRoot( + mountedFrom: context, + transaction: context.mountRoot.inheritedTransaction(), + ctx: &ctx, + create: { context, ctx in + AnyReconcilable(A._makeNode(view, context: context, ctx: &ctx)) + } ) + self.init(state: .a(root), context: context) } - init(a: consuming some _Reconcilable, context: borrowing _ViewContext) { - self.init(a: AnyReconcilable(a), context: context) - } - - init(b: consuming some _Reconcilable, context: borrowing _ViewContext) { - self.init(b: AnyReconcilable(b), context: context) + init( + b view: B, + context: borrowing _ViewContext, + ctx: inout _CommitContext + ) { + let root = MountRoot( + mountedFrom: context, + transaction: context.mountRoot.inheritedTransaction(), + ctx: &ctx, + create: { context, ctx in + AnyReconcilable(B._makeNode(view, context: context, ctx: &ctx)) + } + ) + self.init(state: .b(root), context: context) } mutating func patchWithA( @@ -48,14 +56,13 @@ public struct _ConditionalNode { patchActiveRoot( a, tx: &tx, - makeNode: makeNode, updateNode: updateNode ) state = .a(a) case .b(let b): context.parentElement?.reportChangedChildren(.elementAdded, tx: &tx) let a = makePendingRoot(transaction: tx.transaction, makeNode: makeNode) - scheduleMaterialization([a], tx: &tx) + schedulePendingMount([a], tx: &tx) b.apply(.startRemoval, &tx) context.parentElement?.reportChangedChildren(.elementMoved, tx: &tx) @@ -64,7 +71,6 @@ public struct _ConditionalNode { patchActiveRoot( a, tx: &tx, - makeNode: makeNode, updateNode: updateNode ) state = .aWithBLeaving(a, b) @@ -72,7 +78,6 @@ public struct _ConditionalNode { patchActiveRoot( a, tx: &tx, - makeNode: makeNode, updateNode: updateNode ) a.apply(.cancelRemoval, &tx) @@ -92,14 +97,13 @@ public struct _ConditionalNode { patchActiveRoot( b, tx: &tx, - makeNode: makeNode, updateNode: updateNode ) state = .b(b) case .a(let a): context.parentElement?.reportChangedChildren(.elementAdded, tx: &tx) let b = makePendingRoot(transaction: tx.transaction, makeNode: makeNode) - scheduleMaterialization([b], tx: &tx) + schedulePendingMount([b], tx: &tx) a.apply(.startRemoval, &tx) context.parentElement?.reportChangedChildren(.elementMoved, tx: &tx) @@ -108,7 +112,6 @@ public struct _ConditionalNode { patchActiveRoot( b, tx: &tx, - makeNode: makeNode, updateNode: updateNode ) state = .bWithALeaving(b, a) @@ -116,7 +119,6 @@ public struct _ConditionalNode { patchActiveRoot( b, tx: &tx, - makeNode: makeNode, updateNode: updateNode ) state = .bWithALeaving(b, a) @@ -127,8 +129,8 @@ public struct _ConditionalNode { transaction: Transaction, makeNode: @escaping (borrowing _ViewContext, inout _CommitContext) -> Node ) -> MountRoot { - MountRoot.pending( - seedContext: context, + MountRoot( + pending: context, transaction: transaction, transitionPhase: .willAppear, create: { viewContext, ctx in @@ -140,20 +142,9 @@ public struct _ConditionalNode { private mutating func patchActiveRoot( _ root: MountRoot, tx: inout _TransactionContext, - makeNode: @escaping (borrowing _ViewContext, inout _CommitContext) -> Node, updateNode: (inout Node, inout _TransactionContext) -> Void ) { - if root.isPending { - root.updatePendingCreate( - seedContext: context, - transaction: tx.transaction, - create: { viewContext, ctx in - AnyReconcilable(makeNode(viewContext, &ctx)) - } - ) - scheduleMaterialization([root], tx: &tx) - return - } + precondition(!root.isPending, "double patch of pending MountRoot in _ConditionalNode") let patched = root.withMountedNode(as: Node.self) { node in updateNode(&node, &tx) @@ -161,12 +152,12 @@ public struct _ConditionalNode { precondition(patched, "expected mounted conditional branch") } - private func scheduleMaterialization(_ roots: [MountRoot], tx: inout _TransactionContext) { + private func schedulePendingMount(_ roots: [MountRoot], tx: inout _TransactionContext) { guard !roots.isEmpty else { return } tx.scheduler.addCommitAction { ctx in for root in roots { - root.materialize(&ctx) + root.mount(&ctx) } } } diff --git a/Sources/ElementaryUI/StructureViews/_ForEachNode.swift b/Sources/ElementaryUI/StructureViews/_ForEachNode.swift index 0b640d0..7a6fdf4 100644 --- a/Sources/ElementaryUI/StructureViews/_ForEachNode.swift +++ b/Sources/ElementaryUI/StructureViews/_ForEachNode.swift @@ -99,22 +99,14 @@ where Data: Collection, Content: _KeyReadableView, Content.Value: _Mountable { self.trackingSession = session - var children: [MountRoot] = [] - children.reserveCapacity(views.count) - - for view in views { - children.append( - .materialized( - seedContext: context, - ctx: &ctx, - create: { context, ctx in - AnyReconcilable(Content.Value._makeNode(view._value, context: context, ctx: &ctx)) - } - ) - ) - } - - keyedNode = _KeyedNode(keys: keys, children: children, context: context) + keyedNode = _KeyedNode( + keys: keys, + context: context, + ctx: &ctx, + makeNode: { index, context, ctx in + Content.Value._makeNode(views[index]._value, context: context, ctx: &ctx) + } + ) } public func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { diff --git a/Sources/ElementaryUI/StructureViews/_HTMLArray+Mountable.swift b/Sources/ElementaryUI/StructureViews/_HTMLArray+Mountable.swift index b1fcf84..00982a3 100644 --- a/Sources/ElementaryUI/StructureViews/_HTMLArray+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/_HTMLArray+Mountable.swift @@ -7,24 +7,21 @@ extension _HTMLArray: _Mountable, View where Element: View { ctx: inout _CommitContext ) -> _MountedNode { var keys: [_ViewKey] = [] - var children: [MountRoot] = [] let estimatedCount = view.value.underestimatedCount keys.reserveCapacity(estimatedCount) - children.reserveCapacity(estimatedCount) - for (index, element) in view.value.enumerated() { + for (index, _) in view.value.enumerated() { keys.append(_ViewKey(String(index))) - let root = MountRoot.materialized( - seedContext: context, - ctx: &ctx, - create: { context, ctx in - AnyReconcilable(Element._makeNode(element, context: context, ctx: &ctx)) - } - ) - children.append(root) } - return _MountedNode(keys: keys, children: children, context: context) + return _MountedNode( + keys: keys, + context: context, + ctx: &ctx, + makeNode: { index, context, ctx in + Element._makeNode(view.value[index], context: context, ctx: &ctx) + } + ) } public static func _patchNode( diff --git a/Sources/ElementaryUI/StructureViews/_HTMLConditional+Mountable.swift b/Sources/ElementaryUI/StructureViews/_HTMLConditional+Mountable.swift index 3c61d22..7054144 100644 --- a/Sources/ElementaryUI/StructureViews/_HTMLConditional+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/_HTMLConditional+Mountable.swift @@ -9,23 +9,17 @@ extension _HTMLConditional: _Mountable where TrueContent: _Mountable, FalseConte ) -> _MountedNode { switch view.value { case let .trueContent(content): - let aRoot = MountRoot.materialized( - seedContext: context, - ctx: &ctx, - create: { context, ctx in - AnyReconcilable(TrueContent._makeNode(content, context: context, ctx: &ctx)) - } + return .init( + a: content, + context: context, + ctx: &ctx ) - return .init(aRoot: aRoot, context: context) case let .falseContent(content): - let bRoot = MountRoot.materialized( - seedContext: context, - ctx: &ctx, - create: { context, ctx in - AnyReconcilable(FalseContent._makeNode(content, context: context, ctx: &ctx)) - } + return .init( + b: content, + context: context, + ctx: &ctx ) - return .init(bRoot: bRoot, context: context) } } diff --git a/Sources/ElementaryUI/StructureViews/_KeyedNode.swift b/Sources/ElementaryUI/StructureViews/_KeyedNode.swift index 0f17b98..74f4d67 100644 --- a/Sources/ElementaryUI/StructureViews/_KeyedNode.swift +++ b/Sources/ElementaryUI/StructureViews/_KeyedNode.swift @@ -11,10 +11,49 @@ public struct _KeyedNode { self.viewContext = copy context } + init( + keys: [_ViewKey], + context: borrowing _ViewContext, + ctx: inout _CommitContext, + makeNode: (Int, borrowing _ViewContext, inout _CommitContext) -> Node + ) { + self.keys = keys + self.viewContext = copy context + self.children = [] + self.children.reserveCapacity(keys.count) + + let transaction = context.mountRoot.inheritedTransaction() + for index in keys.indices { + let root = MountRoot( + mountedFrom: context, + transaction: transaction, + ctx: &ctx, + create: { context, ctx in + AnyReconcilable(makeNode(index, context, &ctx)) + } + ) + self.children.append(root) + } + } + + init( + key: _ViewKey, + context: borrowing _ViewContext, + ctx: inout _CommitContext, + makeNode: (borrowing _ViewContext, inout _CommitContext) -> Node + ) { + self.init( + keys: [key], + context: context, + ctx: &ctx, + makeNode: { _, context, ctx in makeNode(context, &ctx) } + ) + } + init(_ value: some Sequence<(key: _ViewKey, node: some _Reconcilable)>, context: borrowing _ViewContext) { self.init( keys: value.map { $0.key }, - children: value.map { MountRoot.mounted(AnyReconcilable($0.node)) }, + children: value.map { MountRoot(mounted: AnyReconcilable($0.node)) }, context: context ) } @@ -45,13 +84,15 @@ public struct _KeyedNode { makeNode: @escaping (Int, borrowing _ViewContext, inout _CommitContext) -> Node, patchNode: (Int, inout Node, inout _TransactionContext) -> Void ) { + assertNoPendingRoots() + guard !newKeys.isEmpty else { fastRemoveAll(context: &context) return } let newKeysArray = Array(newKeys) - var pendingMaterializations: [MountRoot] = [] + var pendingMounts: [MountRoot] = [] guard !(keys.isEmpty && leavingChildren.entries.isEmpty) else { keys = newKeysArray @@ -60,8 +101,8 @@ public struct _KeyedNode { for index in keys.indices { viewContext.parentElement?.reportChangedChildren(.elementAdded, tx: &context) - let root = MountRoot.pending( - seedContext: viewContext, + let root = MountRoot( + pending: viewContext, transaction: context.transaction, transitionPhase: .willAppear, create: { viewContext, ctx in @@ -69,28 +110,17 @@ public struct _KeyedNode { } ) children.append(root) - pendingMaterializations.append(root) + pendingMounts.append(root) } - scheduleMaterializationIfNeeded(&pendingMaterializations, context: &context) + schedulePendingMountIfNeeded(&pendingMounts, context: &context) return } if leavingChildren.entries.isEmpty, keys.count == newKeysArray.count, keys.elementsEqual(newKeysArray) { for index in children.indices { let child = children[index] - - if child.isPending { - child.updatePendingCreate( - seedContext: viewContext, - transaction: context.transaction, - create: { viewContext, ctx in - AnyReconcilable(makeNode(index, viewContext, &ctx)) - } - ) - pendingMaterializations.append(child) - continue - } + precondition(!child.isPending, "double patch of pending MountRoot in keyed stable-patch path") let patched = child.withMountedNode(as: Node.self) { node in patchNode(index, &node, &context) @@ -98,7 +128,7 @@ public struct _KeyedNode { precondition(patched, "expected mounted child during stable keyed patch") } - scheduleMaterializationIfNeeded(&pendingMaterializations, context: &context) + schedulePendingMountIfNeeded(&pendingMounts, context: &context) return } @@ -132,14 +162,15 @@ public struct _KeyedNode { root = moved } else { viewContext.parentElement?.reportChangedChildren(.elementAdded, tx: &context) - root = MountRoot.pending( - seedContext: viewContext, + root = MountRoot( + pending: viewContext, transaction: context.transaction, transitionPhase: .willAppear, create: { viewContext, ctx in AnyReconcilable(makeNode(offset, viewContext, &ctx)) } ) + pendingMounts.append(root) } children.insert(root, at: offset) @@ -152,16 +183,9 @@ public struct _KeyedNode { for index in children.indices { let child = children[index] - if child.isPending { - child.updatePendingCreate( - seedContext: viewContext, - transaction: context.transaction, - create: { viewContext, ctx in - AnyReconcilable(makeNode(index, viewContext, &ctx)) - } - ) - pendingMaterializations.append(child) + // Newly inserted roots in this pass are mounted in commit and + // intentionally not patched before mount. continue } @@ -171,7 +195,7 @@ public struct _KeyedNode { precondition(patched, "expected mounted child during keyed patch") } - scheduleMaterializationIfNeeded(&pendingMaterializations, context: &context) + schedulePendingMountIfNeeded(&pendingMounts, context: &context) } mutating func fastRemoveAll(context: inout _TransactionContext) { @@ -187,19 +211,28 @@ public struct _KeyedNode { children.removeAll() } - private mutating func scheduleMaterializationIfNeeded( - _ pendingMaterializations: inout [MountRoot], + private mutating func schedulePendingMountIfNeeded( + _ pendingMounts: inout [MountRoot], context: inout _TransactionContext ) { - guard !pendingMaterializations.isEmpty else { return } + guard !pendingMounts.isEmpty else { return } - let roots = pendingMaterializations + let roots = pendingMounts context.scheduler.addCommitAction { ctx in for root in roots { - root.materialize(&ctx) + root.mount(&ctx) } } - pendingMaterializations.removeAll(keepingCapacity: true) + pendingMounts.removeAll(keepingCapacity: true) + } + + private func assertNoPendingRoots() { + let hasPendingChildren = children.contains { $0.isPending } + let hasPendingLeaving = leavingChildren.entries.contains { $0.value.isPending } + precondition( + !hasPendingChildren && !hasPendingLeaving, + "double patch of pending MountRoot in _KeyedNode" + ) } } diff --git a/Sources/ElementaryUIMacros/ViewMacro.swift b/Sources/ElementaryUIMacros/ViewMacro.swift index b96b2c6..a1b01ed 100644 --- a/Sources/ElementaryUIMacros/ViewMacro.swift +++ b/Sources/ElementaryUIMacros/ViewMacro.swift @@ -104,7 +104,7 @@ extension ViewMacro: ExtensionMacro { let extensionDecl: DeclSyntax = """ extension \(raw: type.trimmedDescription): __FunctionView { - //\(access)typealias _MountedNode = _FunctionNode + //\(access)typealias _MountedNode = _FunctionNode \(raw: decls.map { $0.description }.joined(separator: "\n")) } From e6fb8fbbc2794b7b74c2ba6cd4ee60be8da7330f Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:32:59 +0100 Subject: [PATCH 03/17] WIP: simplify mounting --- .../Animation/View+Animation.swift | 2 +- .../Data/Environment/View+Envionment.swift | 2 +- .../Data/Lifecycle/View+LifecycleEvents.swift | 2 +- .../Data/Lifecycle/View+OnChange.swift | 2 +- .../Data/Lifecycle/_StatefulNode.swift | 8 - .../FLIP/FLIPLayoutObserver.swift | 2 +- .../AnimateChildrenView.swift | 2 +- .../ElementModifiers/LayoutObserver.swift | 2 +- .../HTMLViews/HTMLElement+Mountable.swift | 8 +- .../HTMLViews/HTMLText+Mountable.swift | 6 +- .../HTMLViews/HTMLVoidElement+Mountable.swift | 6 +- .../ElementaryUI/HTMLViews/View+Binding.swift | 2 +- .../_AttributedElement+Mountable.swift | 2 +- .../ElementaryUI/HTMLViews/_ElementNode.swift | 244 ++-------------- .../ElementaryUI/HTMLViews/_TextNode.swift | 56 +--- .../Reconciling/ApplicationRuntime.swift | 47 ++-- .../Reconciling/_ContainerLayoutPass.swift | 38 --- .../Reconciling/_Reconcilable.swift | 35 +-- .../Reconciling/_ViewContext.swift | 1 - .../StructureViews/EmptyHTML+Mountable.swift | 2 +- .../StructureViews/ForEach+Mountable.swift | 2 +- .../StructureViews/Group+Mountable.swift | 2 +- .../StructureViews/KeyedView.swift | 4 +- .../StructureViews/MountRoot.swift | 265 +++++++----------- .../StructureViews/Optional+Mountable.swift | 27 +- .../PlaceholderContentView.swift | 19 +- .../StructureViews/Tuples+Mountable.swift | 14 +- .../StructureViews/_ConditionalNode.swift | 188 ++++--------- .../StructureViews/_EmptyNode.swift | 7 +- .../StructureViews/_ForEachNode.swift | 20 +- .../StructureViews/_HTMLArray+Mountable.swift | 4 +- .../_HTMLConditional+Mountable.swift | 27 +- .../StructureViews/_KeyedNode.swift | 114 ++++---- .../StructureViews/_MountContext.swift | 89 ++++++ .../StructureViews/_MountedStructure.swift | 221 +++++++++++++++ .../StructureViews/_TupleNode.swift | 93 +----- .../Transition/_TransitionNode.swift | 14 +- .../Transition/_TransitionView.swift | 2 +- .../Views/Function/_FunctionNode.swift | 11 +- .../Views/Function/_FunctionView.swift | 2 +- .../ElementaryUI/Views/View+Mountable.swift | 4 +- .../Reconciler/DOMMountingTests.swift | 24 +- .../Reconciler/DeinitSniffer.swift | 9 +- .../Reconciler/TransitionMountRootTests.swift | 13 +- 44 files changed, 686 insertions(+), 958 deletions(-) delete mode 100644 Sources/ElementaryUI/Reconciling/_ContainerLayoutPass.swift create mode 100644 Sources/ElementaryUI/StructureViews/_MountContext.swift create mode 100644 Sources/ElementaryUI/StructureViews/_MountedStructure.swift diff --git a/Sources/ElementaryUI/Animation/View+Animation.swift b/Sources/ElementaryUI/Animation/View+Animation.swift index 569a249..167a5e4 100644 --- a/Sources/ElementaryUI/Animation/View+Animation.swift +++ b/Sources/ElementaryUI/Animation/View+Animation.swift @@ -14,7 +14,7 @@ struct _TransactionModifierView: View { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { let node = Wrapped._makeNode(view.view, context: context, ctx: &ctx) return .init(state: .init(value: view.value), child: node) diff --git a/Sources/ElementaryUI/Data/Environment/View+Envionment.swift b/Sources/ElementaryUI/Data/Environment/View+Envionment.swift index cb95e4c..83f91b9 100644 --- a/Sources/ElementaryUI/Data/Environment/View+Envionment.swift +++ b/Sources/ElementaryUI/Data/Environment/View+Envionment.swift @@ -132,7 +132,7 @@ public struct _EnvironmentView: View { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { var context = copy context let box = EnvironmentValues._Box(view.value) diff --git a/Sources/ElementaryUI/Data/Lifecycle/View+LifecycleEvents.swift b/Sources/ElementaryUI/Data/Lifecycle/View+LifecycleEvents.swift index 4d66cc5..e5ef085 100644 --- a/Sources/ElementaryUI/Data/Lifecycle/View+LifecycleEvents.swift +++ b/Sources/ElementaryUI/Data/Lifecycle/View+LifecycleEvents.swift @@ -131,7 +131,7 @@ struct _LifecycleEventView: View { static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { let state = LifecycleState(hook: view.listener, scheduler: ctx.scheduler) let child = Wrapped._makeNode(view.wrapped, context: context, ctx: &ctx) diff --git a/Sources/ElementaryUI/Data/Lifecycle/View+OnChange.swift b/Sources/ElementaryUI/Data/Lifecycle/View+OnChange.swift index 9b8cfa4..280c3f0 100644 --- a/Sources/ElementaryUI/Data/Lifecycle/View+OnChange.swift +++ b/Sources/ElementaryUI/Data/Lifecycle/View+OnChange.swift @@ -52,7 +52,7 @@ struct _OnChangeView: View { static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { let state = State(value: view.value, action: view.action) let child = Wrapped._makeNode(view.wrapped, context: context, ctx: &ctx) diff --git a/Sources/ElementaryUI/Data/Lifecycle/_StatefulNode.swift b/Sources/ElementaryUI/Data/Lifecycle/_StatefulNode.swift index 6272b29..1ae47d3 100644 --- a/Sources/ElementaryUI/Data/Lifecycle/_StatefulNode.swift +++ b/Sources/ElementaryUI/Data/Lifecycle/_StatefulNode.swift @@ -20,14 +20,6 @@ public struct _StatefulNode { } extension _StatefulNode: _Reconcilable { - public mutating func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { - child.collectChildren(&ops, &context) - } - - public mutating func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { - child.apply(op, &tx) - } - public consuming func unmount(_ context: inout _CommitContext) { child.unmount(&context) onUnmount?(&context) diff --git a/Sources/ElementaryUI/FLIP/FLIPLayoutObserver.swift b/Sources/ElementaryUI/FLIP/FLIPLayoutObserver.swift index 7e306c7..91df383 100644 --- a/Sources/ElementaryUI/FLIP/FLIPLayoutObserver.swift +++ b/Sources/ElementaryUI/FLIP/FLIPLayoutObserver.swift @@ -32,7 +32,7 @@ final class FLIPLayoutObserver: DOMLayoutObserver { } } - func didLayoutChildren(parent: DOM.Node, entries: [_ContainerLayoutPass.Entry], context: inout _CommitContext) { + func didLayoutChildren(parent: DOM.Node, entries: [LayoutPass.Entry], context: inout _CommitContext) { childNodes.removeAll(keepingCapacity: true) childNodes.reserveCapacity(entries.count) diff --git a/Sources/ElementaryUI/HTMLViews/ElementModifiers/AnimateChildrenView.swift b/Sources/ElementaryUI/HTMLViews/ElementModifiers/AnimateChildrenView.swift index 2d386e1..72ca3d0 100644 --- a/Sources/ElementaryUI/HTMLViews/ElementModifiers/AnimateChildrenView.swift +++ b/Sources/ElementaryUI/HTMLViews/ElementModifiers/AnimateChildrenView.swift @@ -8,7 +8,7 @@ struct AnimateContainerLayoutView: View { static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { let observer = FLIPLayoutObserver( diff --git a/Sources/ElementaryUI/HTMLViews/ElementModifiers/LayoutObserver.swift b/Sources/ElementaryUI/HTMLViews/ElementModifiers/LayoutObserver.swift index a0db65d..5243782 100644 --- a/Sources/ElementaryUI/HTMLViews/ElementModifiers/LayoutObserver.swift +++ b/Sources/ElementaryUI/HTMLViews/ElementModifiers/LayoutObserver.swift @@ -1,7 +1,7 @@ protocol DOMLayoutObserver: Unmountable { func willLayoutChildren(parent: DOM.Node, context: inout _TransactionContext) func setLeaveStatus(_ node: DOM.Node, isLeaving: Bool, context: inout _TransactionContext) - func didLayoutChildren(parent: DOM.Node, entries: [_ContainerLayoutPass.Entry], context: inout _CommitContext) + func didLayoutChildren(parent: DOM.Node, entries: [LayoutPass.Entry], context: inout _CommitContext) } struct DOMLayoutObservers { diff --git a/Sources/ElementaryUI/HTMLViews/HTMLElement+Mountable.swift b/Sources/ElementaryUI/HTMLViews/HTMLElement+Mountable.swift index 92fe572..f5ece0b 100644 --- a/Sources/ElementaryUI/HTMLViews/HTMLElement+Mountable.swift +++ b/Sources/ElementaryUI/HTMLViews/HTMLElement+Mountable.swift @@ -1,10 +1,10 @@ extension HTMLElement: _Mountable, View where Content: _Mountable { - public typealias _MountedNode = _StatefulNode<_AttributeModifier, _ElementNode> + public typealias _MountedNode = _StatefulNode<_AttributeModifier, _ElementNode> public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { let attributeModifier = _AttributeModifier(value: view._attributes, upstream: context.modifiers) @@ -17,7 +17,7 @@ extension HTMLElement: _Mountable, View where Content: _Mountable { tag: self.Tag.name, viewContext: context, ctx: &ctx, - makeChild: { viewContext, c in AnyReconcilable(Content._makeNode(view.content, context: viewContext, ctx: &c)) } + makeChild: { viewContext, c in Content._makeNode(view.content, context: viewContext, ctx: &c) } ) ) } @@ -29,7 +29,7 @@ extension HTMLElement: _Mountable, View where Content: _Mountable { ) { node.state.updateValue(view._attributes, &tx) - node.child.updateChild(&tx, as: Content._MountedNode.self) { child, r in + node.child.updateChild(&tx) { child, r in Content._patchNode( view.content, node: &child, diff --git a/Sources/ElementaryUI/HTMLViews/HTMLText+Mountable.swift b/Sources/ElementaryUI/HTMLViews/HTMLText+Mountable.swift index 711229c..2cead5e 100644 --- a/Sources/ElementaryUI/HTMLViews/HTMLText+Mountable.swift +++ b/Sources/ElementaryUI/HTMLViews/HTMLText+Mountable.swift @@ -4,9 +4,9 @@ extension HTMLText: _Mountable, View { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { - _MountedNode(view.text, viewContext: context, context: &ctx) + _MountedNode(view.text, ctx: &ctx) } public static func _patchNode( @@ -14,6 +14,6 @@ extension HTMLText: _Mountable, View { node: inout _MountedNode, tx: inout _TransactionContext ) { - node.patch(view.text, context: &tx) + node.patch(view.text, tx: &tx) } } diff --git a/Sources/ElementaryUI/HTMLViews/HTMLVoidElement+Mountable.swift b/Sources/ElementaryUI/HTMLViews/HTMLVoidElement+Mountable.swift index b4046b9..e8bd850 100644 --- a/Sources/ElementaryUI/HTMLViews/HTMLVoidElement+Mountable.swift +++ b/Sources/ElementaryUI/HTMLViews/HTMLVoidElement+Mountable.swift @@ -1,10 +1,10 @@ extension HTMLVoidElement: _Mountable, View { - public typealias _MountedNode = _StatefulNode<_AttributeModifier, _ElementNode> + public typealias _MountedNode = _StatefulNode<_AttributeModifier, _ElementNode<_EmptyNode>> public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { let attributeModifier = _AttributeModifier(value: view._attributes, upstream: context.modifiers) @@ -17,7 +17,7 @@ extension HTMLVoidElement: _Mountable, View { tag: self.Tag.name, viewContext: context, ctx: &ctx, - makeChild: { _, _ in AnyReconcilable(_EmptyNode()) } + makeChild: { _, _ in _EmptyNode() } ) ) } diff --git a/Sources/ElementaryUI/HTMLViews/View+Binding.swift b/Sources/ElementaryUI/HTMLViews/View+Binding.swift index 6ef19a8..8dd4df6 100644 --- a/Sources/ElementaryUI/HTMLViews/View+Binding.swift +++ b/Sources/ElementaryUI/HTMLViews/View+Binding.swift @@ -91,7 +91,7 @@ struct DOMEffectView: View { static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { let effect = Effect(value: view.value, upstream: context.modifiers) diff --git a/Sources/ElementaryUI/HTMLViews/_AttributedElement+Mountable.swift b/Sources/ElementaryUI/HTMLViews/_AttributedElement+Mountable.swift index 4cb8bc5..455aa14 100644 --- a/Sources/ElementaryUI/HTMLViews/_AttributedElement+Mountable.swift +++ b/Sources/ElementaryUI/HTMLViews/_AttributedElement+Mountable.swift @@ -4,7 +4,7 @@ extension _AttributedElement: _Mountable, View where Content: _Mountable { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { let attributeModifier = _AttributeModifier(value: view.attributes, upstream: context.modifiers) diff --git a/Sources/ElementaryUI/HTMLViews/_ElementNode.swift b/Sources/ElementaryUI/HTMLViews/_ElementNode.swift index 3f97834..767b311 100644 --- a/Sources/ElementaryUI/HTMLViews/_ElementNode.swift +++ b/Sources/ElementaryUI/HTMLViews/_ElementNode.swift @@ -1,238 +1,48 @@ -public final class _ElementNode: _Reconcilable { - var identifier: String = "" - var child: AnyReconcilable! - - var domNode: ManagedDOMReference? - var mountedModifieres: [AnyUnmountable]? - var layoutObservers: [any DOMLayoutObserver] = [] - - var childrenLayoutStatus: ChildrenLayoutStatus = .init() - - struct ChildrenLayoutStatus { - var isDirty = false - var count: Int = 0 - } - - private(set) var parentNode: _ElementNode? +public struct _ElementNode: _Reconcilable { + private var child: Child + private var mountedModifiers: [AnyUnmountable]? init( tag: String, viewContext: borrowing _ViewContext, - ctx: inout _CommitContext, - makeChild: (borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable + ctx: inout _MountContext, + makeChild: (borrowing _ViewContext, inout _MountContext) -> Child ) { - precondition(viewContext.parentElement != nil, "parent element must be set") - self.parentNode = viewContext.parentElement - self.identifier = "\(tag):\(ObjectIdentifier(self))" + let domNode = ctx.dom.createElement(tag) - logTrace("created element \(identifier) in \(viewContext.parentElement!.identifier)") - viewContext.parentElement!.reportChangedChildren(.elementAdded, ctx: &ctx) - - var viewContext = copy viewContext - viewContext.parentElement = self - let modifiers = viewContext.takeModifiers() - self.layoutObservers = viewContext.takeLayoutObservers() - - precondition(self.domNode == nil, "element already has a DOM node") - let ref = ctx.dom.createElement(tag) - self.domNode = ManagedDOMReference(reference: ref, status: .added) - - self.mountedModifieres = modifiers.reversed().map { - $0.mount(ref, &ctx) - } - - self.child = makeChild(viewContext, &ctx) - } - - init( - root: DOM.Node, - viewContext: consuming _ViewContext, - ctx: inout _CommitContext, - makeChild: (borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable - ) { - self.domNode = .init(reference: root, status: .unchanged) - self.identifier = "\("_root_"):\(ObjectIdentifier(self))" - - var viewContext = viewContext - let layoutObservers = viewContext.layoutObservers.take() - viewContext.parentElement = self - - if !layoutObservers.isEmpty { - self.layoutObservers = layoutObservers - } + var childContext = copy viewContext + let modifiers = childContext.takeModifiers() + let layoutObservers = childContext.takeLayoutObservers() - self.child = makeChild(viewContext, &ctx) - } - - func updateChild( - _ context: inout _TransactionContext, - as: Node.Type = Node.self, - block: (inout Node, inout _TransactionContext) -> Void - ) { - child.modify(as: Node.self) { node in - block(&node, &context) - } - } - - func reportChangedChildren(_ change: ElementNodeChildrenChange, tx: inout _TransactionContext) { - // TODO: count needed storage for children - // TODO: optimize for changes that do not require children re-run (leaving and re-entering nodes) - - if !childrenLayoutStatus.isDirty { - childrenLayoutStatus.isDirty = true - tx.scheduler.addPlacementAction(performLayout(_:)) - - if let ref = domNode?.reference { - for observer in layoutObservers { - observer.willLayoutChildren(parent: ref, context: &tx) + self.mountedModifiers = + ctx.withCommitContext { commit in + modifiers.reversed().map { modifier in + modifier.mount(domNode, &commit) } } - } - switch change { - case let .elementLeaving(node): - for observer in layoutObservers { - observer.setLeaveStatus(node, isLeaving: true, context: &tx) - } - case let .elementReentered(node): - for observer in layoutObservers { - observer.setLeaveStatus(node, isLeaving: false, context: &tx) - } - default: - break - } - } + ctx.appendStaticElement(domNode) - func reportChangedChildren(_ change: ElementNodeChildrenChange, ctx: inout _CommitContext) { - if change.requiresChildrenUpdate, !childrenLayoutStatus.isDirty { - childrenLayoutStatus.isDirty = true - ctx.scheduler.addPlacementAction(performLayout(_:)) + self.child = ctx.withChildContext { (mctx: consuming _MountContext) in + let child = makeChild(childContext, &mctx) + _ = mctx.mountInDOMNode(domNode, observers: layoutObservers) //NOTE: maybe hold on to the container? + return child } } - public func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { - assert(domNode != nil, "unitialized element in layout pass") - self.domNode?.collectLayoutChanges(&ops, type: .element) - } - - public func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { - switch op { - case .startRemoval: - assert(domNode != nil, "unitialized element in startRemoval") - domNode?.status = .removed - parentNode?.reportChangedChildren(.elementRemoved, tx: &tx) - case .cancelRemoval: - if domNode?.status == .removed { - domNode?.status = .moved - parentNode?.reportChangedChildren(.elementAdded, tx: &tx) - } else { - guard let node = domNode?.reference else { - assertionFailure("unitialized element in cancelRemoval") - return - } - parentNode?.reportChangedChildren(.elementReentered(node), tx: &tx) - } - case .markAsMoved: - assert(domNode != nil, "unitialized element in markAsMoved") - domNode?.status = .moved - parentNode?.reportChangedChildren(.elementMoved, tx: &tx) - case .markAsLeaving: - guard let node = domNode?.reference else { - assertionFailure("unitialized element in markAsLeaving") - return - } - parentNode?.reportChangedChildren(.elementLeaving(node), tx: &tx) - } + mutating func updateChild( + _ context: inout _TransactionContext, + block: (inout Child, inout _TransactionContext) -> Void + ) { + block(&child, &context) } - public func unmount(_ context: inout _CommitContext) { - let c = self.child.take()! - c.unmount(&context) + public consuming func unmount(_ context: inout _CommitContext) { + child.unmount(&context) - for modifier in mountedModifieres ?? [] { + for modifier in mountedModifiers ?? [] { modifier.unmount(&context) } - self.mountedModifieres = nil - - for observer in layoutObservers { - observer.unmount(&context) - } - self.layoutObservers = [] - - self.domNode = nil - self.parentNode = nil - } - - func performLayout(_ context: inout _CommitContext) { - guard let ref = domNode?.reference else { - preconditionFailure("unitialized element in commitChanges - maybe this can be fine?") - } - - guard childrenLayoutStatus.isDirty else { - assertionFailure("layout triggered on non-dirty node") - return - } - childrenLayoutStatus.isDirty = false - - var ops = _ContainerLayoutPass() // TODO: initialize with count, could be allocationlessly somehow - child!.collectChildren(&ops, &context) - - if ops.canBatchReplace { - if ops.isAllRemovals { - context.dom.replaceChildren([], in: ref) - } else if ops.isAllAdditions { - context.dom.replaceChildren(ops.entries.map { $0.reference }, in: ref) - } else { - fatalError("cannot batch replace children of \(ref) because it is not all removals or all additions") - } - } else { - var sibling: DOM.Node? - - for entry in ops.entries.reversed() { - switch entry.kind { - case .added, .moved: - context.dom.insertChild(entry.reference, before: sibling, in: ref) - sibling = entry.reference - case .removed: - context.dom.removeChild(entry.reference, from: ref) - case .unchanged: - sibling = entry.reference - break - } - } - } - - for observer in layoutObservers { - observer.didLayoutChildren(parent: ref, entries: ops.entries, context: &context) - } - } -} - -enum ElementNodeChildrenChange { - case elementAdded - case elementMoved - case elementRemoved - case elementLeaving(DOM.Node) - case elementReentered(DOM.Node) - - var requiresChildrenUpdate: Bool { - switch self { - case .elementAdded, .elementMoved, .elementRemoved: - true - case .elementLeaving, .elementReentered: - false - } - } -} - -struct ManagedDOMReference: ~Copyable { - let reference: DOM.Node - var status: _ContainerLayoutPass.Entry.Status -} - -extension ManagedDOMReference { - mutating func collectLayoutChanges(_ ops: inout _ContainerLayoutPass, type: _ContainerLayoutPass.Entry.NodeType) { - ops.append(.init(kind: status, reference: reference, type: type)) - self.status = .unchanged + mountedModifiers = nil } } diff --git a/Sources/ElementaryUI/HTMLViews/_TextNode.swift b/Sources/ElementaryUI/HTMLViews/_TextNode.swift index e99ed39..d95f2a1 100644 --- a/Sources/ElementaryUI/HTMLViews/_TextNode.swift +++ b/Sources/ElementaryUI/HTMLViews/_TextNode.swift @@ -1,58 +1,22 @@ -public final class _TextNode: _Reconcilable { +public struct _TextNode: _Reconcilable { + let domNode: DOM.Node var value: String - var domNode: ManagedDOMReference? - var isDirty: Bool = false - var parentElement: _ElementNode? - init(_ newValue: String, viewContext: borrowing _ViewContext, context: inout _CommitContext) { + init(_ newValue: String, ctx: inout _MountContext) { self.value = newValue - self.domNode = nil - self.parentElement = viewContext.parentElement - - self.parentElement?.reportChangedChildren(.elementAdded, ctx: &context) - self.domNode = ManagedDOMReference(reference: context.dom.createText(newValue), status: .added) + self.domNode = ctx.dom.createText(newValue) + ctx.appendStaticText(self.domNode) } - func patch(_ newValue: String, context: inout _TransactionContext) { - let needsUpdate = !isDirty && !value.utf8Equals(newValue) + mutating func patch(_ newValue: String, tx: inout _TransactionContext) { + guard !value.utf8Equals(newValue) else { return } self.value = newValue - guard needsUpdate else { return } - - isDirty = true - context.scheduler.addCommitAction { [self] context in - assert(isDirty, "text node is not dirty") - assert(domNode != nil, "text node is not mounted") - - (domNode?.reference).map { context.dom.patchText($0, with: value) } - self.isDirty = false - } - } - - public func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { - assert(domNode != nil, "unitialized text node in layout pass") - domNode?.collectLayoutChanges(&ops, type: .text) - } - - public func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { - switch op { - case .startRemoval: - domNode?.status = .removed - self.parentElement?.reportChangedChildren(.elementRemoved, tx: &tx) - case .markAsMoved: - domNode?.status = .moved - self.parentElement?.reportChangedChildren(.elementMoved, tx: &tx) - case .cancelRemoval: - // a text node can not leave and re-enter - break - case .markAsLeaving: - // no-op - break + tx.scheduler.addCommitAction { [self] ctx in + ctx.dom.patchText(domNode, with: value) } } - public func unmount(_ context: inout _CommitContext) { - self.domNode = nil - self.parentElement = nil + public consuming func unmount(_ context: inout _CommitContext) { } } diff --git a/Sources/ElementaryUI/Reconciling/ApplicationRuntime.swift b/Sources/ElementaryUI/Reconciling/ApplicationRuntime.swift index 2e51e92..d67989c 100644 --- a/Sources/ElementaryUI/Reconciling/ApplicationRuntime.swift +++ b/Sources/ElementaryUI/Reconciling/ApplicationRuntime.swift @@ -1,12 +1,12 @@ // TODO: rethink this whole API - maybe once usage of async is clearer // TODO: main-actor stuff very unclear at the moment, ideally not needed at all final class ApplicationRuntime { - private var rootNode: _ElementNode? + private var rootChild: AnyReconcilable? + private var rootContainer: LayoutContainer? private var scheduler: Scheduler init(dom: DOMInteractor) { self.scheduler = Scheduler(dom: dom) - self.rootNode = nil } // generic initializers must be convenience on final classes for embedded @@ -22,41 +22,42 @@ final class ApplicationRuntime { rootViewContext.mountRoot = MountRoot(mounted: nil, transaction: tx.transaction) tx.scheduler.addCommitAction { [self, rootView, rootViewContext] ctx in - self.rootNode = - _ElementNode( - root: domRoot, - viewContext: rootViewContext, - ctx: &ctx, - makeChild: { [rootView] viewContext, ctx in - AnyReconcilable( - RootView._makeNode( - rootView, - context: viewContext, - ctx: &ctx - ) - ) - } - ) + var mountContext = _MountContext(ctx: ctx) + let child = AnyReconcilable( + RootView._makeNode(rootView, context: rootViewContext, ctx: &mountContext) + ) + let layoutNodes = mountContext.takeLayoutNodes() + + let container = LayoutContainer( + domNode: domRoot, + scheduler: ctx.scheduler, + layoutNodes: layoutNodes, + layoutObservers: [] + ) + container.mountInitial(&ctx) + + self.rootChild = child + self.rootContainer = container } } } } func unmount() { - guard let rootNode else { return } + guard let rootChild, let rootContainer else { return } - scheduler.scheduleUpdate { [rootNode] tx in + scheduler.scheduleUpdate { [rootChild, rootContainer] tx in tx.withModifiedTransaction { $0.disablesAnimation = true } run: { tx in tx.scheduler.addPlacementAction { ctx in - rootNode.unmount(&ctx) + rootContainer.removeAllChildren(&ctx) + rootChild.unmount(&ctx) } - - rootNode.child.apply(.startRemoval, &tx) } } - self.rootNode = nil + self.rootChild = nil + self.rootContainer = nil } } diff --git a/Sources/ElementaryUI/Reconciling/_ContainerLayoutPass.swift b/Sources/ElementaryUI/Reconciling/_ContainerLayoutPass.swift deleted file mode 100644 index a1c1713..0000000 --- a/Sources/ElementaryUI/Reconciling/_ContainerLayoutPass.swift +++ /dev/null @@ -1,38 +0,0 @@ -// TODO: move to a better place, maybe use a span with lifecycle stuff -public struct _ContainerLayoutPass: ~Copyable { - var entries: [Entry] - private(set) var isAllRemovals: Bool = true - private(set) var isAllAdditions: Bool = true - - var canBatchReplace: Bool { - (isAllRemovals || isAllAdditions) && entries.count > 1 - } - - init() { - entries = [] - } - - mutating func append(_ entry: Entry) { - entries.append(entry) - isAllAdditions = isAllAdditions && entry.kind == .added - isAllRemovals = isAllRemovals && entry.kind == .removed - } - - struct Entry { - enum NodeType { - case element - case text - } - - enum Status { - case unchanged - case added - case removed - case moved - } - - let kind: Status - let reference: DOM.Node - let type: NodeType - } -} diff --git a/Sources/ElementaryUI/Reconciling/_Reconcilable.swift b/Sources/ElementaryUI/Reconciling/_Reconcilable.swift index 285b637..9082d37 100644 --- a/Sources/ElementaryUI/Reconciling/_Reconcilable.swift +++ b/Sources/ElementaryUI/Reconciling/_Reconcilable.swift @@ -1,24 +1,11 @@ -// TODO: either get rid of this procol entirely, or at least move the apply/collectChildren stuff somewhere out of this +// TODO: either get rid of this protocol entirely, or turn it into a dedicated +// mount lifecycle owner type. public protocol _Reconcilable { - mutating func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) - - mutating func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) - - // TODO: should this be destroy? consuming func unmount(_ context: inout _CommitContext) } -public enum _ReconcileOp { - case startRemoval - case cancelRemoval - case markAsMoved - case markAsLeaving -} - struct AnyReconcilable { class _Box { - func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) {} - func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) {} func unmount(_ context: inout _CommitContext) {} } @@ -27,14 +14,6 @@ struct AnyReconcilable { init(_ node: consuming R) { self.node = node } - override func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { - node.apply(op, &tx) - } - - override func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { - node.collectChildren(&ops, &context) - } - override func unmount(_ context: inout _CommitContext) { node.unmount(&context) } @@ -46,16 +25,6 @@ struct AnyReconcilable { self.box = _TypedBox(node) } - - // TODO: get rid of all these functions and use environment hooks to participate in whatever each node actually needs - func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { - box.apply(op, &tx) - } - - func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { - box.collectChildren(&ops, &context) - } - func unmount(_ context: inout _CommitContext) { box.unmount(&context) } diff --git a/Sources/ElementaryUI/Reconciling/_ViewContext.swift b/Sources/ElementaryUI/Reconciling/_ViewContext.swift index 4d72487..37a5f42 100644 --- a/Sources/ElementaryUI/Reconciling/_ViewContext.swift +++ b/Sources/ElementaryUI/Reconciling/_ViewContext.swift @@ -6,7 +6,6 @@ public struct _ViewContext { var modifiers: DOMElementModifiers = .init() var layoutObservers: DOMLayoutObservers = .init() var functionDepth: Int = 0 - var parentElement: _ElementNode? var mountRoot: MountRoot = .init(mounted: nil) mutating func takeModifiers() -> [any DOMElementModifier] { diff --git a/Sources/ElementaryUI/StructureViews/EmptyHTML+Mountable.swift b/Sources/ElementaryUI/StructureViews/EmptyHTML+Mountable.swift index 9d8342a..e42124f 100644 --- a/Sources/ElementaryUI/StructureViews/EmptyHTML+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/EmptyHTML+Mountable.swift @@ -4,7 +4,7 @@ extension EmptyHTML: _Mountable, View { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { _EmptyNode() } diff --git a/Sources/ElementaryUI/StructureViews/ForEach+Mountable.swift b/Sources/ElementaryUI/StructureViews/ForEach+Mountable.swift index 9692637..4b7c781 100644 --- a/Sources/ElementaryUI/StructureViews/ForEach+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/ForEach+Mountable.swift @@ -27,7 +27,7 @@ extension ForEach: _Mountable, View where Content: _KeyReadableView, Data: Colle public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { _MountedNode( data: view._data, diff --git a/Sources/ElementaryUI/StructureViews/Group+Mountable.swift b/Sources/ElementaryUI/StructureViews/Group+Mountable.swift index 9eb1363..2d37ca6 100644 --- a/Sources/ElementaryUI/StructureViews/Group+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/Group+Mountable.swift @@ -5,7 +5,7 @@ extension Group: _Mountable where Content: _Mountable { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { Content._makeNode(view.content, context: context, ctx: &ctx) } diff --git a/Sources/ElementaryUI/StructureViews/KeyedView.swift b/Sources/ElementaryUI/StructureViews/KeyedView.swift index 8222f77..61eb79e 100644 --- a/Sources/ElementaryUI/StructureViews/KeyedView.swift +++ b/Sources/ElementaryUI/StructureViews/KeyedView.swift @@ -8,14 +8,14 @@ public struct _KeyedView: View { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { .init( key: view.key, context: context, ctx: &ctx, makeNode: { context, ctx in - Value._makeNode(view.value, context: context, ctx: &ctx) + AnyReconcilable(Value._makeNode(view.value, context: context, ctx: &ctx)) } ) } diff --git a/Sources/ElementaryUI/StructureViews/MountRoot.swift b/Sources/ElementaryUI/StructureViews/MountRoot.swift index 51895fe..13a2407 100644 --- a/Sources/ElementaryUI/StructureViews/MountRoot.swift +++ b/Sources/ElementaryUI/StructureViews/MountRoot.swift @@ -1,10 +1,16 @@ final class MountRoot { + private struct MountedState { + var node: AnyReconcilable? + var layoutNodes: [LayoutNode] + var status: LayoutPass.Entry.Status + } + private enum State { case pending( seedContext: _ViewContext, - create: (borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable + create: (borrowing _ViewContext, inout _MountContext) -> AnyReconcilable ) - case mounted(AnyReconcilable?) + case mounted(MountedState) case unmounted } @@ -14,24 +20,18 @@ final class MountRoot { let isStillMounted: () -> Bool } - nonisolated(unsafe) private static var nextTransitionClaimID: UInt64 = 0 - private var state: State private var transitionSignal: TransitionPhase? var transactionAnimation: Animation? var transactionDisablesAnimation: Bool - private var transitionParticipantClaimID: UInt64? - private var transitionParticipant: TransitionParticipant? - private var pendingEnterAnimation: Animation? - private var removalToken: Double? init( mounted node: consuming AnyReconcilable? = nil, transaction: Transaction? = nil, transitionPhase: TransitionPhase? = nil ) { - self.state = .mounted(node) + self.state = .mounted(.init(node: node, layoutNodes: [], status: .unchanged)) self.transitionSignal = transitionPhase self.transactionAnimation = transaction?.animation self.transactionDisablesAnimation = transaction?.disablesAnimation ?? false @@ -41,7 +41,7 @@ final class MountRoot { pending seedContext: borrowing _ViewContext, transaction: Transaction, transitionPhase: TransitionPhase? = nil, - create: @escaping (borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable + create: @escaping (borrowing _ViewContext, inout _MountContext) -> AnyReconcilable ) { self.state = .pending(seedContext: copy seedContext, create: create) self.transitionSignal = transitionPhase @@ -53,17 +53,23 @@ final class MountRoot { mountedFrom seedContext: borrowing _ViewContext, transaction: Transaction, transitionPhase: TransitionPhase? = nil, - ctx: inout _CommitContext, - create: (borrowing _ViewContext, inout _CommitContext) -> AnyReconcilable + ctx: inout _MountContext, + create: (borrowing _ViewContext, inout _MountContext) -> AnyReconcilable ) { - self.state = .mounted(nil) + self.state = .mounted(.init(node: nil, layoutNodes: [], status: .unchanged)) self.transitionSignal = transitionPhase self.transactionAnimation = transaction.animation self.transactionDisablesAnimation = transaction.disablesAnimation var childContext = copy seedContext childContext.mountRoot = self - self.state = .mounted(create(childContext, &ctx)) + let (node, layoutNodes) = ctx.withCommitContext { commit in + var childMount = _MountContext(ctx: commit) + let node = create(childContext, &childMount) + let layoutNodes = childMount.takeLayoutNodes() + return (node, layoutNodes) + } + self.state = .mounted(.init(node: node, layoutNodes: layoutNodes, status: .unchanged)) } func inheritedTransaction() -> Transaction { @@ -83,107 +89,108 @@ final class MountRoot { var childContext = seedContext childContext.mountRoot = self - state = .mounted(create(childContext, &ctx)) + var mountContext = _MountContext(ctx: ctx) + let node = create(childContext, &mountContext) + let layoutNodes = mountContext.takeLayoutNodes() + state = .mounted(.init(node: node, layoutNodes: layoutNodes, status: .added)) } + // MARK: - Transition compatibility (temporary no-op ownership) + func consumeTransitionPhase(defaultAnimation: Animation?) -> TransitionPhase { + _ = defaultAnimation let signal = transitionSignal transitionSignal = nil - - guard signal == .willAppear else { return signal ?? .identity } - guard let animation = effectiveAnimation(defaultAnimation: defaultAnimation) else { - return .identity - } - - pendingEnterAnimation = animation - return .willAppear + return signal ?? .identity } - func reserveTransitionParticipant() -> UInt64? { - guard transitionParticipant == nil, transitionParticipantClaimID == nil else { return nil } - MountRoot.nextTransitionClaimID &+= 1 - let claimID = MountRoot.nextTransitionClaimID - transitionParticipantClaimID = claimID - return claimID - } + func reserveTransitionParticipant() -> UInt64? { nil } func registerTransitionParticipant( claimID: UInt64, defaultAnimation: Animation?, patchPhase: @escaping (TransitionPhase, inout _TransactionContext) -> Void, isStillMounted: @escaping () -> Bool, - ctx: inout _CommitContext + ctx _: inout _MountContext ) { - guard transitionParticipant == nil, transitionParticipantClaimID == claimID else { return } - transitionParticipantClaimID = nil - transitionParticipant = .init( - patchPhase: patchPhase, - defaultAnimation: defaultAnimation, - isStillMounted: isStillMounted - ) - - guard let enterAnimation = pendingEnterAnimation else { return } - pendingEnterAnimation = nil - - ctx.scheduler.scheduleUpdate { tx in - guard let participant = self.transitionParticipant, participant.isStillMounted() else { return } - tx.withModifiedTransaction( - { - $0.animation = enterAnimation - }, - run: { tx in - participant.patchPhase(.identity, &tx) - } - ) - } + _ = claimID + _ = defaultAnimation + _ = patchPhase + _ = isStillMounted } - func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { + // MARK: - Structure lifecycle + + func startRemoval(_ tx: inout _TransactionContext, handle: LayoutContainer.Handle?) { switch state { case .pending: - applyPending(op) - return + state = .unmounted case .unmounted: - return - case .mounted: break + case .mounted(var mounted): + mounted.status = .removed + for element in mountedElementReferences(mounted.layoutNodes) { + handle?.reportLeavingElement(element, &tx) + } + state = .mounted(mounted) } + } - switch op { - case .startRemoval: - startRemoval(&tx) - case .cancelRemoval: - cancelRemoval(&tx) - case .markAsMoved, .markAsLeaving: - mountedNode?.apply(op, &tx) + func cancelRemoval(_ tx: inout _TransactionContext, handle: LayoutContainer.Handle?) { + guard case .mounted(var mounted) = state else { return } + if mounted.status == .removed { + mounted.status = .moved + for element in mountedElementReferences(mounted.layoutNodes) { + handle?.reportReenteringElement(element, &tx) + } + state = .mounted(mounted) } } - func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { + func markMoved(_ tx: inout _TransactionContext) { + guard case .mounted(var mounted) = state else { return } + mounted.status = .moved + state = .mounted(mounted) + } + + func collect(into ops: inout LayoutPass, _ context: inout _CommitContext) { switch state { - case .mounted(let node): - guard let node else { return } - node.collectChildren(&ops, &context) - case .pending: - preconditionFailure("pending MountRoot reached collectChildren") case .unmounted: return + case .pending: + mount(&context) + collect(into: &ops, &context) + case .mounted(var mounted): + let startIndex = ops.entries.count + for node in mounted.layoutNodes { + node.collect(into: &ops, context: &context) + } + + if mounted.status != .unchanged { + for index in startIndex.. Void ) -> Bool { - guard case .mounted(let node) = state, let node else { return false } + _ = type + guard case .mounted(let mounted) = state, let node = mounted.node else { return false } node.modify(as: Node.self, body) return true } - private func applyPending(_ op: _ReconcileOp) { - switch op { - case .startRemoval: - state = .unmounted - transitionParticipantClaimID = nil - case .cancelRemoval, .markAsMoved, .markAsLeaving: - assertionFailure("apply(\(op)) on pending MountRoot") - } - } - - private func startRemoval(_ tx: inout _TransactionContext) { - guard let participant = transitionParticipant, participant.isStillMounted() else { - mountedNode?.apply(.startRemoval, &tx) - return - } - - guard - let transitionAnimation = effectiveAnimation( - defaultAnimation: participant.defaultAnimation, - transaction: tx.transaction - ) - else { - mountedNode?.apply(.startRemoval, &tx) - return - } - - tx.withModifiedTransaction( - { - $0.animation = transitionAnimation - }, - run: { tx in - mountedNode?.apply(.markAsLeaving, &tx) - participant.patchPhase(.didDisappear, &tx) - } - ) - - removalToken = tx.currentFrameTime - tx.transaction.addAnimationCompletion(criteria: .removed) { [scheduler = tx.scheduler, frameTime = removalToken] in - guard self.removalToken == frameTime else { return } - scheduler.scheduleUpdate { tx in - self.mountedNode?.apply(.startRemoval, &tx) + private func mountedElementReferences(_ layoutNodes: [LayoutNode]) -> [DOM.Node] { + var elements: [DOM.Node] = [] + for node in layoutNodes { + switch node { + case .elementNode(let ref): + elements.append(ref) + case .textNode, .dynamicNode: + break } } + return elements } - - private func cancelRemoval(_ tx: inout _TransactionContext) { - removalToken = nil - mountedNode?.apply(.cancelRemoval, &tx) - - guard let participant = transitionParticipant, participant.isStillMounted() else { return } - participant.patchPhase(.identity, &tx) - } - - private var mountedNode: AnyReconcilable? { - guard case .mounted(let node) = state else { return nil } - return node - } - - private func effectiveAnimation(defaultAnimation: Animation?, transaction: Transaction? = nil) -> Animation? { - let disablesAnimation = transaction?.disablesAnimation ?? transactionDisablesAnimation - guard !disablesAnimation else { return nil } - - return transaction?.animation ?? transactionAnimation ?? defaultAnimation - } -} - -final class _MountRootList { - var entries: [_MountRootEntry] - - init() { - self.entries = [] - } -} - -struct _MountRootEntry { - var nodeContainer: NodeContainer -} - -enum MountedNode { - case mountRoot(_MountRootList) - case domNode(DOM.Node) -} - -struct NodeContainer { - enum Entry { - case staticNode(DOM.Node) - case dynamicNode(any DynamicNode) - } - - let nodes: [Entry] -} - -protocol DynamicNode { - var count: Int { get } - func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) } diff --git a/Sources/ElementaryUI/StructureViews/Optional+Mountable.swift b/Sources/ElementaryUI/StructureViews/Optional+Mountable.swift index 994f542..44debba 100644 --- a/Sources/ElementaryUI/StructureViews/Optional+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/Optional+Mountable.swift @@ -5,21 +5,30 @@ extension Optional: _Mountable where Wrapped: _Mountable { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { + let transaction = context.mountRoot.inheritedTransaction() switch view { case let .some(view): - return .init( - a: view, - context: context, - ctx: &ctx + let root = MountRoot( + mountedFrom: context, + transaction: transaction, + ctx: &ctx, + create: { c, mountCtx in + AnyReconcilable(Wrapped._makeNode(view, context: c, ctx: &mountCtx)) + } ) + return .init(state: .a(root), context: context, ctx: &ctx) case .none: - return .init( - b: EmptyHTML(), - context: context, - ctx: &ctx + let root = MountRoot( + mountedFrom: context, + transaction: transaction, + ctx: &ctx, + create: { c, mountCtx in + AnyReconcilable(EmptyHTML._makeNode(EmptyHTML(), context: c, ctx: &mountCtx)) + } ) + return .init(state: .b(root), context: context, ctx: &ctx) } } diff --git a/Sources/ElementaryUI/StructureViews/PlaceholderContentView.swift b/Sources/ElementaryUI/StructureViews/PlaceholderContentView.swift index bce71ec..607945e 100644 --- a/Sources/ElementaryUI/StructureViews/PlaceholderContentView.swift +++ b/Sources/ElementaryUI/StructureViews/PlaceholderContentView.swift @@ -14,9 +14,9 @@ /// } /// ``` public struct PlaceholderContentView: View { - private var makeNodeFn: (borrowing _ViewContext, inout _CommitContext) -> _PlaceholderNode + private var makeNodeFn: (borrowing _ViewContext, inout _MountContext) -> _PlaceholderNode - init(makeNodeFn: @escaping (borrowing _ViewContext, inout _CommitContext) -> _PlaceholderNode) { + init(makeNodeFn: @escaping (borrowing _ViewContext, inout _MountContext) -> _PlaceholderNode) { self.makeNodeFn = makeNodeFn } } @@ -27,7 +27,7 @@ extension PlaceholderContentView: _Mountable { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { view.makeNodeFn(context, &ctx) } @@ -36,7 +36,8 @@ extension PlaceholderContentView: _Mountable { _ view: consuming Self, node: inout _MountedNode, tx: inout _TransactionContext - ) {} + ) { + } } public final class _PlaceholderNode: _Reconcilable { @@ -46,17 +47,7 @@ public final class _PlaceholderNode: _Reconcilable { self.node = node } - public func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { - node.apply(op, &tx) - } - - public func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { - node.collectChildren(&ops, &context) - } - public func unmount(_ context: inout _CommitContext) { - // TODO: we should maybe remove ourself from the parent list? - // or at least prevent updates to unmounted placeholders node.unmount(&context) } } diff --git a/Sources/ElementaryUI/StructureViews/Tuples+Mountable.swift b/Sources/ElementaryUI/StructureViews/Tuples+Mountable.swift index ad22e96..0b60629 100644 --- a/Sources/ElementaryUI/StructureViews/Tuples+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/Tuples+Mountable.swift @@ -5,7 +5,7 @@ extension _HTMLTuple2: _Mountable where V0: _Mountable, V1: _Mountable { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { _MountedNode( V0._makeNode(view.v0, context: context, ctx: &ctx), @@ -30,7 +30,7 @@ extension _HTMLTuple3: _Mountable where V0: _Mountable, V1: _Mountable, V2: _Mou public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { _MountedNode( V0._makeNode(view.v0, context: context, ctx: &ctx), @@ -57,7 +57,7 @@ extension _HTMLTuple4: _Mountable where V0: _Mountable, V1: _Mountable, V2: _Mou public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { _MountedNode( V0._makeNode(view.v0, context: context, ctx: &ctx), @@ -86,7 +86,7 @@ extension _HTMLTuple5: _Mountable where V0: _Mountable, V1: _Mountable, V2: _Mou public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { _MountedNode( V0._makeNode(view.v0, context: context, ctx: &ctx), @@ -119,7 +119,7 @@ extension _HTMLTuple6: _Mountable where V0: _Mountable, V1: _Mountable, V2: _Mou public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { _MountedNode( V0._makeNode(view.v0, context: context, ctx: &ctx), @@ -154,7 +154,7 @@ extension _HTMLTuple: _Mountable where repeat each Child: _Mountable { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { _MountedNode( repeat makeNode( @@ -195,7 +195,7 @@ private func __noop_goshDarnValuePacksAreAnnoyingAF(_ v: inout some _Mountable) private func makeNode( _ view: consuming V, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> V._MountedNode { V._makeNode(view, context: context, ctx: &ctx) } diff --git a/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift b/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift index 261ecb2..a4ade86 100644 --- a/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift +++ b/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift @@ -1,4 +1,4 @@ -public struct _ConditionalNode { +public final class _ConditionalNode: _Reconcilable, DynamicNode { enum State { case a(MountRoot) case b(MountRoot) @@ -8,138 +8,99 @@ public struct _ConditionalNode { private var state: State private var context: _ViewContext + private var containerHandle: LayoutContainer.Handle? - private init(state: State, context: borrowing _ViewContext) { - self.state = state - self.context = copy context - } - - init( - a view: A, - context: borrowing _ViewContext, - ctx: inout _CommitContext - ) { - let root = MountRoot( - mountedFrom: context, - transaction: context.mountRoot.inheritedTransaction(), - ctx: &ctx, - create: { context, ctx in - AnyReconcilable(A._makeNode(view, context: context, ctx: &ctx)) - } - ) - self.init(state: .a(root), context: context) + var count: Int { + switch state { + case .a, .b: + 1 + case .aWithBLeaving, .bWithALeaving: + 2 + } } - init( - b view: B, - context: borrowing _ViewContext, - ctx: inout _CommitContext - ) { - let root = MountRoot( - mountedFrom: context, - transaction: context.mountRoot.inheritedTransaction(), - ctx: &ctx, - create: { context, ctx in - AnyReconcilable(B._makeNode(view, context: context, ctx: &ctx)) - } - ) - self.init(state: .b(root), context: context) + init(state: State, context: borrowing _ViewContext, ctx: inout _MountContext) { + self.state = state + self.context = copy context + ctx.appendDynamicNode(self) } - mutating func patchWithA( + final func patchWithA( tx: inout _TransactionContext, - makeNode: @escaping (borrowing _ViewContext, inout _CommitContext) -> NodeA, + makeNode: @escaping (borrowing _ViewContext, inout _MountContext) -> NodeA, updateNode: (inout NodeA, inout _TransactionContext) -> Void ) { + var didStructureChange = false + switch state { case .a(let a): - patchActiveRoot( - a, - tx: &tx, - updateNode: updateNode - ) + patchActiveRoot(a, tx: &tx, updateNode: updateNode) state = .a(a) case .b(let b): - context.parentElement?.reportChangedChildren(.elementAdded, tx: &tx) let a = makePendingRoot(transaction: tx.transaction, makeNode: makeNode) - schedulePendingMount([a], tx: &tx) - - b.apply(.startRemoval, &tx) - context.parentElement?.reportChangedChildren(.elementMoved, tx: &tx) + b.startRemoval(&tx, handle: containerHandle) state = .aWithBLeaving(a, b) + didStructureChange = true case .aWithBLeaving(let a, let b): - patchActiveRoot( - a, - tx: &tx, - updateNode: updateNode - ) + patchActiveRoot(a, tx: &tx, updateNode: updateNode) state = .aWithBLeaving(a, b) case .bWithALeaving(let b, let a): - patchActiveRoot( - a, - tx: &tx, - updateNode: updateNode - ) - a.apply(.cancelRemoval, &tx) - b.apply(.startRemoval, &tx) - context.parentElement?.reportChangedChildren(.elementMoved, tx: &tx) + patchActiveRoot(a, tx: &tx, updateNode: updateNode) + a.cancelRemoval(&tx, handle: containerHandle) + b.startRemoval(&tx, handle: containerHandle) state = .aWithBLeaving(a, b) + didStructureChange = true + } + + if didStructureChange { + containerHandle?.reportLayoutChange(&tx) } } - mutating func patchWithB( + final func patchWithB( tx: inout _TransactionContext, - makeNode: @escaping (borrowing _ViewContext, inout _CommitContext) -> NodeB, + makeNode: @escaping (borrowing _ViewContext, inout _MountContext) -> NodeB, updateNode: (inout NodeB, inout _TransactionContext) -> Void ) { + var didStructureChange = false + switch state { case .b(let b): - patchActiveRoot( - b, - tx: &tx, - updateNode: updateNode - ) + patchActiveRoot(b, tx: &tx, updateNode: updateNode) state = .b(b) case .a(let a): - context.parentElement?.reportChangedChildren(.elementAdded, tx: &tx) let b = makePendingRoot(transaction: tx.transaction, makeNode: makeNode) - schedulePendingMount([b], tx: &tx) - - a.apply(.startRemoval, &tx) - context.parentElement?.reportChangedChildren(.elementMoved, tx: &tx) + a.startRemoval(&tx, handle: containerHandle) state = .bWithALeaving(b, a) + didStructureChange = true case .aWithBLeaving(let a, let b): - patchActiveRoot( - b, - tx: &tx, - updateNode: updateNode - ) + patchActiveRoot(b, tx: &tx, updateNode: updateNode) state = .bWithALeaving(b, a) case .bWithALeaving(let b, let a): - patchActiveRoot( - b, - tx: &tx, - updateNode: updateNode - ) + patchActiveRoot(b, tx: &tx, updateNode: updateNode) state = .bWithALeaving(b, a) } + + if didStructureChange { + containerHandle?.reportLayoutChange(&tx) + } } - private mutating func makePendingRoot( + private final func makePendingRoot( transaction: Transaction, - makeNode: @escaping (borrowing _ViewContext, inout _CommitContext) -> Node + makeNode: @escaping (borrowing _ViewContext, inout _MountContext) -> Node ) -> MountRoot { MountRoot( pending: context, transaction: transaction, transitionPhase: .willAppear, - create: { viewContext, ctx in - AnyReconcilable(makeNode(viewContext, &ctx)) + create: { viewContext, mountCtx in + AnyReconcilable(makeNode(viewContext, &mountCtx)) } ) } - private mutating func patchActiveRoot( + private final func patchActiveRoot( _ root: MountRoot, tx: inout _TransactionContext, updateNode: (inout Node, inout _TransactionContext) -> Void @@ -152,29 +113,21 @@ public struct _ConditionalNode { precondition(patched, "expected mounted conditional branch") } - private func schedulePendingMount(_ roots: [MountRoot], tx: inout _TransactionContext) { - guard !roots.isEmpty else { return } - - tx.scheduler.addCommitAction { ctx in - for root in roots { - root.mount(&ctx) - } + func collect(into ops: inout LayoutPass, context: inout _CommitContext) { + if containerHandle == nil { + containerHandle = ops.containerHandle } - } -} -extension _ConditionalNode: _Reconcilable { - public mutating func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { switch state { case .a(let a): - a.collectChildren(&ops, &context) + a.collect(into: &ops, &context) case .b(let b): - b.collectChildren(&ops, &context) + b.collect(into: &ops, &context) case .aWithBLeaving(let a, let b): - a.collectChildren(&ops, &context) + a.collect(into: &ops, &context) let isRemovalCompleted = ops.withRemovalTracking { ops in - b.collectChildren(&ops, &context) + b.collect(into: &ops, &context) } if isRemovalCompleted { @@ -183,10 +136,10 @@ extension _ConditionalNode: _Reconcilable { } case .bWithALeaving(let b, let a): let isRemovalCompleted = ops.withRemovalTracking { ops in - a.collectChildren(&ops, &context) + a.collect(into: &ops, &context) } - b.collectChildren(&ops, &context) + b.collect(into: &ops, &context) if isRemovalCompleted { a.unmount(&context) @@ -195,19 +148,7 @@ extension _ConditionalNode: _Reconcilable { } } - public mutating func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { - switch state { - case .a(let a): - a.apply(op, &tx) - case .b(let b): - b.apply(op, &tx) - case .aWithBLeaving(let a, let b), .bWithALeaving(let b, let a): - a.apply(op, &tx) - b.apply(op, &tx) - } - } - - public consuming func unmount(_ context: inout _CommitContext) { + public func unmount(_ context: inout _CommitContext) { switch state { case .a(let a): a.unmount(&context) @@ -219,18 +160,3 @@ extension _ConditionalNode: _Reconcilable { } } } - -extension _ContainerLayoutPass { - mutating func withRemovalTracking(_ block: (inout Self) -> Void) -> Bool { - let index = entries.count - block(&self) - var isRemoved = true - for entry in entries[index.. Content, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) { self.data = data self.contentBuilder = contentBuilder @@ -67,8 +67,8 @@ where Data: Collection, Content: _KeyReadableView, Content.Value: _Mountable { keys, context: &tx, as: Content.Value._MountedNode.self, - makeNode: { index, context, ctx in - Content.Value._makeNode(views[index]._value, context: context, ctx: &ctx) + makeNode: { index, context, mountCtx in + Content.Value._makeNode(views[index]._value, context: context, ctx: &mountCtx) }, patchNode: { index, node, tx in Content.Value._patchNode(views[index]._value, node: &node, tx: &tx) @@ -76,7 +76,7 @@ where Data: Collection, Content: _KeyReadableView, Content.Value: _Mountable { ) } - private func runFunctionInitial(ctx: inout _CommitContext) { + private func runFunctionInitial(ctx: inout _MountContext) { self.trackingSession.take()?.cancel() let ((views, keys), session) = withReactiveTrackingSession { @@ -103,20 +103,12 @@ where Data: Collection, Content: _KeyReadableView, Content.Value: _Mountable { keys: keys, context: context, ctx: &ctx, - makeNode: { index, context, ctx in - Content.Value._makeNode(views[index]._value, context: context, ctx: &ctx) + makeNode: { index, context, mountCtx in + AnyReconcilable(Content.Value._makeNode(views[index]._value, context: context, ctx: &mountCtx)) } ) } - public func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { - keyedNode?.collectChildren(&ops, &context) - } - - public func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { - keyedNode?.apply(op, &tx) - } - public func unmount(_ context: inout _CommitContext) { self.trackingSession.take()?.cancel() let node = keyedNode.take() diff --git a/Sources/ElementaryUI/StructureViews/_HTMLArray+Mountable.swift b/Sources/ElementaryUI/StructureViews/_HTMLArray+Mountable.swift index 00982a3..6cafb9a 100644 --- a/Sources/ElementaryUI/StructureViews/_HTMLArray+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/_HTMLArray+Mountable.swift @@ -4,7 +4,7 @@ extension _HTMLArray: _Mountable, View where Element: View { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { var keys: [_ViewKey] = [] let estimatedCount = view.value.underestimatedCount @@ -19,7 +19,7 @@ extension _HTMLArray: _Mountable, View where Element: View { context: context, ctx: &ctx, makeNode: { index, context, ctx in - Element._makeNode(view.value[index], context: context, ctx: &ctx) + AnyReconcilable(Element._makeNode(view.value[index], context: context, ctx: &ctx)) } ) } diff --git a/Sources/ElementaryUI/StructureViews/_HTMLConditional+Mountable.swift b/Sources/ElementaryUI/StructureViews/_HTMLConditional+Mountable.swift index 7054144..078b517 100644 --- a/Sources/ElementaryUI/StructureViews/_HTMLConditional+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/_HTMLConditional+Mountable.swift @@ -5,21 +5,30 @@ extension _HTMLConditional: _Mountable where TrueContent: _Mountable, FalseConte public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { + let transaction = context.mountRoot.inheritedTransaction() switch view.value { case let .trueContent(content): - return .init( - a: content, - context: context, - ctx: &ctx + let root = MountRoot( + mountedFrom: context, + transaction: transaction, + ctx: &ctx, + create: { c, mountCtx in + AnyReconcilable(TrueContent._makeNode(content, context: c, ctx: &mountCtx)) + } ) + return .init(state: .a(root), context: context, ctx: &ctx) case let .falseContent(content): - return .init( - b: content, - context: context, - ctx: &ctx + let root = MountRoot( + mountedFrom: context, + transaction: transaction, + ctx: &ctx, + create: { c, mountCtx in + AnyReconcilable(FalseContent._makeNode(content, context: c, ctx: &mountCtx)) + } ) + return .init(state: .b(root), context: context, ctx: &ctx) } } diff --git a/Sources/ElementaryUI/StructureViews/_KeyedNode.swift b/Sources/ElementaryUI/StructureViews/_KeyedNode.swift index 74f4d67..cfde201 100644 --- a/Sources/ElementaryUI/StructureViews/_KeyedNode.swift +++ b/Sources/ElementaryUI/StructureViews/_KeyedNode.swift @@ -1,8 +1,13 @@ -public struct _KeyedNode { +public final class _KeyedNode: _Reconcilable, DynamicNode { private var keys: [_ViewKey] private var children: [MountRoot] private var leavingChildren: LeavingChildrenTracker = .init() private let viewContext: _ViewContext + private var containerHandle: LayoutContainer.Handle? + + var count: Int { + children.count + leavingChildren.entries.count + } init(keys: [_ViewKey], children: [MountRoot], context: borrowing _ViewContext) { assert(keys.count == children.count) @@ -11,11 +16,11 @@ public struct _KeyedNode { self.viewContext = copy context } - init( + init( keys: [_ViewKey], context: borrowing _ViewContext, - ctx: inout _CommitContext, - makeNode: (Int, borrowing _ViewContext, inout _CommitContext) -> Node + ctx: inout _MountContext, + makeNode: (Int, borrowing _ViewContext, inout _MountContext) -> AnyReconcilable ) { self.keys = keys self.viewContext = copy context @@ -28,19 +33,21 @@ public struct _KeyedNode { mountedFrom: context, transaction: transaction, ctx: &ctx, - create: { context, ctx in - AnyReconcilable(makeNode(index, context, &ctx)) + create: { context, mountCtx in + makeNode(index, context, &mountCtx) } ) self.children.append(root) } + + ctx.appendDynamicNode(self) } - init( + convenience init( key: _ViewKey, context: borrowing _ViewContext, - ctx: inout _CommitContext, - makeNode: (borrowing _ViewContext, inout _CommitContext) -> Node + ctx: inout _MountContext, + makeNode: (borrowing _ViewContext, inout _MountContext) -> AnyReconcilable ) { self.init( keys: [key], @@ -50,7 +57,7 @@ public struct _KeyedNode { ) } - init(_ value: some Sequence<(key: _ViewKey, node: some _Reconcilable)>, context: borrowing _ViewContext) { + convenience init(_ value: some Sequence<(key: _ViewKey, node: some _Reconcilable)>, context: borrowing _ViewContext) { self.init( keys: value.map { $0.key }, children: value.map { MountRoot(mounted: AnyReconcilable($0.node)) }, @@ -58,15 +65,15 @@ public struct _KeyedNode { ) } - init(key: _ViewKey, child: some _Reconcilable, context: borrowing _ViewContext) { + convenience init(key: _ViewKey, child: some _Reconcilable, context: borrowing _ViewContext) { self.init(CollectionOfOne((key: key, node: child)), context: context) } - mutating func patch( + final func patch( key: _ViewKey, context: inout _TransactionContext, as: Node.Type = Node.self, - makeNode: @escaping (borrowing _ViewContext, inout _CommitContext) -> Node, + makeNode: @escaping (borrowing _ViewContext, inout _MountContext) -> Node, patchNode: (inout Node, inout _TransactionContext) -> Void ) { patch( @@ -77,22 +84,24 @@ public struct _KeyedNode { ) } - mutating func patch( + final func patch( _ newKeys: some BidirectionalCollection<_ViewKey>, context: inout _TransactionContext, - as: Node.Type = Node.self, - makeNode: @escaping (Int, borrowing _ViewContext, inout _CommitContext) -> Node, + as type: Node.Type = Node.self, + makeNode: @escaping (Int, borrowing _ViewContext, inout _MountContext) -> Node, patchNode: (Int, inout Node, inout _TransactionContext) -> Void ) { + _ = type assertNoPendingRoots() guard !newKeys.isEmpty else { fastRemoveAll(context: &context) + containerHandle?.reportLayoutChange(&context) return } let newKeysArray = Array(newKeys) - var pendingMounts: [MountRoot] = [] + var didStructureChange = false guard !(keys.isEmpty && leavingChildren.entries.isEmpty) else { keys = newKeysArray @@ -100,20 +109,21 @@ public struct _KeyedNode { children.reserveCapacity(keys.count) for index in keys.indices { - viewContext.parentElement?.reportChangedChildren(.elementAdded, tx: &context) let root = MountRoot( pending: viewContext, transaction: context.transaction, transitionPhase: .willAppear, - create: { viewContext, ctx in - AnyReconcilable(makeNode(index, viewContext, &ctx)) + create: { viewContext, mountCtx in + AnyReconcilable(makeNode(index, viewContext, &mountCtx)) } ) children.append(root) - pendingMounts.append(root) } - schedulePendingMountIfNeeded(&pendingMounts, context: &context) + didStructureChange = !children.isEmpty + if didStructureChange { + containerHandle?.reportLayoutChange(&context) + } return } @@ -127,8 +137,6 @@ public struct _KeyedNode { } precondition(patched, "expected mounted child during stable keyed patch") } - - schedulePendingMountIfNeeded(&pendingMounts, context: &context) return } @@ -144,13 +152,13 @@ public struct _KeyedNode { let root = children.remove(at: offset) if movedTo != nil { - root.apply(.markAsMoved, &context) + root.markMoved(&context) moversCache[offset] = root } else { - root.apply(.startRemoval, &context) - viewContext.parentElement?.reportChangedChildren(.elementMoved, tx: &context) + root.startRemoval(&context, handle: containerHandle) leavingChildren.append(key, atIndex: offset, value: root) } + didStructureChange = true case let .insert(offset, element: key, associatedWith: movedFrom): let root: MountRoot @@ -161,20 +169,19 @@ public struct _KeyedNode { } root = moved } else { - viewContext.parentElement?.reportChangedChildren(.elementAdded, tx: &context) root = MountRoot( pending: viewContext, transaction: context.transaction, transitionPhase: .willAppear, - create: { viewContext, ctx in - AnyReconcilable(makeNode(offset, viewContext, &ctx)) + create: { viewContext, mountCtx in + AnyReconcilable(makeNode(offset, viewContext, &mountCtx)) } ) - pendingMounts.append(root) } children.insert(root, at: offset) leavingChildren.reflectInsertionAt(offset) + didStructureChange = true } } @@ -184,8 +191,6 @@ public struct _KeyedNode { for index in children.indices { let child = children[index] if child.isPending { - // Newly inserted roots in this pass are mounted in commit and - // intentionally not patched before mount. continue } @@ -195,15 +200,15 @@ public struct _KeyedNode { precondition(patched, "expected mounted child during keyed patch") } - schedulePendingMountIfNeeded(&pendingMounts, context: &context) + if didStructureChange { + containerHandle?.reportLayoutChange(&context) + } } - mutating func fastRemoveAll(context: inout _TransactionContext) { - viewContext.parentElement?.reportChangedChildren(.elementMoved, tx: &context) - + func fastRemoveAll(context: inout _TransactionContext) { for index in children.indices { let root = children[index] - root.apply(.startRemoval, &context) + root.startRemoval(&context, handle: containerHandle) leavingChildren.append(keys[index], atIndex: index, value: root) } @@ -211,21 +216,6 @@ public struct _KeyedNode { children.removeAll() } - private mutating func schedulePendingMountIfNeeded( - _ pendingMounts: inout [MountRoot], - context: inout _TransactionContext - ) { - guard !pendingMounts.isEmpty else { return } - - let roots = pendingMounts - context.scheduler.addCommitAction { ctx in - for root in roots { - root.mount(&ctx) - } - } - pendingMounts.removeAll(keepingCapacity: true) - } - private func assertNoPendingRoots() { let hasPendingChildren = children.contains { $0.isPending } let hasPendingLeaving = leavingChildren.entries.contains { $0.value.isPending } @@ -234,16 +224,12 @@ public struct _KeyedNode { "double patch of pending MountRoot in _KeyedNode" ) } -} -extension _KeyedNode: _Reconcilable { - public func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { - for child in children { - child.apply(op, &tx) + func collect(into ops: inout LayoutPass, context: inout _CommitContext) { + if containerHandle == nil { + containerHandle = ops.containerHandle } - } - public mutating func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { var lIndex = 0 var nextInsertionPoint = leavingChildren.insertionIndex(for: 0) @@ -254,7 +240,7 @@ extension _KeyedNode: _Reconcilable { nextInsertionPoint = leavingChildren.insertionIndex(for: lIndex) } - children[cIndex].collectChildren(&ops, &context) + children[cIndex].collect(into: &ops, &context) } while nextInsertionPoint != nil { @@ -264,7 +250,7 @@ extension _KeyedNode: _Reconcilable { } } - public consuming func unmount(_ context: inout _CommitContext) { + public func unmount(_ context: inout _CommitContext) { for child in children { child.unmount(&context) } @@ -307,11 +293,11 @@ private extension _KeyedNode { mutating func commitAndCheckRemoval( at index: Int, - ops: inout _ContainerLayoutPass, + ops: inout LayoutPass, context: inout _CommitContext ) -> Bool { let isRemovalCommitted = ops.withRemovalTracking { ops in - entries[index].value.collectChildren(&ops, &context) + entries[index].value.collect(into: &ops, &context) } if isRemovalCommitted { diff --git a/Sources/ElementaryUI/StructureViews/_MountContext.swift b/Sources/ElementaryUI/StructureViews/_MountContext.swift new file mode 100644 index 0000000..6419776 --- /dev/null +++ b/Sources/ElementaryUI/StructureViews/_MountContext.swift @@ -0,0 +1,89 @@ +public struct _MountContext: ~Copyable { + private var layoutNodes: [LayoutNode] = [] + private(set) var isStatic: Bool = true + + let scheduler: Scheduler + let dom: any DOM.Interactor + let currentFrameTime: Double //TODO: remove + + private init(scheduler: Scheduler, dom: any DOM.Interactor) { + self.scheduler = scheduler + self.dom = dom + self.currentFrameTime = 0 + } + + init(ctx: borrowing _CommitContext) { + self.scheduler = ctx.scheduler + self.dom = ctx.dom + self.currentFrameTime = ctx.currentFrameTime + } + + mutating func appendStaticElement(_ node: DOM.Node) { + appendLayoutNode(.elementNode(node)) + } + + mutating func appendStaticText(_ node: DOM.Node) { + appendLayoutNode(.textNode(node)) + } + + mutating func appendDynamicNode(_ node: any DynamicNode) { + appendLayoutNode(.dynamicNode(node)) + } + + func withChildContext(_ body: (consuming _MountContext) -> R) -> R { + body(_MountContext(scheduler: scheduler, dom: dom)) + } + + // TODO: get rid of this... + mutating func withCommitContext(_ body: (inout _CommitContext) -> R) -> R { + var context = _CommitContext( + dom: dom, + scheduler: scheduler, + currentFrameTime: currentFrameTime + ) + return body(&context) + } + + private mutating func appendLayoutNode(_ node: LayoutNode) { + isStatic = isStatic && node.isStatic + layoutNodes.append(node) + } +} + +extension _MountContext { + consuming func takeLayoutNodes() -> [LayoutNode] { + layoutNodes + } + + consuming func mountInDOMNode(_ domNode: DOM.Node, observers: [any DOMLayoutObserver]) -> LayoutContainer? { + if isStatic { + // TODO: measure if just appending is faster than replacing... + let refs = layoutNodes.map { $0.staticDOMNode } + if refs.count == 1 { + dom.insertChild(refs[0], before: nil, in: domNode) + } else if refs.count > 1 { + dom.replaceChildren(refs, in: domNode) + } + return nil + } else { + let container = LayoutContainer( + domNode: domNode, + scheduler: scheduler, + layoutNodes: layoutNodes, + layoutObservers: observers + ) + var commit = _CommitContext(dom: dom, scheduler: scheduler, currentFrameTime: currentFrameTime) + container.mountInitial(&commit) + return container + } + } +} + +private extension LayoutNode { + var staticDOMNode: DOM.Node { + switch self { + case .elementNode(let node), .textNode(let node): node + case .dynamicNode: fatalError("dynamic node in static node list") + } + } +} diff --git a/Sources/ElementaryUI/StructureViews/_MountedStructure.swift b/Sources/ElementaryUI/StructureViews/_MountedStructure.swift new file mode 100644 index 0000000..93d9822 --- /dev/null +++ b/Sources/ElementaryUI/StructureViews/_MountedStructure.swift @@ -0,0 +1,221 @@ +protocol DynamicNode: AnyObject { + var count: Int { get } + func collect(into pass: inout LayoutPass, context: inout _CommitContext) +} + +final class LayoutContainer { + let domNode: DOM.Node + private let scheduler: Scheduler + private var layoutObservers: [any DOMLayoutObserver] + private var layoutNodes: [LayoutNode] + private var isDirty: Bool = false + + init( + domNode: DOM.Node, + scheduler: Scheduler, + layoutNodes: [LayoutNode], + layoutObservers: [any DOMLayoutObserver] + ) { + self.domNode = domNode + self.scheduler = scheduler + self.layoutNodes = layoutNodes + self.layoutObservers = layoutObservers + } + + func mountInitial(_ context: inout _CommitContext) { + var ops = LayoutPass(layoutContainer: self) + collectLayout(&ops, &context) + + if ops.entries.count == 1 { + context.dom.insertChild(ops.entries[0].reference, before: nil, in: domNode) + } else if ops.entries.count > 1 { + context.dom.replaceChildren(ops.entries.map { $0.reference }, in: domNode) + } + + for observer in layoutObservers { + observer.didLayoutChildren(parent: domNode, entries: ops.entries, context: &context) + } + } + + // TODO: I get rid of this... + func removeAllChildren(_ context: inout _CommitContext) { + var ops = LayoutPass(layoutContainer: self) + collectLayout(&ops, &context) + + if ops.entries.count == 1 { + context.dom.removeChild(ops.entries[0].reference, from: domNode) + } else if ops.entries.count > 1 { + context.dom.replaceChildren([], in: domNode) + } + } + + private func collectLayout(_ ops: inout LayoutPass, _ context: inout _CommitContext) { + for node in layoutNodes { + node.collect(into: &ops, context: &context) + } + } + + private func markDirty(_ tx: inout _TransactionContext) { + guard !isDirty else { return } + + isDirty = true + tx.scheduler.addPlacementAction(performLayout(_:)) + for observer in layoutObservers { + observer.willLayoutChildren(parent: domNode, context: &tx) + } + } + + private func reportLeavingElement(_ node: DOM.Node, _ tx: inout _TransactionContext) { + for observer in layoutObservers { + observer.setLeaveStatus(node, isLeaving: true, context: &tx) + } + } + + private func reportReenteringElement(_ node: DOM.Node, _ tx: inout _TransactionContext) { + for observer in layoutObservers { + observer.setLeaveStatus(node, isLeaving: false, context: &tx) + } + } + + private func performLayout(_ context: inout _CommitContext) { + guard isDirty else { return } + isDirty = false + + var ops = LayoutPass(layoutContainer: self) + collectLayout(&ops, &context) + + if ops.canBatchReplace { + if ops.isAllRemovals { + context.dom.replaceChildren([], in: domNode) + } else if ops.isAllAdditions { + context.dom.replaceChildren(ops.entries.map { $0.reference }, in: domNode) + } else { + fatalError("invalid batch replace pass in layout container") + } + } else { + var sibling: DOM.Node? + for entry in ops.entries.reversed() { + switch entry.kind { + case .added, .moved: + context.dom.insertChild(entry.reference, before: sibling, in: domNode) + sibling = entry.reference + case .removed: + context.dom.removeChild(entry.reference, from: domNode) + case .unchanged: + sibling = entry.reference + } + } + } + + for observer in layoutObservers { + observer.didLayoutChildren(parent: domNode, entries: ops.entries, context: &context) + } + } + + struct Handle { + private let container: LayoutContainer + + init(container: LayoutContainer) { + self.container = container + } + + func reportLayoutChange(_ tx: inout _TransactionContext) { + container.markDirty(&tx) + } + + func reportLeavingElement(_ node: DOM.Node, _ tx: inout _TransactionContext) { + container.reportLeavingElement(node, &tx) + } + + func reportReenteringElement(_ node: DOM.Node, _ tx: inout _TransactionContext) { + container.reportReenteringElement(node, &tx) + } + } +} + +enum LayoutNode { + case elementNode(DOM.Node) + case textNode(DOM.Node) + case dynamicNode(any DynamicNode) + + func collect(into ops: inout LayoutPass, context: inout _CommitContext) { + switch self { + case .elementNode(let node): + ops.append(.init(kind: .unchanged, reference: node, type: .element)) + case .textNode(let node): + ops.append(.init(kind: .unchanged, reference: node, type: .text)) + case .dynamicNode(let node): + node.collect(into: &ops, context: &context) + } + } + + var isStatic: Bool { + switch self { + case .elementNode, .textNode: + true + case .dynamicNode: + false + } + } +} + +struct LayoutPass: ~Copyable { + var entries: [Entry] + var containerHandle: LayoutContainer.Handle + + private(set) var isAllRemovals: Bool = true + private(set) var isAllAdditions: Bool = true + + var canBatchReplace: Bool { + (isAllRemovals || isAllAdditions) && entries.count > 1 + } + + init(layoutContainer: LayoutContainer) { + entries = [] + self.containerHandle = .init(container: layoutContainer) + } + + mutating func append(_ entry: Entry) { + entries.append(entry) + isAllAdditions = isAllAdditions && entry.kind == .added + isAllRemovals = isAllRemovals && entry.kind == .removed + } + + mutating func recomputeBatchFlags() { + isAllAdditions = true + isAllRemovals = true + for entry in entries { + isAllAdditions = isAllAdditions && entry.kind == .added + isAllRemovals = isAllRemovals && entry.kind == .removed + } + } + + mutating func withRemovalTracking(_ block: (inout Self) -> Void) -> Bool { + let index = entries.count + block(&self) + var isRemoved = true + for entry in entries[index..: _Reconcilable { var value: (repeat each N) @@ -6,18 +6,6 @@ public struct _TupleNode: _Reconcilable { self.value = (repeat each value) } - public mutating func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { - for var value in repeat each value { - value.collectChildren(&ops, &context) - } - } - - public mutating func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { - for var value in repeat each value { - value.apply(op, &tx) - } - } - public consuming func unmount(_ context: inout _CommitContext) { for value in repeat each value { value.unmount(&context) @@ -32,16 +20,6 @@ public struct _TupleNode2: _Reconcilable { self.value = (n0, n1) } - public mutating func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { - value.0.collectChildren(&ops, &context) - value.1.collectChildren(&ops, &context) - } - - public mutating func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { - value.0.apply(op, &tx) - value.1.apply(op, &tx) - } - public consuming func unmount(_ context: inout _CommitContext) { value.0.unmount(&context) value.1.unmount(&context) @@ -55,18 +33,6 @@ public struct _TupleNode3 { -// let values: [count of AnyReconcilable] - -// init(_ values: [count of AnyReconcilable]) { -// self.values = values -// } -// } diff --git a/Sources/ElementaryUI/Transition/_TransitionNode.swift b/Sources/ElementaryUI/Transition/_TransitionNode.swift index 9f60c49..4a0b91d 100644 --- a/Sources/ElementaryUI/Transition/_TransitionNode.swift +++ b/Sources/ElementaryUI/Transition/_TransitionNode.swift @@ -10,7 +10,7 @@ public final class _TransitionNode: _Reconcilable { // a transition can theoretically duplicate the content node, but it will be rare private var additionalPlaceholderNodes: [_PlaceholderNode] = [] - init(view: consuming _TransitionView, context: borrowing _ViewContext, ctx: inout _CommitContext) { + init(view: consuming _TransitionView, context: borrowing _ViewContext, ctx: inout _MountContext) { let view = view let defaultAnimation = view.animation self.value = view @@ -65,7 +65,7 @@ public final class _TransitionNode: _Reconcilable { func makeInitialNode( for phase: TransitionPhase, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> AnyReconcilable { AnyReconcilable( T.Body._makeNode( @@ -76,7 +76,7 @@ public final class _TransitionNode: _Reconcilable { ) } - private func makePlaceholderNode(context: borrowing _ViewContext, ctx: inout _CommitContext) -> _PlaceholderNode { + private func makePlaceholderNode(context: borrowing _ViewContext, ctx: inout _MountContext) -> _PlaceholderNode { let node = _PlaceholderNode(node: AnyReconcilable(V._makeNode(value.wrapped, context: context, ctx: &ctx))) if placeholderNode == nil { placeholderNode = node @@ -86,14 +86,6 @@ public final class _TransitionNode: _Reconcilable { return node } - public func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { - node?.apply(op, &tx) - } - - public func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { - node?.collectChildren(&ops, &context) - } - public func unmount(_ context: inout _CommitContext) { node?.unmount(&context) diff --git a/Sources/ElementaryUI/Transition/_TransitionView.swift b/Sources/ElementaryUI/Transition/_TransitionView.swift index 7211098..bb11399 100644 --- a/Sources/ElementaryUI/Transition/_TransitionView.swift +++ b/Sources/ElementaryUI/Transition/_TransitionView.swift @@ -15,7 +15,7 @@ public struct _TransitionView: View { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { .init(view: view, context: context, ctx: &ctx) } diff --git a/Sources/ElementaryUI/Views/Function/_FunctionNode.swift b/Sources/ElementaryUI/Views/Function/_FunctionNode.swift index 35fa387..7ce0e48 100644 --- a/Sources/ElementaryUI/Views/Function/_FunctionNode.swift +++ b/Sources/ElementaryUI/Views/Function/_FunctionNode.swift @@ -25,7 +25,7 @@ where Value: __FunctionView, ChildNode: _Reconcilable, ChildNode == Value.Body._ init( value: consuming Value, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) { self.depthInTree = context.functionDepth @@ -119,15 +119,6 @@ where Value: __FunctionView, ChildNode: _Reconcilable, ChildNode == Value.Body._ } extension _FunctionNode: _Reconcilable { - - public func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { - child?.collectChildren(&ops, &context) - } - - public func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { - child?.apply(op, &tx) - } - public consuming func unmount(_ context: inout _CommitContext) { self.trackingSession.take()?.cancel() diff --git a/Sources/ElementaryUI/Views/Function/_FunctionView.swift b/Sources/ElementaryUI/Views/Function/_FunctionView.swift index bae27ea..51512c7 100644 --- a/Sources/ElementaryUI/Views/Function/_FunctionView.swift +++ b/Sources/ElementaryUI/Views/Function/_FunctionView.swift @@ -17,7 +17,7 @@ public extension __FunctionView { static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { .init( value: view, diff --git a/Sources/ElementaryUI/Views/View+Mountable.swift b/Sources/ElementaryUI/Views/View+Mountable.swift index 306e386..8ef63a2 100644 --- a/Sources/ElementaryUI/Views/View+Mountable.swift +++ b/Sources/ElementaryUI/Views/View+Mountable.swift @@ -63,7 +63,7 @@ public protocol _Mountable { static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode static func _patchNode( @@ -79,7 +79,7 @@ extension Never: _Mountable { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - ctx: inout _CommitContext + ctx: inout _MountContext ) -> _MountedNode { fatalError("This should never be called") } diff --git a/Tests/ElementaryUITests/Reconciler/DOMMountingTests.swift b/Tests/ElementaryUITests/Reconciler/DOMMountingTests.swift index c927a1d..1eb69a4 100644 --- a/Tests/ElementaryUITests/Reconciler/DOMMountingTests.swift +++ b/Tests/ElementaryUITests/Reconciler/DOMMountingTests.swift @@ -81,19 +81,17 @@ struct DOMMountingTests { } } - #expect( - ops == [ - .createElement("ul"), - .createElement("li"), - .createText("Text"), - .createElement("li"), - .createElement("p"), - .addChild(parent: "

  • ", child: "

    "), - .addChild(parent: "

  • ", child: "Text"), - .setChildren(parent: "