diff --git a/Sources/ElementaryUI/HTMLViews/ElementModifiers/LayoutObserver.swift b/Sources/ElementaryUI/HTMLViews/ElementModifiers/LayoutObserver.swift index db37d16..e1ca4a3 100644 --- a/Sources/ElementaryUI/HTMLViews/ElementModifiers/LayoutObserver.swift +++ b/Sources/ElementaryUI/HTMLViews/ElementModifiers/LayoutObserver.swift @@ -7,6 +7,10 @@ protocol DOMLayoutObserver: Unmountable { struct DOMLayoutObservers { private var storage: [any DOMLayoutObserver] = [] + var isEmpty: Bool { + storage.isEmpty + } + mutating func add(_ observer: any DOMLayoutObserver) { storage.append(observer) } diff --git a/Sources/ElementaryUI/HTMLViews/_ElementNode.swift b/Sources/ElementaryUI/HTMLViews/_ElementNode.swift index ce2ddd8..a5fd71f 100644 --- a/Sources/ElementaryUI/HTMLViews/_ElementNode.swift +++ b/Sources/ElementaryUI/HTMLViews/_ElementNode.swift @@ -15,10 +15,24 @@ public struct _ElementNode: _Reconcilable { ctx: inout _MountContext, makeChild: (borrowing _ViewContext, inout _MountContext) -> Child ) { - var childContext = copy viewContext - let domNode = ctx.dom.createElement(tag) + ctx.appendStaticElement(domNode) + + guard !viewContext.hasNoUpstreamModifiers else { + // no upstream: apply attributes directly, skip context copy and _AttributeModifier check + ctx.dom.addHTMLAttributes(domNode, attributes) + self.attributes = .inline(node: domNode, lastApplied: attributes) + self.child = ctx.withChildContext { (mctx: consuming _MountContext) in + let child = makeChild(viewContext, &mctx) + _ = mctx.mountInDOMNode(domNode, observers: []) + return child + } + return + } + + var childContext = copy viewContext + if childContext.modifiers[_AttributeModifier.key] != nil { // upstream modifier exists: chain through _AttributeModifier as before let modifier = _AttributeModifier(value: attributes, upstream: childContext.modifiers) @@ -39,8 +53,6 @@ public struct _ElementNode: _Reconcilable { self.mountedModifiers.append(modifier.mount(domNode, &ctx)) } - ctx.appendStaticElement(domNode) - 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? @@ -58,10 +70,7 @@ public struct _ElementNode: _Reconcilable { modifier.updateValue(attributes, &context) case .inline(let node, let lastApplied): if attributes != lastApplied { - let (prev, next, n) = (lastApplied, attributes, node) - context.scheduler.addCommitAction { ctx in - ctx.dom.applyHTMLAttributes(n, from: prev, to: next) - } + context.scheduler.addCommitAction(.patchAttributes(node: node, from: lastApplied, to: attributes)) self.attributes = .inline(node: node, lastApplied: attributes) } } @@ -77,3 +86,9 @@ public struct _ElementNode: _Reconcilable { mountedModifiers.removeAll() } } + +private extension _ViewContext { + var hasNoUpstreamModifiers: Bool { + modifiers.isEmpty && layoutObservers.isEmpty + } +} diff --git a/Sources/ElementaryUI/HTMLViews/_TextNode.swift b/Sources/ElementaryUI/HTMLViews/_TextNode.swift index d95f2a1..0cddc18 100644 --- a/Sources/ElementaryUI/HTMLViews/_TextNode.swift +++ b/Sources/ElementaryUI/HTMLViews/_TextNode.swift @@ -12,9 +12,7 @@ public struct _TextNode: _Reconcilable { guard !value.utf8Equals(newValue) else { return } self.value = newValue - tx.scheduler.addCommitAction { [self] ctx in - ctx.dom.patchText(domNode, with: value) - } + tx.scheduler.addCommitAction(.patchText(node: domNode, text: value)) } public consuming func unmount(_ context: inout _CommitContext) { diff --git a/Sources/ElementaryUI/Reconciling/ApplicationRuntime.swift b/Sources/ElementaryUI/Reconciling/ApplicationRuntime.swift index 2e0ec6f..566b901 100644 --- a/Sources/ElementaryUI/Reconciling/ApplicationRuntime.swift +++ b/Sources/ElementaryUI/Reconciling/ApplicationRuntime.swift @@ -1,6 +1,7 @@ +import BasicContainers + final class ApplicationRuntime { - private var rootChild: AnyReconcilable? - private var rootContainer: LayoutContainer? + private var rootNode: _ConditionalNode? private var scheduler: Scheduler init(dom: DOMInteractor) { @@ -16,43 +17,40 @@ final class ApplicationRuntime { tx.withModifiedTransaction { $0.disablesAnimation = true } run: { tx in - let rootViewContext = _ViewContext() - let mountTransaction = tx.transaction - - // TODO: clean this up and reuse a mount container - tx.scheduler.addCommitAction { [self, rootView, rootViewContext] ctx in - let (child, container) = ctx.withMountContext(transaction: mountTransaction) { (ctx: consuming _MountContext) in - let child = AnyReconcilable( - RootView._makeNode(rootView, context: rootViewContext, ctx: &ctx) - ) - let container = ctx.consumeAsLayoutContainer(domNode: domRoot, observers: []) - return (child, container) - } - - container.mountInitial(&ctx) - - self.rootChild = child - self.rootContainer = container + + tx.scheduler.addCommitAction { [self, transaction = tx.transaction, rootView] ctx in + self.rootNode = + ctx.withMountContext(transaction: transaction) { + (mountCtx: consuming _MountContext) in + let node = _ConditionalNode( + isA: true, + context: _ViewContext(), + ctx: &mountCtx, + makeActive: { viewContext, mountCtx in + RootView._makeNode(rootView, context: viewContext, ctx: &mountCtx) + } + ) + + _ = mountCtx.mountInDOMNode(domRoot, observers: []) + return node + } } } } } func unmount() { - guard let rootChild, let rootContainer else { return } + guard var rootNode = self.rootNode.take() else { return } - scheduler.scheduleUpdate { [rootChild, rootContainer] tx in + scheduler.scheduleUpdate { tx in tx.withModifiedTransaction { $0.disablesAnimation = true } run: { tx in - tx.scheduler.addPlacementAction { ctx in - rootContainer.removeAllChildren(&ctx) - rootChild.unmount(&ctx) - } + rootNode.patchWithB(tx: &tx, makeNode: { _, _ in _EmptyNode() }, updateNode: { _, _ in }) + + // Break the root container/layout cycle after patch-driven removals are committed. + tx.scheduler.addCommitAction { ctx in rootNode.unmount(&ctx) } } } - - self.rootChild = nil - self.rootContainer = nil } } diff --git a/Sources/ElementaryUI/Reconciling/LayoutContainer.swift b/Sources/ElementaryUI/Reconciling/LayoutContainer.swift index 285c369..288b57d 100644 --- a/Sources/ElementaryUI/Reconciling/LayoutContainer.swift +++ b/Sources/ElementaryUI/Reconciling/LayoutContainer.swift @@ -34,28 +34,11 @@ final class LayoutContainer { } } - // TODO: I get rid of this... - func removeAllChildren(_ context: inout _CommitContext) { - context.scheduler.scratch.withLayoutEntryScratchFrame { scratch in - var ops = LayoutPass(layoutContainer: self, scratch: consume scratch) - layoutNodes.collect(into: &ops, context: &context, op: .removed) - let entryCount = ops.count - - ops.consume { entries in - if entryCount == 1 { - context.dom.removeChild(entries[unchecked: 0].reference, from: domNode) - } else if entryCount > 1 { - context.dom.clearChildren(in: domNode) - } - } - } - } - private func markDirty(_ tx: inout _TransactionContext) { guard !isDirty else { return } isDirty = true - tx.scheduler.addPlacementAction(performLayout(_:)) + tx.scheduler.addCommitAction(.patchLayout(container: self)) for observer in layoutObservers { observer.willLayoutChildren(parent: domNode, context: &tx) } @@ -73,7 +56,7 @@ final class LayoutContainer { } } - private func performLayout(_ context: inout _CommitContext) { + func performLayout(_ context: inout _CommitContext) { guard isDirty else { return } isDirty = false diff --git a/Sources/ElementaryUI/Reconciling/MountContainer.swift b/Sources/ElementaryUI/Reconciling/MountContainer.swift index 6d76a9d..436cf7f 100644 --- a/Sources/ElementaryUI/Reconciling/MountContainer.swift +++ b/Sources/ElementaryUI/Reconciling/MountContainer.swift @@ -106,9 +106,9 @@ final class MountContainer { removed.unmount(&context) } - activeSlots.removeAll(keepingCapacity: true) - leavingSlots.removeAll(keepingCapacity: true) - removedNodes.removeAll(keepingCapacity: true) + activeSlots.removeAll() + leavingSlots.removeAll() + removedNodes.removeAll() containerHandle = nil } diff --git a/Sources/ElementaryUI/Reconciling/Scheduler.swift b/Sources/ElementaryUI/Reconciling/Scheduler.swift index d9f3dd1..819474d 100644 --- a/Sources/ElementaryUI/Reconciling/Scheduler.swift +++ b/Sources/ElementaryUI/Reconciling/Scheduler.swift @@ -15,6 +15,26 @@ struct AnyAnimatable { let progressAnimation: (inout _TransactionContext) -> AnimationProgressResult } +enum CommitAction { + case patchText(node: DOM.Node, text: String) + case patchAttributes(node: DOM.Node, from: _AttributeStorage, to: _AttributeStorage) + case patchLayout(container: LayoutContainer) + case closure((inout _CommitContext) -> Void) + + func apply(context: inout _CommitContext) { + switch self { + case let .patchText(node, text): + context.dom.patchText(node, with: text) + case let .patchAttributes(node, previousAttributes, newAttributes): + context.dom.applyHTMLAttributes(node, from: previousAttributes, to: newAttributes) + case let .patchLayout(container): + container.performLayout(&context) + case let .closure(action): + action(&context) + } + } +} + final class Scheduler { private let dom: any DOM.Interactor @@ -27,8 +47,7 @@ final class Scheduler { // Work queues private var pendingFunctions: PendingFunctionQueue = .init() private var pendingUpdates: UniqueArray<(inout _TransactionContext) -> Void> = .init() - private var pendingCommitActions: UniqueArray<(inout _CommitContext) -> Void> = .init() - private var pendingPlacements: UniqueArray<(inout _CommitContext) -> Void> = .init() + private var pendingCommitActions: UniqueArray = .init() private var pendingEffects: UniqueArray<() -> Void> = .init() private var runningAnimations: [AnyAnimatable] = [] @@ -55,7 +74,7 @@ final class Scheduler { } private var hasCommitWork: Bool { - !pendingCommitActions.isEmpty || !pendingPlacements.isEmpty + !pendingCommitActions.isEmpty } private var needsAnimationFrame: Bool { @@ -90,14 +109,13 @@ final class Scheduler { pendingUpdates.append(callback) } - func addCommitAction(_ action: @escaping (inout _CommitContext) -> Void) { + func addCommitAction(_ action: CommitAction) { assert(isUpdateCycleActive, "Commit actions must be added during an update cycle") pendingCommitActions.append(action) } - func addPlacementAction(_ action: @escaping (inout _CommitContext) -> Void) { - assert(isUpdateCycleActive, "Placement actions must be added during an update cycle") - pendingPlacements.append(action) + func addCommitAction(_ action: @escaping (inout _CommitContext) -> Void) { + addCommitAction(.closure(action)) } // Effects are run after all pending transactions are committed @@ -239,20 +257,10 @@ final class Scheduler { precondition(passes <= maxCommitPasses, "Exceeded \(maxCommitPasses) commit passes - infinite loop?") if !pendingCommitActions.isEmpty { - var actions: UniqueArray<(inout _CommitContext) -> Void> = .init() + var actions: UniqueArray = .init() swap(&actions, &pendingCommitActions) for index in actions.indices { - actions[index](&context) - } - } - - if !pendingPlacements.isEmpty { - var placements: UniqueArray<(inout _CommitContext) -> Void> = .init() - swap(&placements, &pendingPlacements) - var index = placements.endIndex - while index > placements.startIndex { - index -= 1 - placements[index](&context) + actions[index].apply(context: &context) } } } diff --git a/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift b/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift index dfbeb13..dd3f48c 100644 --- a/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift +++ b/Sources/ElementaryUI/StructureViews/_ConditionalNode.swift @@ -11,10 +11,9 @@ public struct _ConditionalNode: _Reconcilable { makeActive: (borrowing _ViewContext, inout _MountContext) -> Node ) { let initialKey = isA ? keyA : keyB - let containerContext = copy context self.container = MountContainer( mountedKey: initialKey, - context: consume containerContext, + context: context, ctx: &ctx, makeNode: makeActive ) diff --git a/Sources/ElementaryUI/StructureViews/_ForEachNode.swift b/Sources/ElementaryUI/StructureViews/_ForEachNode.swift index 3224e2c..bec645e 100644 --- a/Sources/ElementaryUI/StructureViews/_ForEachNode.swift +++ b/Sources/ElementaryUI/StructureViews/_ForEachNode.swift @@ -28,11 +28,9 @@ where Data: Collection, Content: _KeyReadableView, Content.Value: _Mountable { self.trackingSession = session - let containerContext = copy context - self.container = MountContainer( mountedKeyStorage: keysScratch.span, - context: consume containerContext, + context: context, ctx: &ctx, makeNode: { index, context, mountCtx in Content.Value._makeNode(self.viewsScratch[index]._value, context: context, ctx: &mountCtx) diff --git a/Sources/ElementaryUI/StructureViews/_KeyedNode.swift b/Sources/ElementaryUI/StructureViews/_KeyedNode.swift index ec88f80..780812b 100644 --- a/Sources/ElementaryUI/StructureViews/_KeyedNode.swift +++ b/Sources/ElementaryUI/StructureViews/_KeyedNode.swift @@ -7,10 +7,9 @@ public struct _KeyedNode: _Reconcilable { ctx: inout _MountContext, makeNode: (Int, borrowing _ViewContext, inout _MountContext) -> Node ) { - let containerContext = copy context self.container = MountContainer( mountedKeyStorage: keys, - context: consume containerContext, + context: context, ctx: &ctx, makeNode: makeNode ) @@ -23,10 +22,9 @@ public struct _KeyedNode: _Reconcilable { ctx: inout _MountContext, makeNode: (borrowing _ViewContext, inout _MountContext) -> Node ) { - let containerContext = copy context self.container = MountContainer( mountedKey: key, - context: consume containerContext, + context: context, ctx: &ctx, makeNode: makeNode )