diff --git a/Benchmarks/SwiftBenchmarks/Benchmarks/SwiftBenchmarks/SwiftBenchmarks.swift b/Benchmarks/SwiftBenchmarks/Benchmarks/SwiftBenchmarks/SwiftBenchmarks.swift index ab1fcaa..bc44f8c 100644 --- a/Benchmarks/SwiftBenchmarks/Benchmarks/SwiftBenchmarks/SwiftBenchmarks.swift +++ b/Benchmarks/SwiftBenchmarks/Benchmarks/SwiftBenchmarks/SwiftBenchmarks.swift @@ -3,7 +3,7 @@ import Benchmark import Reactivity struct BenchRow: Equatable { - let id: Int + let id: String var label: String } @@ -44,7 +44,7 @@ struct BenchRowView { var body: some View { tr { - td(.class("id")) { "\(row.id)" } + td(.class("id")) { row.id } td(.class("label")) { row.label } } } @@ -117,7 +117,7 @@ private func makeRows(startID: Int, count: Int, labelPrefix: String) -> [BenchRo var rows: [BenchRow] = [] rows.reserveCapacity(count) for i in 0..: View { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _MountContext ) -> _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..83f91b9 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 _MountContext ) -> _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..e5ef085 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 _MountContext ) -> _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..280c3f0 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 _MountContext ) -> _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/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 f3232f5..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, - tx: inout _TransactionContext + ctx: inout _MountContext ) -> _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..1c87a40 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) @@ -38,7 +38,7 @@ public final class _AttributeModifier: DOMElementModifier, Invalidateable { } } - func mount(_ node: DOM.Node, _ context: inout _CommitContext) -> AnyUnmountable { + func mount(_ node: DOM.Node, _ context: inout _MountContext) -> AnyUnmountable { logTrace("mounting attribute modifier") return AnyUnmountable(MountedInstance(node, self, &context)) } @@ -58,11 +58,11 @@ extension _AttributeModifier { var isDirty: Bool = false var previousAttributes: _AttributeStorage = .none - init(_ node: DOM.Node, _ modifier: _AttributeModifier, _ context: inout _CommitContext) { + init(_ node: DOM.Node, _ modifier: _AttributeModifier, _ context: inout _MountContext) { self.node = node self.modifier = modifier self.modifier.tracker.addDependency(self) - updateDOMNode(&context) + patchAttributes(with: modifier.value, on: context.dom) } func invalidate(_ context: inout _TransactionContext) { diff --git a/Sources/ElementaryUI/HTMLViews/ElementModifiers/BindingModifiers.swift b/Sources/ElementaryUI/HTMLViews/ElementModifiers/BindingModifiers.swift index 5434b28..2c2138d 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 } @@ -43,10 +43,14 @@ final class BindingModifier: DOMElementModifier, Unmountable wher isDirty = false } - func mount(_ node: DOM.Node, _ context: inout _CommitContext) -> AnyUnmountable { + func mount(_ node: DOM.Node, _ context: inout _MountContext) -> AnyUnmountable { if mountedNode != nil { assertionFailure("Binding effect can only be mounted on a single element") - self.unmount(&context) + if let sink = self.sink, let node = self.mountedNode { + context.dom.removeEventListener(node, event: Configuration.eventName, sink: sink) + self.mountedNode = nil + self.sink = nil + } } self.mountedNode = node diff --git a/Sources/ElementaryUI/HTMLViews/ElementModifiers/DOMElementModifier.swift b/Sources/ElementaryUI/HTMLViews/ElementModifiers/DOMElementModifier.swift index d77b4a9..6164466 100644 --- a/Sources/ElementaryUI/HTMLViews/ElementModifiers/DOMElementModifier.swift +++ b/Sources/ElementaryUI/HTMLViews/ElementModifiers/DOMElementModifier.swift @@ -3,10 +3,10 @@ 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 + func mount(_ node: DOM.Node, _ context: inout _MountContext) -> AnyUnmountable } protocol Unmountable: AnyObject { diff --git a/Sources/ElementaryUI/HTMLViews/ElementModifiers/EventModifier.swift b/Sources/ElementaryUI/HTMLViews/ElementModifiers/EventModifier.swift index 54cf599..9dbf219 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] } @@ -20,7 +20,7 @@ final class EventModifier: DOMElementModifier { self.value = value } - func mount(_ node: DOM.Node, _ context: inout _CommitContext) -> AnyUnmountable { + func mount(_ node: DOM.Node, _ context: inout _MountContext) -> AnyUnmountable { logTrace("mounting event modifier") return AnyUnmountable(MountedInstance(node, self, &context)) } @@ -45,7 +45,7 @@ extension EventModifier { var isDirty: Bool = false - init(_ node: DOM.Node, _ modifier: EventModifier, _ context: inout _CommitContext) { + init(_ node: DOM.Node, _ modifier: EventModifier, _ context: inout _MountContext) { self.node = node self.modifier = modifier diff --git a/Sources/ElementaryUI/HTMLViews/ElementModifiers/FocusModifier.swift b/Sources/ElementaryUI/HTMLViews/ElementModifiers/FocusModifier.swift index 1fbed46..d04cece 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 } @@ -15,11 +15,12 @@ final class FocusModifier: DOMElementModifier, Unmountable self.binding = value } - func mount(_ node: DOM.Node, _ context: inout _CommitContext) -> AnyUnmountable { + func mount(_ node: DOM.Node, _ context: inout _MountContext) -> AnyUnmountable { if focusAccessor != nil { assertionFailure("FocusModifier can only be mounted on a single element") logWarning("FocusModifier can only be mounted on a single element") - self.unmount(&context) + binding.unregister(focusable: self) + self.focusAccessor.take()?.unmount() } binding.register(focusable: self) 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 965e362..f5ece0b 100644 --- a/Sources/ElementaryUI/HTMLViews/HTMLElement+Mountable.swift +++ b/Sources/ElementaryUI/HTMLViews/HTMLElement+Mountable.swift @@ -1,12 +1,12 @@ 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, - tx: inout _TransactionContext + ctx: inout _MountContext ) -> _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 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 754a7f3..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, - tx: inout _TransactionContext + ctx: inout _MountContext ) -> _MountedNode { - _MountedNode(view.text, viewContext: context, context: &tx) + _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 35c982f..e8bd850 100644 --- a/Sources/ElementaryUI/HTMLViews/HTMLVoidElement+Mountable.swift +++ b/Sources/ElementaryUI/HTMLViews/HTMLVoidElement+Mountable.swift @@ -1,12 +1,12 @@ 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, - tx: inout _TransactionContext + ctx: inout _MountContext ) -> _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 HTMLVoidElement: _Mountable, View { child: _ElementNode( tag: self.Tag.name, viewContext: context, - tx: &tx, - makeChild: { _, _ in AnyReconcilable(_EmptyNode()) } + ctx: &ctx, + makeChild: { _, _ in _EmptyNode() } ) ) } diff --git a/Sources/ElementaryUI/HTMLViews/StyleModifiers/FilterModifier.swift b/Sources/ElementaryUI/HTMLViews/StyleModifiers/FilterModifier.swift index 91782f7..2113c20 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 @@ -25,11 +25,11 @@ final class FilterModifier: DOMElementModifier { } } - func mount(_ node: DOM.Node, _ context: inout _CommitContext) -> AnyUnmountable { + func mount(_ node: DOM.Node, _ context: inout _MountContext) -> AnyUnmountable { AnyUnmountable(MountedStyleModifier(node, makeLayers(&context), &context)) } - private func makeLayers(_ context: inout _CommitContext) -> [AnyCSSAnimatedValueInstance] { + private func makeLayers(_ context: inout _MountContext) -> [AnyCSSAnimatedValueInstance] { if var layers = upstream.map({ $0.makeLayers(&context) }) { layers.append(AnyCSSAnimatedValueInstance(value.makeInstance())) return layers diff --git a/Sources/ElementaryUI/HTMLViews/StyleModifiers/MountedStyleModifier.swift b/Sources/ElementaryUI/HTMLViews/StyleModifiers/MountedStyleModifier.swift index 7e30bda..87016b4 100644 --- a/Sources/ElementaryUI/HTMLViews/StyleModifiers/MountedStyleModifier.swift +++ b/Sources/ElementaryUI/HTMLViews/StyleModifiers/MountedStyleModifier.swift @@ -7,7 +7,7 @@ final class MountedStyleModifier: Unmountabl var isDirty: Bool = false - init(_ node: DOM.Node, _ layers: [Instance], _ context: inout _CommitContext) { + init(_ node: DOM.Node, _ layers: [Instance], _ context: inout _MountContext) { self.node = node self.accessor = context.dom.makeStyleAccessor(node, cssName: Instance.CSSValue.styleKey) self.values = layers @@ -18,7 +18,11 @@ final class MountedStyleModifier: Unmountabl value.setTarget(selfAsTarget) } - updateDOMNode(&context) + if let combined = reduceCombinedSingleValue() { + accessor.set(combined.cssString) + } else { + startOrUpdateAnimations(dom: context.dom, scheduler: context.scheduler) + } } func invalidate(_ context: inout _TransactionContext) { @@ -32,7 +36,7 @@ final class MountedStyleModifier: Unmountabl clearAllAnimations() accessor.set(combined.cssString) } else { - startOrUpdateAnimations(&context) + startOrUpdateAnimations(dom: context.dom, scheduler: context.scheduler) } isDirty = false @@ -56,7 +60,7 @@ final class MountedStyleModifier: Unmountabl animations.removeAll(keepingCapacity: true) } - private func startOrUpdateAnimations(_ context: inout _CommitContext) { + private func startOrUpdateAnimations(dom: any DOM.Interactor, scheduler: Scheduler) { if animations.isEmpty { logTrace("starting animations") animations.reserveCapacity(values.count) @@ -64,10 +68,10 @@ final class MountedStyleModifier: Unmountabl for (index, value) in values.enumerated() { let progressAnimation = value.progressAnimation(_:) animations.append( - context.dom.animateElement( + dom.animateElement( node, DOM.Animation.KeyframeEffect(value.value, isFirst: index == 0) - ) { [scheduler = context.scheduler, progressAnimation] in + ) { [scheduler, progressAnimation] in logTrace("animation finished") scheduler.scheduleUpdate(progressAnimation) } diff --git a/Sources/ElementaryUI/HTMLViews/StyleModifiers/OpacityModifer.swift b/Sources/ElementaryUI/HTMLViews/StyleModifiers/OpacityModifer.swift index 921ef8e..2064690 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 @@ -16,11 +16,11 @@ final class OpacityModifier: DOMElementModifier { self.value.updateValue(value, &context) } - func mount(_ node: DOM.Node, _ context: inout _CommitContext) -> AnyUnmountable { + func mount(_ node: DOM.Node, _ context: inout _MountContext) -> AnyUnmountable { AnyUnmountable(MountedStyleModifier(node, makeLayers(&context), &context)) } - private func makeLayers(_ context: inout _CommitContext) -> [CSSValueSource.Instance] { + private func makeLayers(_ context: inout _MountContext) -> [CSSValueSource.Instance] { if var layers = upstream.map({ $0.makeLayers(&context) }) { layers.append(value.makeInstance()) return layers diff --git a/Sources/ElementaryUI/HTMLViews/StyleModifiers/TransformModifier.swift b/Sources/ElementaryUI/HTMLViews/StyleModifiers/TransformModifier.swift index 2368243..af4b731 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 @@ -25,11 +25,11 @@ final class TransformModifier: DOMElementModifier { } } - func mount(_ node: DOM.Node, _ context: inout _CommitContext) -> AnyUnmountable { + func mount(_ node: DOM.Node, _ context: inout _MountContext) -> AnyUnmountable { AnyUnmountable(MountedStyleModifier(node, makeLayers(&context), &context)) } - private func makeLayers(_ context: inout _CommitContext) -> [AnyCSSAnimatedValueInstance] { + private func makeLayers(_ context: inout _MountContext) -> [AnyCSSAnimatedValueInstance] { if var layers = upstream.map({ $0.makeLayers(&context) }) { layers.append(AnyCSSAnimatedValueInstance(value.makeInstance())) return layers diff --git a/Sources/ElementaryUI/HTMLViews/View+Binding.swift b/Sources/ElementaryUI/HTMLViews/View+Binding.swift index b940764..66f0134 100644 --- a/Sources/ElementaryUI/HTMLViews/View+Binding.swift +++ b/Sources/ElementaryUI/HTMLViews/View+Binding.swift @@ -91,27 +91,30 @@ struct DOMEffectView: View { static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _MountContext ) -> _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 { // NOTE: 6.2 embedded hack for type inclusion - var context = _CommitContext( + let commitContext = _CommitContext( dom: JSKitDOMInteractor(), scheduler: Scheduler(dom: JSKitDOMInteractor()), currentFrameTime: 0 ) // force inclusion of types used in mount - _ = effect.mount(.init(.init()), &context) + _ = commitContext.withMountContext(transaction: Transaction()) { mountCtx in + var mountCtx = consume mountCtx + return effect.mount(.init(.init()), &mountCtx) + } } #endif 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..455aa14 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 _MountContext ) -> _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..54ac26d 100644 --- a/Sources/ElementaryUI/HTMLViews/_ElementNode.swift +++ b/Sources/ElementaryUI/HTMLViews/_ElementNode.swift @@ -1,233 +1,47 @@ -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, - tx: inout _TransactionContext, - makeChild: (borrowing _ViewContext, inout _TransactionContext) -> 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))" - - logTrace("created element \(identifier) in \(viewContext.parentElement!.identifier)") - viewContext.parentElement!.reportChangedChildren(.elementAdded, tx: &tx) - - var viewContext = copy viewContext - viewContext.parentElement = self - let modifiers = viewContext.takeModifiers() - self.layoutObservers = viewContext.takeLayoutObservers() + let domNode = ctx.dom.createElement(tag) - 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) + var childContext = copy viewContext + let modifiers = childContext.takeModifiers() + let layoutObservers = childContext.takeLayoutObservers() - self.mountedModifieres = modifiers.reversed().map { - $0.mount(ref, &context) - } + var mountedModifiers: [AnyUnmountable] = [] + for modifier in modifiers.reversed() { + mountedModifiers.append(modifier.mount(domNode, &ctx)) } + self.mountedModifiers = mountedModifiers - self.child = makeChild(viewContext, &tx) - } - - init( - root: DOM.Node, - viewContext: consuming _ViewContext, - tx: inout _TransactionContext, - makeChild: (borrowing _ViewContext, inout _TransactionContext) -> AnyReconcilable - ) { - self.domNode = .init(reference: root, status: .unchanged) - self.identifier = "\("_root_"):\(ObjectIdentifier(self))" - - var viewContext = viewContext - let layoutObservers = viewContext.layoutObservers.take() - viewContext.parentElement = self + ctx.appendStaticElement(domNode) - if !layoutObservers.isEmpty { - self.layoutObservers = layoutObservers + 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 } - - self.child = makeChild(viewContext, &tx) } - func updateChild( + mutating func updateChild( _ context: inout _TransactionContext, - as: Node.Type = Node.self, - block: (inout Node, inout _TransactionContext) -> Void + block: (inout Child, 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) - } - } - } - - 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 - } - } - - 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) - } + 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 f6dce88..d95f2a1 100644 --- a/Sources/ElementaryUI/HTMLViews/_TextNode.swift +++ b/Sources/ElementaryUI/HTMLViews/_TextNode.swift @@ -1,63 +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 _TransactionContext) { + init(_ newValue: String, ctx: inout _MountContext) { 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.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 6626956..eee7ab2 100644 --- a/Sources/ElementaryUI/Reconciling/ApplicationRuntime.swift +++ b/Sources/ElementaryUI/Reconciling/ApplicationRuntime.swift @@ -1,12 +1,10 @@ -// 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 @@ -18,40 +16,49 @@ 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 - ) - ) - } + let rootViewContext = _ViewContext() + let mountTransaction = tx.transaction + + tx.scheduler.addCommitAction { [self, rootView, rootViewContext] ctx in + let (child, layoutNodes) = ctx.withMountContext(transaction: mountTransaction) { (ctx: consuming _MountContext) in + let child = AnyReconcilable( + RootView._makeNode(rootView, context: rootViewContext, ctx: &ctx) + ) + // TODO: clean this up - this should be a mount root contaienrs + let (layoutNodes, _) = ctx.takeMountOutput() + return (child, layoutNodes) + } + + 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/LayoutContainer.swift b/Sources/ElementaryUI/Reconciling/LayoutContainer.swift new file mode 100644 index 0000000..75b4266 --- /dev/null +++ b/Sources/ElementaryUI/Reconciling/LayoutContainer.swift @@ -0,0 +1,205 @@ +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 container(MountContainer) + + 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 .container(let container): + container.collect(into: &ops, context: &context) + } + } + + var isStatic: Bool { + switch self { + case .elementNode, .textNode: + true + case .container: + 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 + } + } + + 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/MountContainer.swift b/Sources/ElementaryUI/Reconciling/MountContainer.swift new file mode 100644 index 0000000..d312c73 --- /dev/null +++ b/Sources/ElementaryUI/Reconciling/MountContainer.swift @@ -0,0 +1,644 @@ +final class MountContainer { + private let viewContext: _ViewContext + private var slots: [Slot] + var containerHandle: LayoutContainer.Handle? + + private var scratchOldMiddle: [Int] = [] + private var scratchSources: [Int] = [] + private var scratchOldKeyMap: [_ViewKey: Int] = [:] + private var scratchLeavingByKey: [_ViewKey: Int] = [:] + private var scratchNewMiddle: [Slot] = [] + private var scratchMiddleResult: [Slot] = [] + + private init(context: borrowing _ViewContext, slots: [Slot]) { + self.viewContext = copy context + self.slots = slots + } + + convenience init( + mountedKey key: _ViewKey, + context: borrowing _ViewContext, + ctx: inout _MountContext, + makeNode: (borrowing _ViewContext, inout _MountContext) -> Node + ) { + self.init( + context: context, + slots: [ + Slot.mounted( + key: key, + index: 0, + viewContext: context, + ctx: &ctx, + makeNode: { _, context, mountCtx in + makeNode(context, &mountCtx) + } + ) + ] + ) + } + + convenience init( + mountedKeys keys: some Collection<_ViewKey>, + context: borrowing _ViewContext, + ctx: inout _MountContext, + makeNode: (Int, borrowing _ViewContext, inout _MountContext) -> Node + ) { + guard !keys.isEmpty else { + self.init(context: context, slots: []) + return + } + self.init( + context: context, + slots: keys.enumerated().map { (index, key) in + Slot.mounted( + key: key, + index: index, + viewContext: context, + ctx: &ctx, + makeNode: makeNode + ) + } + ) + } + + func collect(into ops: inout LayoutPass, context: inout _CommitContext) { + if containerHandle == nil { containerHandle = ops.containerHandle } + + for index in slots.indices { + slots[index].collect(into: &ops, context: &context, viewContext: viewContext) + } + + slots.removeAll { $0.isRemoved } + } + + func unmount(_ context: inout _CommitContext) { + for index in slots.indices { + slots[index].unmount(&context) + } + slots.removeAll() + } + + func reportLayoutChange(_ tx: inout _TransactionContext) { + containerHandle?.reportLayoutChange(&tx) + } + + // MARK: - Patch (thin generic wrapper) + + func patch( + keys newKeys: some BidirectionalCollection<_ViewKey>, + tx: inout _TransactionContext, + makeNode: @escaping (Int, borrowing _ViewContext, inout _MountContext) -> Node, + patchNode: (Int, inout Node, inout _TransactionContext) -> Void + ) { + let startIndex = newKeys.startIndex + _performDiff( + newKeyCount: newKeys.count, + newKey: { newKeys[newKeys.index(startIndex, offsetBy: $0)] }, + tx: &tx, + patchSlot: { slotIndex, newKeyIndex, tx in + switch self.slots[slotIndex].slotState { + case .pending, .removed: + self.slots[slotIndex].overwritePending( + transaction: tx.transaction, + create: { context, mountCtx in + AnyReconcilable(makeNode(newKeyIndex, context, &mountCtx)) + } + ) + case .mounted: + _ = self.slots[slotIndex].patchMountedIfActive(as: Node.self) { node in + patchNode(newKeyIndex, &node, &tx) + } + } + }, + makeNewSlot: { key, newKeyIndex, transaction in + .pending(key: key, transaction: transaction) { context, mountCtx in + AnyReconcilable(makeNode(newKeyIndex, context, &mountCtx)) + } + } + ) + } + + // MARK: - Non-generic diff engine (prefix/suffix + LIS) + + private func _performDiff( + newKeyCount: Int, + newKey: (Int) -> _ViewKey, + tx: inout _TransactionContext, + patchSlot: (_ slotIndex: Int, _ newKeyIndex: Int, _ tx: inout _TransactionContext) -> Void, + makeNewSlot: (_ key: _ViewKey, _ newKeyIndex: Int, _ transaction: Transaction) -> Slot + ) { + let oldCount = slots.count + if oldCount == 0 && newKeyCount == 0 { return } + + scratchLeavingByKey.removeAll(keepingCapacity: true) + for i in slots.indices where slots[i].isLeavingInline { + scratchLeavingByKey[slots[i].key] = i + } + + // ── Phase 1: Common prefix ────────────────────────────────────── + var fwdSlot = 0 + var fwdKey = 0 + while fwdSlot < oldCount && fwdKey < newKeyCount { + if !slots[fwdSlot].isActiveForPatch { fwdSlot += 1; continue } + guard slots[fwdSlot].key == newKey(fwdKey) else { break } + patchSlot(fwdSlot, fwdKey, &tx) + fwdSlot += 1; fwdKey += 1 + } + + // ── Phase 2: Common suffix ────────────────────────────────────── + var bwdSlot = oldCount - 1 + var bwdKey = newKeyCount - 1 + while bwdSlot >= fwdSlot && bwdKey >= fwdKey { + if !slots[bwdSlot].isActiveForPatch { bwdSlot -= 1; continue } + guard slots[bwdSlot].key == newKey(bwdKey) else { break } + bwdSlot -= 1; bwdKey -= 1 + } + + // ── Phase 3: Patch suffix in-place (before middle modifies structure) ─ + do { + var ss = bwdSlot + 1 + var sk = bwdKey + 1 + while ss < oldCount && sk < newKeyCount { + if !slots[ss].isActiveForPatch { ss += 1; continue } + patchSlot(ss, sk, &tx) + ss += 1; sk += 1 + } + } + + // ── Determine middle extents ──────────────────────────────────── + let newMiddleCount = max(0, bwdKey - fwdKey + 1) + + scratchOldMiddle.removeAll(keepingCapacity: true) + if fwdSlot <= bwdSlot { + for i in fwdSlot...bwdSlot where slots[i].isActiveForPatch { + scratchOldMiddle.append(i) + } + } + let oldMiddleCount = scratchOldMiddle.count + + if oldMiddleCount == 0 && newMiddleCount == 0 { return } + + // ── Phase 4: Process middle ───────────────────────────────────── + var didStructureChange = false + var didReportLayoutChange = false + + if oldMiddleCount == 0 { + // Pure insertions + didStructureChange = true + scratchNewMiddle.removeAll(keepingCapacity: true) + scratchNewMiddle.reserveCapacity(newMiddleCount) + for j in 0.. bwdSlot { + slots[leavingIdx].slotState = .removed + } + } else { + scratchNewMiddle.append(makeNewSlot(key, keyIdx, tx.transaction)) + } + } + + } else if newMiddleCount == 0 { + // Pure removals + didStructureChange = true + scratchNewMiddle.removeAll(keepingCapacity: true) + for localIdx in 0..= 0 && !inLIS[j] { + slots[scratchOldMiddle[scratchSources[j]]].markMoved() + didStructureChange = true + } + } + + // Build new middle active array in target order + scratchNewMiddle.removeAll(keepingCapacity: true) + scratchNewMiddle.reserveCapacity(newMiddleCount) + for j in 0..= 0 { + scratchNewMiddle.append(slots[scratchOldMiddle[src]]) + } else if src < -1 { + let leavingIdx = -(src + 2) + if !didReportLayoutChange { reportLayoutChange(&tx); didReportLayoutChange = true } + _ = slots[leavingIdx].reviveFromLeaving(tx: &tx, handle: containerHandle) + patchSlot(leavingIdx, keyIdx, &tx) + scratchNewMiddle.append(slots[leavingIdx]) + if leavingIdx < fwdSlot || leavingIdx > bwdSlot { + slots[leavingIdx].slotState = .removed + } + } else { + scratchNewMiddle.append(makeNewSlot(key, keyIdx, tx.transaction)) + } + } + } + + // ── Phase 5: Reassemble middle ────────────────────────────────── + if fwdSlot <= bwdSlot { + scratchMiddleResult.removeAll(keepingCapacity: true) + scratchMiddleResult.reserveCapacity(max(bwdSlot - fwdSlot + 1, scratchNewMiddle.count)) + var activeCursor = 0 + for i in fwdSlot...bwdSlot { + if slots[i].isLeavingInline { + scratchMiddleResult.append(slots[i]) + } else if !slots[i].isRemoved && activeCursor < scratchNewMiddle.count { + scratchMiddleResult.append(scratchNewMiddle[activeCursor]) + activeCursor += 1 + } + } + while activeCursor < scratchNewMiddle.count { + scratchMiddleResult.append(scratchNewMiddle[activeCursor]) + activeCursor += 1 + } + slots.replaceSubrange(fwdSlot...bwdSlot, with: scratchMiddleResult) + } else if !scratchNewMiddle.isEmpty { + slots.insert(contentsOf: scratchNewMiddle, at: fwdSlot) + } + + scratchNewMiddle.removeAll(keepingCapacity: true) + scratchMiddleResult.removeAll(keepingCapacity: true) + + if didStructureChange && !didReportLayoutChange { + reportLayoutChange(&tx) + } + } + + // MARK: - Longest Increasing Subsequence (O(n log n)) + + private static func _longestIncreasingSubsequence(_ sources: [Int]) -> [Bool] { + let n = sources.count + guard n > 0 else { return [] } + + var result = [Bool](repeating: false, count: n) + var tails: [Int] = [] + var tailIdx: [Int] = [] + var preds = [Int](repeating: -1, count: n) + + for i in 0..= 0 else { continue } + + var lo = 0 + var hi = tails.count + while lo < hi { + let mid = (lo + hi) &>> 1 + if tails[mid] < val { lo = mid + 1 } else { hi = mid } + } + + if lo == tails.count { + tails.append(val) + tailIdx.append(i) + } else { + tails[lo] = val + tailIdx[lo] = i + } + + preds[i] = lo > 0 ? tailIdx[lo - 1] : -1 + } + + if !tails.isEmpty { + var pos = tailIdx[tails.count - 1] + while pos >= 0 { + result[pos] = true + pos = preds[pos] + } + } + + return result + } +} + +extension MountContainer { + + private struct Slot { + struct Pending { + var transaction: Transaction + var create: (borrowing _ViewContext, inout _MountContext) -> AnyReconcilable + } + + struct Mounted { + enum MountState { + case active + case leaving + case left + } + + var node: AnyReconcilable + var layoutNodes: [LayoutNode] + var mountState: MountState + var didMove: Bool + var transitionCoordinator: MountRootTransitionCoordinator? + } + + enum SlotState { + case pending(Pending) + case mounted(Mounted) + case removed + } + + var key: _ViewKey + var slotState: SlotState + + var isRemoved: Bool { + if case .removed = slotState { return true } + return false + } + + var isActiveForPatch: Bool { + switch slotState { + case .pending: + return true + case .mounted(let mounted): + return mounted.mountState == .active + case .removed: + return false + } + } + + var isMounted: Bool { + if case .mounted = slotState { return true } + return false + } + + var isLeavingInline: Bool { + switch slotState { + case .mounted(let mounted): + return mounted.mountState == .leaving || mounted.mountState == .left + case .pending, .removed: + return false + } + } + + static func pending( + key: _ViewKey, + transaction: Transaction, + create: @escaping (borrowing _ViewContext, inout _MountContext) -> AnyReconcilable + ) -> Self { + .init( + key: key, + slotState: .pending( + .init(transaction: transaction, create: create) + ) + ) + } + + static func mounted( + key: _ViewKey, + index: Int, + viewContext: borrowing _ViewContext, + ctx: inout _MountContext, + makeNode: (Int, borrowing _ViewContext, inout _MountContext) -> Node + ) -> Self { + let context = copy viewContext + let (node, layoutNodes, transitionCoordinator) = ctx.withMountRootContext { (rootCtx: consuming _MountContext) in + var rootCtx = consume rootCtx + let node = AnyReconcilable(makeNode(index, context, &rootCtx)) + let (layoutNodes, transitionCoordinator) = rootCtx.takeMountOutput() + return (node, layoutNodes, transitionCoordinator) + } + + return .init( + key: key, + slotState: .mounted( + .init( + node: node, + layoutNodes: layoutNodes, + mountState: .active, + didMove: false, + transitionCoordinator: transitionCoordinator + ) + ) + ) + } + + mutating func overwritePending( + transaction: Transaction, + create: @escaping (borrowing _ViewContext, inout _MountContext) -> AnyReconcilable + ) { + slotState = .pending(.init(transaction: transaction, create: create)) + } + + mutating func markMoved() { + guard case .mounted(var mounted) = slotState else { return } + mounted.didMove = true + slotState = .mounted(mounted) + } + + @discardableResult + mutating func beginLeaving( + tx: inout _TransactionContext, + handle: LayoutContainer.Handle? + ) -> Bool { + switch slotState { + case .pending: + slotState = .removed + return false + case .mounted(var mounted): + for layoutNode in mounted.layoutNodes { + if case let .elementNode(element) = layoutNode { + handle?.reportLeavingElement(element, &tx) + } + } + + let shouldDeferRemoval = mounted.transitionCoordinator?.beginRemoval(tx: &tx, handle: handle) ?? false + mounted.mountState = shouldDeferRemoval ? .leaving : .left + slotState = .mounted(mounted) + return true + case .removed: + return false + } + } + + @discardableResult + mutating func reviveFromLeaving( + tx: inout _TransactionContext, + handle: LayoutContainer.Handle? + ) -> Bool { + guard case .mounted(var mounted) = slotState else { return false } + + switch mounted.mountState { + case .active: + return false + case .leaving, .left: + break + } + + mounted.transitionCoordinator?.cancelRemoval(tx: &tx) + mounted.mountState = .active + mounted.didMove = true + + for layoutNode in mounted.layoutNodes { + if case let .elementNode(element) = layoutNode { + handle?.reportReenteringElement(element, &tx) + } + } + + slotState = .mounted(mounted) + return true + } + + mutating func collect( + into ops: inout LayoutPass, + context: inout _CommitContext, + viewContext: borrowing _ViewContext + ) { + switch slotState { + case .pending(let pending): + let contextCopy = copy viewContext + let (node, layoutNodes, transitionCoordinator) = context.withMountContext(transaction: pending.transaction) { mountCtx in + var mountCtx = consume mountCtx + let node = pending.create(contextCopy, &mountCtx) + let (layoutNodes, transitionCoordinator) = mountCtx.takeMountOutput() + return (node, layoutNodes, transitionCoordinator) + } + + transitionCoordinator?.scheduleEnterIdentityIfNeeded(scheduler: context.scheduler) + + let mounted = Mounted( + node: node, + layoutNodes: layoutNodes, + mountState: .active, + didMove: false, + transitionCoordinator: transitionCoordinator + ) + collectLayoutNodes(mounted.layoutNodes, kind: .added, into: &ops, context: &context) + slotState = .mounted(mounted) + + case .mounted(var mounted): + if case .leaving = mounted.mountState, + mounted.transitionCoordinator?.consumeDeferredRemovalReadySignal() == true + { + mounted.mountState = .left + } + + let kind: LayoutPass.Entry.Status + switch mounted.mountState { + case .active: + kind = mounted.didMove ? .moved : .unchanged + case .leaving: + kind = .unchanged + case .left: + kind = .removed + } + + collectLayoutNodes(mounted.layoutNodes, kind: kind, into: &ops, context: &context) + + switch mounted.mountState { + case .active: + mounted.didMove = false + slotState = .mounted(mounted) + case .leaving: + slotState = .mounted(mounted) + case .left: + mounted.node.unmount(&context) + slotState = .removed + } + + case .removed: + break + } + } + + mutating func unmount(_ context: inout _CommitContext) { + guard case let .mounted(mounted) = slotState else { + slotState = .removed + return + } + + mounted.node.unmount(&context) + slotState = .removed + } + + @discardableResult + func patchMountedIfActive( + as type: Node.Type = Node.self, + _ body: (inout Node) -> Void + ) -> Bool { + _ = type + guard case let .mounted(mounted) = slotState, + mounted.mountState == .active + else { + return false + } + + mounted.node.modify(as: Node.self, body) + return true + } + + private func collectLayoutNodes( + _ layoutNodes: [LayoutNode], + kind: LayoutPass.Entry.Status, + into ops: inout LayoutPass, + context: inout _CommitContext + ) { + let startIndex = ops.entries.count + for layoutNode in layoutNodes { + layoutNode.collect(into: &ops, context: &context) + } + + guard kind != .unchanged else { return } + for entryIndex in startIndex.. 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/_MountContext.swift b/Sources/ElementaryUI/Reconciling/_MountContext.swift new file mode 100644 index 0000000..11d23b0 --- /dev/null +++ b/Sources/ElementaryUI/Reconciling/_MountContext.swift @@ -0,0 +1,147 @@ +public struct _MountContext: ~Copyable { + private var layoutNodes: [LayoutNode] = [] + private(set) var isStatic: Bool = true + + private var transitionCoordinator: MountRootTransitionCoordinator? + private var isRoot: Bool + + // NOTE: we could use a fancy Inout<_CommitContext> here.. but maybe not worth it + let scheduler: Scheduler + let dom: any DOM.Interactor + let currentFrameTime: Double + let transaction: Transaction + + fileprivate init( + dom: any DOM.Interactor, + scheduler: Scheduler, + currentFrameTime: Double, + transaction: Transaction, + isRoot: Bool + ) { + self.dom = dom + self.scheduler = scheduler + self.currentFrameTime = currentFrameTime + self.transaction = transaction + self.isRoot = isRoot + } + + mutating func appendStaticElement(_ node: DOM.Node) { + appendLayoutNode(.elementNode(node)) + } + + mutating func appendStaticText(_ node: DOM.Node) { + appendLayoutNode(.textNode(node)) + } + + mutating func appendContainer(_ container: MountContainer) { + appendLayoutNode(.container(container)) + } + + mutating func appendTransitionParticipant(_ participant: any MountRootTransitionParticipant) -> TransitionPhase { + guard isRoot else { return .identity } + + let coordinator = transitionCoordinator ?? MountRootTransitionCoordinator(mountTransaction: transaction) + let phase = coordinator.register(participant) + self.transitionCoordinator = coordinator + return phase + } + + func withMountRootContext(_ body: (consuming _MountContext) -> R) -> R { + body( + _MountContext( + dom: dom, + scheduler: scheduler, + currentFrameTime: currentFrameTime, + transaction: transaction, + isRoot: true + ) + ) + } + + mutating func withTransitionBoundary(_ body: (inout _MountContext) -> R) -> R { + let previousIsRoot = isRoot + isRoot = false + let result = body(&self) + isRoot = previousIsRoot + return result + } + + func withChildContext(_ body: (consuming _MountContext) -> R) -> R { + body( + _MountContext( + dom: dom, + scheduler: scheduler, + currentFrameTime: currentFrameTime, + transaction: transaction, + isRoot: false + ) + ) + } + + func withCommitContext(_ body: (inout _CommitContext) -> R) -> R { + var commitContext = _CommitContext( + dom: dom, + scheduler: scheduler, + currentFrameTime: currentFrameTime + ) + return body(&commitContext) + } + + consuming func takeMountOutput() -> ([LayoutNode], MountRootTransitionCoordinator?) { + (layoutNodes, transitionCoordinator) + } + + consuming func mountInDOMNode(_ domNode: DOM.Node, observers: [any DOMLayoutObserver]) -> LayoutContainer? { + if isStatic { + 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 + } + + let container = LayoutContainer( + domNode: domNode, + scheduler: scheduler, + layoutNodes: layoutNodes, + layoutObservers: observers + ) + withCommitContext { commit in + container.mountInitial(&commit) + } + return container + } + + private mutating func appendLayoutNode(_ node: LayoutNode) { + isStatic = isStatic && node.isStatic + layoutNodes.append(node) + } +} + +private extension LayoutNode { + var staticDOMNode: DOM.Node { + switch self { + case .elementNode(let node), .textNode(let node): node + case .container: fatalError("dynamic container in static node list") + } + } +} + +extension _CommitContext { + func withMountContext( + transaction: Transaction, + _ body: (consuming _MountContext) -> R + ) -> R { + body( + _MountContext( + dom: dom, + scheduler: scheduler, + currentFrameTime: currentFrameTime, + transaction: transaction, + isRoot: true + ) + ) + } +} diff --git a/Sources/ElementaryUI/Reconciling/_Reconcilable.swift b/Sources/ElementaryUI/Reconciling/_Reconcilable.swift index 285b637..7f0bbe5 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,20 +25,11 @@ 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) } + // TODO: make this mutating to prepare for ~Copyable all the way func modify(as type: R.Type = R.self, _ body: (inout R) -> Void) { let box = unsafeDowncast(self.box, to: _TypedBox.self) body(&box.node) diff --git a/Sources/ElementaryUI/Reconciling/_TransitionStuff.swift b/Sources/ElementaryUI/Reconciling/_TransitionStuff.swift new file mode 100644 index 0000000..cb4fe86 --- /dev/null +++ b/Sources/ElementaryUI/Reconciling/_TransitionStuff.swift @@ -0,0 +1,159 @@ +protocol MountRootTransitionParticipant: AnyObject { + var mountRootDefaultAnimation: Animation? { get } + var mountRootIsMounted: Bool { get } + func mountRootPatchTransitionPhase(_ phase: TransitionPhase, tx: inout _TransactionContext) +} + +final class MountRootTransitionCoordinator { + private var participants: [any MountRootTransitionParticipant] = [] + private var nextRemovalToken: UInt64 = 0 + private var pendingExitCompletions: Int = 0 + private var pendingEnterIdentityPatches: Int = 0 + private var deferredRemovalReady: Bool = false + + private(set) var activeRemovalToken: UInt64? + private let mountTransaction: Transaction + + init(mountTransaction: Transaction) { + self.mountTransaction = mountTransaction + } + + func register(_ participant: any MountRootTransitionParticipant) -> TransitionPhase { + participants.append(participant) + + guard transitionEffectiveAnimation(for: participant, transaction: mountTransaction) != nil else { + return .identity + } + + pendingEnterIdentityPatches += 1 + return .willAppear + } + + var isRemovalInFlight: Bool { + activeRemovalToken != nil + } + + func scheduleEnterIdentityIfNeeded(scheduler: Scheduler) { + let shouldSchedule = pendingEnterIdentityPatches > 0 + pendingEnterIdentityPatches = 0 + guard shouldSchedule else { return } + + let transaction = mountTransaction + scheduler.scheduleUpdate { [coordinator = self, transaction] tx in + coordinator.patchAll(.identity, tx: &tx, transaction: transaction) + } + } + + func beginRemoval(tx: inout _TransactionContext, handle: LayoutContainer.Handle?) -> Bool { + pruneParticipants() + + let live = participants.filter { $0.mountRootIsMounted } + guard !live.isEmpty else { + cancelDeferredRemovalState() + return false + } + + nextRemovalToken &+= 1 + let token = nextRemovalToken + activeRemovalToken = token + pendingExitCompletions = 0 + deferredRemovalReady = false + let scheduler = tx.scheduler + + for participant in live { + let animation = transitionEffectiveAnimation(for: participant, transaction: tx.transaction) + if let animation { + pendingExitCompletions += 1 + tx.withModifiedTransaction { + $0.animation = animation + $0.disablesAnimation = false + $0.addAnimationCompletion { [coordinator = self, scheduler, handle] in + coordinator.notifyExitAnimationCompleted(token: token, scheduler: scheduler, handle: handle) + } + } run: { tx in + participant.mountRootPatchTransitionPhase(.didDisappear, tx: &tx) + } + } else { + participant.mountRootPatchTransitionPhase(.didDisappear, tx: &tx) + } + } + + if pendingExitCompletions == 0 { + activeRemovalToken = nil + return false + } + + return true + } + + func cancelRemoval(tx: inout _TransactionContext) { + cancelDeferredRemovalState() + patchAll(.identity, tx: &tx, transaction: tx.transaction) + } + + func consumeDeferredRemovalReadySignal() -> Bool { + let isReady = deferredRemovalReady + deferredRemovalReady = false + return isReady + } + + private func patchAll(_ phase: TransitionPhase, tx: inout _TransactionContext, transaction: Transaction) { + pruneParticipants() + for participant in participants where participant.mountRootIsMounted { + let animation = transitionEffectiveAnimation(for: participant, transaction: transaction) + if let animation { + tx.withModifiedTransaction { + $0.animation = animation + $0.disablesAnimation = false + } run: { tx in + participant.mountRootPatchTransitionPhase(phase, tx: &tx) + } + } else { + tx.withModifiedTransaction { + $0.animation = nil + $0.disablesAnimation = false + } run: { tx in + participant.mountRootPatchTransitionPhase(phase, tx: &tx) + } + } + } + } + + private func notifyExitAnimationCompleted( + token: UInt64, + scheduler: Scheduler, + handle: LayoutContainer.Handle? + ) { + guard activeRemovalToken == token else { return } + if pendingExitCompletions > 0 { + pendingExitCompletions -= 1 + } + guard pendingExitCompletions == 0 else { return } + + activeRemovalToken = nil + deferredRemovalReady = true + scheduler.scheduleUpdate { tx in + handle?.reportLayoutChange(&tx) + } + } + + private func cancelDeferredRemovalState() { + activeRemovalToken = nil + pendingExitCompletions = 0 + deferredRemovalReady = false + } + + private func pruneParticipants() { + participants.removeAll { !$0.mountRootIsMounted } + } +} + +private func transitionEffectiveAnimation( + for participant: any MountRootTransitionParticipant, + transaction: Transaction +) -> Animation? { + if transaction.disablesAnimation { + return nil + } + return transaction.animation ?? participant.mountRootDefaultAnimation +} diff --git a/Sources/ElementaryUI/Reconciling/_ViewContext.swift b/Sources/ElementaryUI/Reconciling/_ViewContext.swift index 4abfb36..c8b307b 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? 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..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, - tx: inout _TransactionContext + ctx: inout _MountContext ) -> _MountedNode { _EmptyNode() } diff --git a/Sources/ElementaryUI/StructureViews/ForEach+Mountable.swift b/Sources/ElementaryUI/StructureViews/ForEach+Mountable.swift index 75addeb..4b7c781 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 _MountContext ) -> _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..2d37ca6 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 _MountContext ) -> _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..5cea7af 100644 --- a/Sources/ElementaryUI/StructureViews/KeyedView.swift +++ b/Sources/ElementaryUI/StructureViews/KeyedView.swift @@ -8,12 +8,15 @@ public struct _KeyedView: View { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _MountContext ) -> _MountedNode { .init( key: view.key, - child: Value._makeNode(view.value, context: context, tx: &tx), - context: context + context: context, + ctx: &ctx, + makeNode: { context, ctx in + Value._makeNode(view.value, context: context, ctx: &ctx) + } ) } @@ -26,12 +29,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..6b6ba50 100644 --- a/Sources/ElementaryUI/StructureViews/Optional+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/Optional+Mountable.swift @@ -5,13 +5,27 @@ extension Optional: _Mountable where Wrapped: _Mountable { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _MountContext ) -> _MountedNode { switch view { case let .some(view): - return .init(a: Wrapped._makeNode(view, context: context, tx: &tx), context: context) + return .init( + isA: true, + context: context, + ctx: &ctx, + makeActive: { c, mountCtx in + Wrapped._makeNode(view, context: c, ctx: &mountCtx) + } + ) case .none: - return .init(b: _EmptyNode(), context: context) + return .init( + isA: false, + context: context, + ctx: &ctx, + makeActive: { c, mountCtx in + EmptyHTML._makeNode(EmptyHTML(), context: c, ctx: &mountCtx) + } + ) } } @@ -24,7 +38,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..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 _TransactionContext) -> _PlaceholderNode + private var makeNodeFn: (borrowing _ViewContext, inout _MountContext) -> _PlaceholderNode - init(makeNodeFn: @escaping (borrowing _ViewContext, inout _TransactionContext) -> _PlaceholderNode) { + init(makeNodeFn: @escaping (borrowing _ViewContext, inout _MountContext) -> _PlaceholderNode) { self.makeNodeFn = makeNodeFn } } @@ -27,16 +27,17 @@ extension PlaceholderContentView: _Mountable { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _MountContext ) -> _MountedNode { - view.makeNodeFn(context, &tx) + view.makeNodeFn(context, &ctx) } public static func _patchNode( _ 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 b4a7912..3362d5f 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 _MountContext ) -> _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 _MountContext ) -> _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 _MountContext ) -> _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 _MountContext ) -> _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 _MountContext ) -> _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 _MountContext ) -> _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 _MountContext ) -> 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/ViewKey.swift b/Sources/ElementaryUI/StructureViews/ViewKey.swift index e806c90..ba0b6f0 100644 --- a/Sources/ElementaryUI/StructureViews/ViewKey.swift +++ b/Sources/ElementaryUI/StructureViews/ViewKey.swift @@ -1,32 +1,45 @@ import Reactivity public struct _ViewKey: Equatable, Hashable, CustomStringConvertible { + @usableFromInline + enum Storage: Equatable, Hashable { + case text(HashableUTF8View) + case number(Int) + } - // NOTE: this was an enum once, but maybe we don't need this? in any case, let's keep the option for mutiple values here open @usableFromInline - let _value: HashableUTF8View + let storage: Storage @inlinable public init(_ value: String) { - self._value = HashableUTF8View(value) + self.storage = .text(HashableUTF8View(value)) + } + + @inlinable + public init(_ value: Int) { + self.storage = .number(value) } @inlinable public init(_ value: T) { - self._value = HashableUTF8View(value.description) + // Keep compatibility for existing call-sites while keeping strict typed equality. + self.storage = .text(HashableUTF8View(value.description)) } public var description: String { - _value.stringValue + switch storage { + case .text(let text): text.stringValue + case .number(let number): String(number) + } } @inlinable public static func == (lhs: Self, rhs: Self) -> Bool { - lhs._value == rhs._value + lhs.storage == rhs.storage } @inlinable public func hash(into hasher: inout Hasher) { - _value.hash(into: &hasher) + storage.hash(into: &hasher) } } diff --git a/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift b/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift index 84c64a7..ef5573d 100644 --- a/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift +++ b/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift @@ -1,173 +1,73 @@ -public struct _ConditionalNode { - enum State { - case a(AnyReconcilable) - case b(AnyReconcilable) - case aWithBLeaving(AnyReconcilable, AnyReconcilable) - case bWithALeaving(AnyReconcilable, AnyReconcilable) - } - - private var state: State - private var context: _ViewContext +private let keyA = _ViewKey(0) +private let keyB = _ViewKey(1) - init(a: consuming AnyReconcilable? = nil, b: consuming AnyReconcilable? = nil, context: borrowing _ViewContext) { - switch (a, b) { - 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") - } +public struct _ConditionalNode: _Reconcilable { + let container: MountContainer - self.context = copy 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( + isA: Bool, + context: borrowing _ViewContext, + ctx: inout _MountContext, + makeActive: (borrowing _ViewContext, inout _MountContext) -> Node + ) { + let initialKey = isA ? keyA : keyB + let containerContext = copy context + self.container = MountContainer( + mountedKey: initialKey, + context: consume containerContext, + ctx: &ctx, + makeNode: makeActive + ) + ctx.appendContainer(container) } mutating func patchWithA( tx: inout _TransactionContext, - makeNode: (borrowing _ViewContext, inout _TransactionContext) -> NodeA, + makeNode: @escaping (borrowing _ViewContext, inout _MountContext) -> 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) - } - state = .a(a) - case .b(let b): - let a = AnyReconcilable(makeNode(context, &tx)) - b.apply(.startRemoval, &tx) - self.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) - } - state = .aWithBLeaving(a, b) - case .bWithALeaving(let b, let a): - let a = a - a.modify(as: NodeA.self) { node in - updateNode(&node, &tx) - } - a.apply(.cancelRemoval, &tx) - b.apply(.startRemoval, &tx) - self.context.parentElement?.reportChangedChildren(.elementMoved, tx: &tx) - state = .aWithBLeaving(a, b) - } + patchBranch( + key: keyA, + tx: &tx, + makeNode: makeNode, + updateNode: updateNode + ) } mutating func patchWithB( tx: inout _TransactionContext, - makeNode: (borrowing _ViewContext, inout _TransactionContext) -> NodeB, + makeNode: @escaping (borrowing _ViewContext, inout _MountContext) -> 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) - } - state = .b(b) - case .a(let a): - let b = AnyReconcilable(makeNode(context, &tx)) - a.apply(.startRemoval, &tx) - self.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) - } - state = .bWithALeaving(b, a) - case .bWithALeaving(let b, let a): - let b = b - b.modify(as: NodeB.self) { node in - updateNode(&node, &tx) - } - state = .bWithALeaving(b, a) - } - } - -} - -extension _ConditionalNode: _Reconcilable { - public mutating func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { - switch state { - case .a(let a): - a.collectChildren(&ops, &context) - case .b(let b): - b.collectChildren(&ops, &context) - case .aWithBLeaving(let a, let b): - a.collectChildren(&ops, &context) - - let isRemovalCompleted = ops.withRemovalTracking { ops in - b.collectChildren(&ops, &context) - } - - if isRemovalCompleted { - b.unmount(&context) - 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) - } - - b.collectChildren(&ops, &context) - - if isRemovalCompleted { - a.unmount(&context) - state = .b(b) - } - } - } - - 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) - } + patchBranch( + key: keyB, + tx: &tx, + makeNode: makeNode, + updateNode: updateNode + ) } public consuming func unmount(_ context: inout _CommitContext) { - switch state { - case .a(let a): - a.unmount(&context) - case .b(let b): - b.unmount(&context) - case .aWithBLeaving(let a, let b), .bWithALeaving(let b, let a): - a.unmount(&context) - b.unmount(&context) - } + container.unmount(&context) } } -extension _ContainerLayoutPass { - mutating func withRemovalTracking(_ block: (inout Self) -> Void) -> Bool { - let index = entries.count - block(&self) - var isRemoved = true - for entry in entries[index..( + key: _ViewKey, + tx: inout _TransactionContext, + makeNode: @escaping (borrowing _ViewContext, inout _MountContext) -> Node, + updateNode: (inout Node, inout _TransactionContext) -> Void + ) { + container.patch( + keys: CollectionOfOne(key), + tx: &tx, + makeNode: { _, context, mountCtx in + makeNode(context, &mountCtx) + }, + patchNode: { _, node, tx in + updateNode(&node, &tx) } - } - return isRemoved + ) } } diff --git a/Sources/ElementaryUI/StructureViews/_EmptyNode.swift b/Sources/ElementaryUI/StructureViews/_EmptyNode.swift index d3233a2..a7a4e3f 100644 --- a/Sources/ElementaryUI/StructureViews/_EmptyNode.swift +++ b/Sources/ElementaryUI/StructureViews/_EmptyNode.swift @@ -1,7 +1,4 @@ public struct _EmptyNode: _Reconcilable { - public func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) {} - - public func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) {} - - public consuming func unmount(_ context: inout _CommitContext) {} + public consuming func unmount(_ context: inout _CommitContext) { + } } diff --git a/Sources/ElementaryUI/StructureViews/_ForEachNode.swift b/Sources/ElementaryUI/StructureViews/_ForEachNode.swift index 9f2ff6a..478a7d9 100644 --- a/Sources/ElementaryUI/StructureViews/_ForEachNode.swift +++ b/Sources/ElementaryUI/StructureViews/_ForEachNode.swift @@ -2,28 +2,46 @@ import Reactivity public final class _ForEachNode: _Reconcilable where Data: Collection, Content: _KeyReadableView, Content.Value: _Mountable { + private typealias Evaluation = (views: [Content], keys: [_ViewKey], session: TrackingSession) + private var data: Data private var contentBuilder: @Sendable (Data.Element) -> Content - private var keyedNode: _KeyedNode? - private var context: _ViewContext private var trackingSession: TrackingSession? = nil - - public var depthInTree: Int - var asFunctionNode: AnyFunctionNode! + private var container: MountContainer! + private var asFunctionNode: AnyFunctionNode! init( data: consuming Data, contentBuilder: @escaping @Sendable (Data.Element) -> Content, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _MountContext ) { self.data = data self.contentBuilder = contentBuilder - self.context = copy context - self.depthInTree = context.functionDepth - self.asFunctionNode = AnyFunctionNode(self) - runFunction(tx: &tx) + self.asFunctionNode = AnyFunctionNode(self, depthInTree: context.functionDepth) + + let (views, keys, session) = Self.evaluateViewsAndKeys( + data: self.data, + contentBuilder: self.contentBuilder, + onWillSet: { [scheduler = ctx.scheduler, asFunctionNode] in + scheduler.invalidateFunction(asFunctionNode) + } + ) + + self.trackingSession = session + + let containerContext = copy context + self.container = MountContainer( + mountedKeys: keys, + context: consume containerContext, + ctx: &ctx, + makeNode: { index, context, mountCtx in + Content.Value._makeNode(views[index]._value, context: context, ctx: &mountCtx) + } + ) + + ctx.appendContainer(container) } func patch( @@ -33,77 +51,86 @@ where Data: Collection, Content: _KeyReadableView, Content.Value: _Mountable { ) { self.data = data self.contentBuilder = contentBuilder - tx.addFunction(asFunctionNode) + runFunction(tx: &tx) } func runFunction(tx: inout _TransactionContext) { 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 = tx.scheduler, asFunctionNode = asFunctionNode!] in - scheduler.invalidateFunction(asFunctionNode) - } + let (views, keys, session) = evaluateViewsAndKeys(scheduler: tx.scheduler) 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)) + container.patch( + keys: keys, + tx: &tx, + 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) } + ) + } - keyedNode = _KeyedNode(keys: keys, children: children, context: context) - return - } + public consuming func unmount(_ context: inout _CommitContext) { + self.trackingSession.take()?.cancel() + container.unmount(&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) - } + private static func makeOnWillSet( + scheduler: Scheduler, + functionNode: AnyFunctionNode + ) -> @Sendable () -> Void { + { + scheduler.invalidateFunction(functionNode) } } - public func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { - keyedNode?.collectChildren(&ops, &context) + private func makeOnWillSet(scheduler: Scheduler) -> @Sendable () -> Void { + Self.makeOnWillSet(scheduler: scheduler, functionNode: asFunctionNode) } - public func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { - keyedNode?.apply(op, &tx) + private func evaluateViewsAndKeys(scheduler: Scheduler) -> Evaluation { + Self.evaluateViewsAndKeys( + data: data, + contentBuilder: contentBuilder, + onWillSet: makeOnWillSet(scheduler: scheduler) + ) } - public func unmount(_ context: inout _CommitContext) { - self.trackingSession.take()?.cancel() - let node = keyedNode.take() - node?.unmount(&context) + private static func evaluateViewsAndKeys( + data: Data, + contentBuilder: @escaping @Sendable (Data.Element) -> Content, + onWillSet: @escaping @Sendable () -> Void + ) -> Evaluation { + 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: onWillSet + ) + + return (views, keys, session) } } extension AnyFunctionNode { - init(_ forEachNode: _ForEachNode) { - self.identifier = ObjectIdentifier(forEachNode) - self.depthInTree = forEachNode.depthInTree - self.runUpdate = forEachNode.runFunction + init(_ node: _ForEachNode, depthInTree: Int) { + self.identifier = ObjectIdentifier(node) + self.depthInTree = depthInTree + self.runUpdate = node.runFunction } } diff --git a/Sources/ElementaryUI/StructureViews/_HTMLArray+Mountable.swift b/Sources/ElementaryUI/StructureViews/_HTMLArray+Mountable.swift index 373f809..b9f920d 100644 --- a/Sources/ElementaryUI/StructureViews/_HTMLArray+Mountable.swift +++ b/Sources/ElementaryUI/StructureViews/_HTMLArray+Mountable.swift @@ -4,16 +4,23 @@ extension _HTMLArray: _Mountable, View where Element: View { public static func _makeNode( _ view: consuming Self, context: borrowing _ViewContext, - tx: inout _TransactionContext + ctx: inout _MountContext ) -> _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] = [] + let estimatedCount = view.value.underestimatedCount + keys.reserveCapacity(estimatedCount) + + for (index, _) in view.value.enumerated() { + keys.append(_ViewKey(index)) + } + + return _MountedNode( + keys: keys, + context: context, + ctx: &ctx, + makeNode: { index, context, ctx in + Element._makeNode(view.value[index], context: context, ctx: &ctx) + } ) } @@ -24,18 +31,17 @@ extension _HTMLArray: _Mountable, View where Element: View { ) { // maybe we can optimize this // NOTE: written with cast for this https://github.com/swiftlang/swift/issues/83895 - let indexes = view.value.indices.map { _ViewKey(String($0 as Int)) } + let indexes = view.value.indices.map { _ViewKey($0 as Int) } node.patch( 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..b686171 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 _MountContext ) -> _MountedNode { switch view.value { case let .trueContent(content): - return .init(a: TrueContent._makeNode(content, context: context, tx: &tx), context: context) + return .init( + isA: true, + context: context, + ctx: &ctx, + makeActive: { c, mountCtx in + TrueContent._makeNode(content, context: c, ctx: &mountCtx) + } + ) case let .falseContent(content): - return .init(b: FalseContent._makeNode(content, context: context, tx: &tx), context: context) + return .init( + isA: false, + context: context, + ctx: &ctx, + makeActive: { c, mountCtx in + FalseContent._makeNode(content, context: c, ctx: &mountCtx) + } + ) } } @@ -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..5bf8799 100644 --- a/Sources/ElementaryUI/StructureViews/_KeyedNode.swift +++ b/Sources/ElementaryUI/StructureViews/_KeyedNode.swift @@ -1,265 +1,70 @@ -public struct _KeyedNode { - private var keys: [_ViewKey] - private var children: [AnyReconcilable?] - private var leavingChildren: LeavingChildrenTracker = .init() - private let viewContext: _ViewContext - - init(keys: [_ViewKey], children: [AnyReconcilable?], context: borrowing _ViewContext) { - assert(keys.count == children.count) - self.keys = keys - self.children = children - self.viewContext = copy context - } - - 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) }, - context: context +public struct _KeyedNode: _Reconcilable { + let container: MountContainer + + init( + keys: [_ViewKey], + context: borrowing _ViewContext, + ctx: inout _MountContext, + makeNode: (Int, borrowing _ViewContext, inout _MountContext) -> Node + ) { + let containerContext = copy context + self.container = MountContainer( + mountedKeys: keys, + context: consume containerContext, + ctx: &ctx, + makeNode: makeNode ) + ctx.appendContainer(container) } - init(key: _ViewKey, child: some _Reconcilable, context: borrowing _ViewContext) { - self.init(CollectionOfOne((key: key, node: child)), context: context) + init( + key: _ViewKey, + context: borrowing _ViewContext, + ctx: inout _MountContext, + makeNode: (borrowing _ViewContext, inout _MountContext) -> Node + ) { + let containerContext = copy context + self.container = MountContainer( + mountedKey: key, + context: consume containerContext, + ctx: &ctx, + makeNode: makeNode + ) + ctx.appendContainer(container) } mutating func patch( key: _ViewKey, context: inout _TransactionContext, as: Node.Type = Node.self, - makeOrPatchNode: (inout Node?, borrowing _ViewContext, inout _TransactionContext) -> Void + makeNode: @escaping (borrowing _ViewContext, inout _MountContext) -> 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) } ) } mutating func patch( _ newKeys: some BidirectionalCollection<_ViewKey>, context: inout _TransactionContext, - as: Node.Type = Node.self, - makeOrPatchNode: (Int, inout Node?, borrowing _ViewContext, inout _TransactionContext) -> Void + as type: Node.Type = Node.self, + makeNode: @escaping (Int, borrowing _ViewContext, inout _MountContext) -> 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) - - // TODO: clean this up - guard !(keys.isEmpty && leavingChildren.entries.isEmpty) else { - // fast add-all case - keys = newKeysArray - children = Array(repeating: nil, count: keys.count) - - for index in keys.indices { - children.withNode(index: index, as: Node.self) { node in - makeOrPatchNode(index, &node, self.viewContext, &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) - } - assert(children[index] != nil, "unexpected nil child on collection") - } - return - } - - let diff = newKeysArray.difference(from: keys).inferringMoves() - 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 - - 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") - } - - if movedTo != nil { - node.apply(.markAsMoved, &context) - moversCache[offset] = consume node - } else { - node.apply(.startRemoval, &context) - self.viewContext.parentElement?.reportChangedChildren(.elementMoved, tx: &context) - leavingChildren.append(key, atIndex: offset, value: node) - } - case let .insert(offset, element: key, associatedWith: movedFrom): - var node: AnyReconcilable? = nil - - if let movedFrom { - logTrace("move \(key) from \(movedFrom) to \(offset)") - node = moversCache.removeValue(forKey: movedFrom) - precondition(node != nil, "mover not found in cache") - } - - children.insert(node, 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) - } - assert(children[index] != nil, "unexpected nil child on collection") - } - } - - mutating func fastRemoveAll(context: inout _TransactionContext) { - self.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) - } - - keys.removeAll() - children.removeAll() - } -} - -extension _KeyedNode: _Reconcilable { - public func apply(_ op: _ReconcileOp, _ tx: inout _TransactionContext) { - for index in children.indices { - children[index]?.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) - } - - while nextInsertionPoint != nil { - let removed = leavingChildren.commitAndCheckRemoval(at: lIndex, ops: &ops, context: &context) - if !removed { lIndex += 1 } - nextInsertionPoint = leavingChildren.insertionIndex(for: lIndex) - } + _ = type + container.patch( + keys: newKeys, + tx: &context, + makeNode: makeNode, + patchNode: patchNode + ) } public consuming func unmount(_ context: inout _CommitContext) { - for index in children.indices { - children[index]?.unmount(&context) - } - - children.removeAll() - for entry in leavingChildren.entries { - entry.value.unmount(&context) - } - leavingChildren.entries.removeAll() - } -} - -private extension _KeyedNode { - struct LeavingChildrenTracker { //: ~Copyable { - struct Entry { - let key: _ViewKey - var originalMountIndex: Int - var value: AnyReconcilable - } - - 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? - let newEntry = Entry(key: key, originalMountIndex: index, value: value) - if let insertIndex = entries.firstIndex(where: { $0.originalMountIndex > index }) { - entries.insert(newEntry, at: insertIndex) - } else { - entries.append(newEntry) - } - } - - mutating func reflectInsertionAt(_ index: Int) { - shiftEntriesFromIndexUpwards(index, by: 1) - } - - 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) - } - - if isRemovalCommitted { - let entry = entries.remove(at: index) - shiftEntriesFromIndexUpwards(entry.originalMountIndex, by: -1) - entry.value.unmount(&context) - return true - } else { - return false - } - } - - 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! - } - } + container.unmount(&context) } } diff --git a/Sources/ElementaryUI/StructureViews/_TupleNode.swift b/Sources/ElementaryUI/StructureViews/_TupleNode.swift index c2bdf26..bd69200 100644 --- a/Sources/ElementaryUI/StructureViews/_TupleNode.swift +++ b/Sources/ElementaryUI/StructureViews/_TupleNode.swift @@ -1,4 +1,4 @@ -// FIXME:NONCOPYABLE tuples currently do not support ~Copyable +// FIXME: NONCOPYABLE tuples currently do not support ~Copyable public struct _TupleNode: _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 4834bc9..a2f3e2b 100644 --- a/Sources/ElementaryUI/Transition/_TransitionNode.swift +++ b/Sources/ElementaryUI/Transition/_TransitionNode.swift @@ -9,74 +9,61 @@ 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 _MountContext) { + let view = view self.value = view - placeholderView = PlaceholderContentView(makeNodeFn: self.makePlaceholderNode) + self.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 - ) - ) - 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 - ) - } - } + let initialPhase = ctx.appendTransitionParticipant(self) + self.node = ctx.withTransitionBoundary { childCtx in + makeInitialNode(for: initialPhase, context: context, ctx: &childCtx) } } - 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 _MountContext + ) -> AnyReconcilable { + AnyReconcilable( + T.Body._makeNode( + value.transition.body(content: placeholderView!, phase: phase), + context: context, + ctx: &ctx + ) + ) + } + + 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 } else { @@ -85,64 +72,6 @@ public final class _TransitionNode: _Reconcilable { return node } - 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) - } - } - - public func collectChildren(_ ops: inout _ContainerLayoutPass, _ context: inout _CommitContext) { - node?.collectChildren(&ops, &context) - } - public func unmount(_ context: inout _CommitContext) { node?.unmount(&context) @@ -151,3 +80,17 @@ public final class _TransitionNode: _Reconcilable { additionalPlaceholderNodes.removeAll() } } + +extension _TransitionNode: MountRootTransitionParticipant { + var mountRootDefaultAnimation: Animation? { + value.animation + } + + var mountRootIsMounted: Bool { + node != nil + } + + func mountRootPatchTransitionPhase(_ phase: TransitionPhase, tx: inout _TransactionContext) { + patchTransitionPhase(phase, tx: &tx) + } +} diff --git a/Sources/ElementaryUI/Transition/_TransitionView.swift b/Sources/ElementaryUI/Transition/_TransitionView.swift index 9cd71f9..bb11399 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 _MountContext ) -> _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..ed0485b 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 _MountContext ) { 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,24 +113,12 @@ 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) } } 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() @@ -133,6 +129,7 @@ extension _FunctionNode: _Reconcilable { self.state = nil self.value = nil self.context = nil + self.asFunctionNode = nil } } diff --git a/Sources/ElementaryUI/Views/Function/_FunctionView.swift b/Sources/ElementaryUI/Views/Function/_FunctionView.swift index 59560ff..51512c7 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 _MountContext ) -> _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..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, - tx: inout _TransactionContext + ctx: inout _MountContext ) -> _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 _MountContext ) -> _MountedNode { fatalError("This should never be called") } 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")) } diff --git a/Tests/ElementaryUITests/Reconciler/DOMMountingTests.swift b/Tests/ElementaryUITests/Reconciler/DOMMountingTests.swift index c927a1d..39fa67c 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: "