Experimental "React Fiber"-like Reconciler (#471)

* Initial Reconciler using visitor pattern

* Preliminary static HTML renderer using the new reconciler

* Add environment

* Initial DOM renderer

* Nearly-working and simplified reconciler

* Working reconciler for HTML/DOM renderers

* Rename files, and split code across files

* Add some documentation and refinements

* Remove GraphRendererTests

* Re-add Optional.body for StackReconciler-based renderers

* Add benchmarks to compare the stack/fiber reconcilers

* Fix some issues created for the StackReconciler, and add update benchmarks

* Add BenchmarkState.measure to only calculate the time to update

* Fix hang in update shallow benchmark

* Fix build errors

* Address build issues

* Remove File.swift headers

* Rename Element -> FiberElement and Element.Data -> FiberElement.Content

* Add doc comment explaining unowned usage

* Add doc comments explaining implicitly unwrapped optionals

* Attempt to use Swift instead of JS for applying mutations

* Fix issue with not applying updates to DOMFiberElement

* Add comment explaining manual implementation of Hashable for PropertyInfo

* Fix linter issues

* Remove dynamicMember label from subscript

* Re-enable carton test

* Re-enable TokamakDemo with StackReconciler

Co-authored-by: Max Desiatov <max@desiatov.com>
This commit is contained in:
Carson Katri 2022-05-23 12:14:16 -04:00 committed by GitHub
parent a41ac37500
commit 8177fc8cae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1970 additions and 8 deletions

View File

@ -22,7 +22,7 @@ jobs:
- uses: actions/checkout@v2
- uses: swiftwasm/swiftwasm-action@v5.6
with:
shell-action: carton test
shell-action: carton test --environment node
# Disabled until macos-12 is available on GitHub Actions, which is required for Xcode 13.3
# core_macos_build:

View File

@ -135,6 +135,7 @@ let package = Package(
dependencies: [
.product(name: "Benchmark", package: "swift-benchmark"),
"TokamakCore",
"TokamakTestRenderer",
]
),
.executableTarget(
@ -194,6 +195,13 @@ let package = Package(
name: "TokamakTestRenderer",
dependencies: ["TokamakCore"]
),
.testTarget(
name: "TokamakReconcilerTests",
dependencies: [
"TokamakCore",
"TokamakTestRenderer",
]
),
.testTarget(
name: "TokamakTests",
dependencies: ["TokamakTestRenderer"]

View File

@ -0,0 +1,268 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 2/15/22.
//
@_spi(TokamakCore) public extension FiberReconciler {
/// A manager for a single `View`.
///
/// There are always 2 `Fiber`s for every `View` in the tree,
/// a current `Fiber`, and a work in progress `Fiber`.
/// They point to each other using the `alternate` property.
///
/// The current `Fiber` represents the `View` as it is currently rendered on the screen.
/// The work in progress `Fiber` (the `alternate` of current),
/// is used in the reconciler to compute the new tree.
///
/// When reconciling, the tree is recomputed from
/// the root of the state change on the work in progress `Fiber`.
/// Each node in the fiber tree is updated to apply any changes,
/// and a list of mutations needed to get the rendered output to match is created.
///
/// After the entire tree has been traversed, the current and work in progress trees are swapped,
/// making the updated tree the current one,
/// and leaving the previous current tree available to apply future changes on.
final class Fiber: CustomDebugStringConvertible {
weak var reconciler: FiberReconciler<Renderer>?
/// The underlying `View` instance.
///
/// Stored as an IUO because we must use the `bindProperties` method
/// to create the `View` with its dependencies setup,
/// which requires all stored properties be set before using.
@_spi(TokamakCore) public var view: Any!
/// Outputs from evaluating `View._makeView`
///
/// Stored as an IUO because creating `ViewOutputs` depends on
/// the `bindProperties` method, which requires
/// all stored properties be set before using.
/// `outputs` is guaranteed to be set in the initializer.
var outputs: ViewOutputs!
/// A function to visit `view` generically.
///
/// Stored as an IUO because it captures a weak reference to `self`, which requires all stored properties be set before capturing.
var visitView: ((ViewVisitor) -> ())!
/// The identity of this `View`
var id: Identity?
/// The mounted element, if this is a Renderer primitive.
var element: Renderer.ElementType?
/// The first child node.
@_spi(TokamakCore) public var child: Fiber?
/// This node's right sibling.
@_spi(TokamakCore) public var sibling: Fiber?
/// An unowned reference to the parent node.
///
/// Parent references are `unowned` (as opposed to `weak`)
/// because the parent will always exist if a child does.
/// If the parent is released, the child is released with it.
unowned var parent: Fiber?
/// The nearest parent that can be mounted on.
unowned var elementParent: Fiber?
/// The cached type information for the underlying `View`.
var typeInfo: TypeInfo?
/// Boxes that store `State` data.
var state: [PropertyInfo: MutableStorage] = [:]
/// The WIP node if this is current, or the current node if this is WIP.
weak var alternate: Fiber?
var createAndBindAlternate: (() -> Fiber)?
/// A box holding a value for an `@State` property wrapper.
/// Will call `onSet` (usually a `Reconciler.reconcile` call) when updated.
final class MutableStorage {
private(set) var value: Any
let onSet: () -> ()
func setValue(_ newValue: Any, with transaction: Transaction) {
value = newValue
onSet()
}
init(initialValue: Any, onSet: @escaping () -> ()) {
value = initialValue
self.onSet = onSet
}
}
public enum Identity: Hashable {
case explicit(AnyHashable)
case structural(index: Int)
}
init<V: View>(
_ view: inout V,
element: Renderer.ElementType?,
parent: Fiber?,
elementParent: Fiber?,
childIndex: Int,
reconciler: FiberReconciler<Renderer>?
) {
self.reconciler = reconciler
child = nil
sibling = nil
self.parent = parent
self.elementParent = elementParent
typeInfo = TokamakCore.typeInfo(of: V.self)
let viewInputs = ViewInputs<V>(
view: view,
proposedSize: parent?.outputs.layoutComputer?.proposeSize(for: view, at: childIndex),
environment: parent?.outputs.environment ?? .init(.init())
)
state = bindProperties(to: &view, typeInfo, viewInputs)
self.view = view
outputs = V._makeView(viewInputs)
visitView = { [weak self] in
guard let self = self else { return }
// swiftlint:disable:next force_cast
$0.visit(self.view as! V)
}
if let element = element {
self.element = element
} else if Renderer.isPrimitive(view) {
self.element = .init(from: .init(from: view))
}
let alternateView = view
createAndBindAlternate = {
// Create the alternate lazily
let alternate = Fiber(
bound: alternateView,
alternate: self,
outputs: self.outputs,
typeInfo: self.typeInfo,
element: self.element,
parent: self.parent?.alternate,
elementParent: self.elementParent?.alternate,
reconciler: reconciler
)
self.alternate = alternate
if self.parent?.child === self {
self.parent?.alternate?.child = alternate // Link it with our parent's alternate.
} else {
// Find our left sibling.
var node = self.parent?.child
while node?.sibling !== self {
guard node?.sibling != nil else { return alternate }
node = node?.sibling
}
if node?.sibling === self {
node?.alternate?.sibling = alternate // Link it with our left sibling's alternate.
}
}
return alternate
}
}
init<V: View>(
bound view: V,
alternate: Fiber,
outputs: ViewOutputs,
typeInfo: TypeInfo?,
element: Renderer.ElementType?,
parent: FiberReconciler<Renderer>.Fiber?,
elementParent: Fiber?,
reconciler: FiberReconciler<Renderer>?
) {
self.view = view
self.alternate = alternate
self.reconciler = reconciler
self.element = element
child = nil
sibling = nil
self.parent = parent
self.elementParent = elementParent
self.typeInfo = typeInfo
self.outputs = outputs
visitView = { [weak self] in
guard let self = self else { return }
// swiftlint:disable:next force_cast
$0.visit(self.view as! V)
}
}
private func bindProperties<V: View>(
to view: inout V,
_ typeInfo: TypeInfo?,
_ viewInputs: ViewInputs<V>
) -> [PropertyInfo: MutableStorage] {
guard let typeInfo = typeInfo else { return [:] }
var state: [PropertyInfo: MutableStorage] = [:]
for property in typeInfo.properties where property.type is DynamicProperty.Type {
var value = property.get(from: view)
if var storage = value as? WritableValueStorage {
let box = MutableStorage(initialValue: storage.anyInitialValue, onSet: { [weak self] in
guard let self = self else { return }
self.reconciler?.reconcile(from: self)
})
state[property] = box
storage.getter = { box.value }
storage.setter = { box.setValue($0, with: $1) }
value = storage
} else if var environmentReader = value as? EnvironmentReader {
environmentReader.setContent(from: viewInputs.environment.environment)
value = environmentReader
}
property.set(value: value, on: &view)
}
return state
}
func update<V: View>(
with view: inout V,
childIndex: Int
) -> Renderer.ElementType.Content? {
typeInfo = TokamakCore.typeInfo(of: V.self)
let viewInputs = ViewInputs<V>(
view: view,
proposedSize: parent?.outputs.layoutComputer?.proposeSize(for: view, at: childIndex),
environment: parent?.outputs.environment ?? .init(.init())
)
state = bindProperties(to: &view, typeInfo, viewInputs)
self.view = view
outputs = V._makeView(viewInputs)
visitView = { [weak self] in
guard let self = self else { return }
// swiftlint:disable:next force_cast
$0.visit(self.view as! V)
}
if Renderer.isPrimitive(view) {
return .init(from: view)
} else {
return nil
}
}
public var debugDescription: String {
flush()
}
private func flush(level: Int = 0) -> String {
let spaces = String(repeating: " ", count: level)
return """
\(spaces)\(String(describing: typeInfo?.type ?? Any.self)
.split(separator: "<")[0])\(element != nil ? "(\(element!))" : "") {
\(child?.flush(level: level + 2) ?? "")
\(spaces)}
\(sibling?.flush(level: level) ?? "")
"""
}
}
}

View File

@ -0,0 +1,33 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 2/15/22.
//
/// A reference type that points to a `Renderer`-specific element that has been mounted.
/// For instance, a DOM node in the `DOMFiberRenderer`.
public protocol FiberElement: AnyObject {
associatedtype Content: FiberElementContent
var content: Content { get }
init(from content: Content)
func update(with content: Content)
}
/// The data used to create an `FiberElement`.
///
/// We re-use `FiberElement` instances in the `Fiber` tree,
/// but can re-create and copy `FiberElementContent` as often as needed.
public protocol FiberElementContent: Equatable {
init<V: View>(from primitiveView: V)
}

View File

@ -0,0 +1,320 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 2/15/22.
//
/// A reconciler modeled after React's [Fiber reconciler](https://reactjs.org/docs/faq-internals.html#what-is-react-fiber)
public final class FiberReconciler<Renderer: FiberRenderer> {
/// The root node in the `Fiber` tree that represents the `View`s currently rendered on screen.
@_spi(TokamakCore) public var current: Fiber!
/// The alternate of `current`, or the work in progress tree root.
///
/// We must keep a strong reference to both the current and alternate tree roots,
/// as they only keep weak references to each other.
private var alternate: Fiber!
/// The `FiberRenderer` used to create and update the `Element`s on screen.
public let renderer: Renderer
public init<V: View>(_ renderer: Renderer, _ view: V) {
self.renderer = renderer
var view = view.environmentValues(renderer.defaultEnvironment)
current = .init(
&view,
element: renderer.rootElement,
parent: nil,
elementParent: nil,
childIndex: 0,
reconciler: self
)
// Start by building the initial tree.
alternate = current.createAndBindAlternate?()
reconcile(from: current)
}
/// Convert the first level of children of a `View` into a linked list of `Fiber`s.
struct TreeReducer: ViewReducer {
final class Result {
// For references
let fiber: Fiber?
let visitChildren: (TreeReducer.Visitor) -> ()
unowned var parent: Result?
var child: Result?
var sibling: Result?
var newContent: Renderer.ElementType.Content?
// For reducing
var childrenCount: Int = 0
var lastSibling: Result?
var nextExisting: Fiber?
var nextExistingAlternate: Fiber?
init(
fiber: Fiber?,
visitChildren: @escaping (TreeReducer.Visitor) -> (),
parent: Result?,
child: Fiber?,
alternateChild: Fiber?,
newContent: Renderer.ElementType.Content? = nil
) {
self.fiber = fiber
self.visitChildren = visitChildren
self.parent = parent
nextExisting = child
nextExistingAlternate = alternateChild
self.newContent = newContent
}
}
static func reduce<V>(into partialResult: inout Result, nextView: V) where V: View {
// Create the node and its element.
var nextView = nextView
let resultChild: Result
if let existing = partialResult.nextExisting {
// If a fiber already exists, simply update it with the new view.
let newContent = existing.update(
with: &nextView,
childIndex: partialResult.childrenCount
)
resultChild = Result(
fiber: existing,
visitChildren: nextView._visitChildren,
parent: partialResult,
child: existing.child,
alternateChild: existing.alternate?.child,
newContent: newContent
)
partialResult.nextExisting = existing.sibling
} else {
// Otherwise, create a new fiber for this child.
let fiber = Fiber(
&nextView,
element: partialResult.nextExistingAlternate?.element,
parent: partialResult.fiber,
elementParent: partialResult.fiber?.element != nil
? partialResult.fiber
: partialResult.fiber?.elementParent,
childIndex: partialResult.childrenCount,
reconciler: partialResult.fiber?.reconciler
)
// If a fiber already exists for an alternate, link them.
if let alternate = partialResult.nextExistingAlternate {
fiber.alternate = alternate
partialResult.nextExistingAlternate = alternate.sibling
}
resultChild = Result(
fiber: fiber,
visitChildren: nextView._visitChildren,
parent: partialResult,
child: nil,
alternateChild: fiber.alternate?.child
)
}
// Keep track of the index of the child so the LayoutComputer can propose sizes.
partialResult.childrenCount += 1
// Get the last child element we've processed, and add the new child as its sibling.
if let lastSibling = partialResult.lastSibling {
lastSibling.fiber?.sibling = resultChild.fiber
lastSibling.sibling = resultChild
} else {
// Otherwise setup the first child
partialResult.fiber?.child = resultChild.fiber
partialResult.child = resultChild
}
partialResult.lastSibling = resultChild
}
}
final class ReconcilerVisitor: ViewVisitor {
unowned let reconciler: FiberReconciler<Renderer>
/// The current, mounted `Fiber`.
var currentRoot: Fiber
var mutations = [Mutation<Renderer>]()
init(root: Fiber, reconciler: FiberReconciler<Renderer>) {
self.reconciler = reconciler
currentRoot = root
}
/// Walk the current tree, recomputing at each step to check for discrepancies.
///
/// Parent-first depth-first traversal.
/// Take this `View` tree for example.
/// ```swift
/// VStack {
/// HStack {
/// Text("A")
/// Text("B")
/// }
/// Text("C")
/// }
/// ```
/// Basically, we read it like this:
/// 1. `VStack` has children, so we go to it's first child, `HStack`.
/// 2. `HStack` has children, so we go further to it's first child, `Text`.
/// 3. `Text` has no child, but has a sibling, so we go to that.
/// 4. `Text` has no child and no sibling, so we return to the `HStack`.
/// 5. We've already read the children, so we look for a sibling, `Text`.
/// 6. `Text` has no children and no sibling, so we return to the `VStack.`
/// We finish once we've returned to the root element.
/// ```
///
/// VStack
///
/// 1
///
/// HStack
///
/// 2
///
/// Text
/// 6 4
/// 3
/// 5
/// Text
///
///
///
/// Text
///
/// ```
func visit<V>(_ view: V) where V: View {
let alternateRoot: Fiber?
if let alternate = currentRoot.alternate {
alternateRoot = alternate
} else {
alternateRoot = currentRoot.createAndBindAlternate?()
}
let rootResult = TreeReducer.Result(
fiber: alternateRoot, // The alternate is the WIP node.
visitChildren: view._visitChildren,
parent: nil,
child: alternateRoot?.child,
alternateChild: currentRoot.child
)
var node = rootResult
/// A dictionary keyed by the unique ID of an element, with a value indicating what index
/// we are currently at. This ensures we place children in the correct order, even if they are
/// at different levels in the `View` tree.
var elementIndices = [ObjectIdentifier: Int]()
/// Compare `node` with its alternate, and add any mutations to the list.
func reconcile(_ node: TreeReducer.Result) {
if let element = node.fiber?.element,
let parent = node.fiber?.elementParent?.element
{
let key = ObjectIdentifier(parent)
let index = elementIndices[key, default: 0]
if node.fiber?.alternate == nil { // This didn't exist before (no alternate)
mutations.append(.insert(element: element, parent: parent, index: index))
} else if node.fiber?.typeInfo?.type != node.fiber?.alternate?.typeInfo?.type,
let previous = node.fiber?.alternate?.element
{
// This is a completely different type of view.
mutations.append(.replace(parent: parent, previous: previous, replacement: element))
} else if let newContent = node.newContent,
newContent != element.content
{
// This is the same type of view, but its backing data has changed.
mutations.append(.update(previous: element, newContent: newContent))
}
elementIndices[key] = index + 1
}
}
// The main reconciler loop.
while true {
// Perform work on the node.
reconcile(node)
// Compute the children of the node.
let reducer = TreeReducer.Visitor(initialResult: node)
node.visitChildren(reducer)
// Setup the alternate if it doesn't exist yet.
if node.fiber?.alternate == nil {
_ = node.fiber?.createAndBindAlternate?()
}
// Walk all down all the way into the deepest child.
if let child = reducer.result.child {
node = child
continue
} else if let alternateChild = node.fiber?.alternate?.child {
walk(alternateChild) { node in
if let element = node.element,
let parent = node.elementParent?.element
{
// The alternate has a child that no longer exists.
// Removals must happen in reverse order, so a child element
// is removed before its parent.
self.mutations.insert(.remove(element: element, parent: parent), at: 0)
}
return true
}
}
if reducer.result.child == nil {
node.fiber?.child = nil // Make sure we clear the child if there was none
}
// If we've made it back to the root, then exit.
if node === rootResult {
return
}
// Now walk back up the tree until we find a sibling.
while node.sibling == nil {
var alternateSibling = node.fiber?.alternate?.sibling
while alternateSibling != nil { // The alternate had siblings that no longer exist.
if let element = alternateSibling?.element,
let parent = alternateSibling?.elementParent?.element
{
// Removals happen in reverse order, so a child element is removed before
// its parent.
mutations.insert(.remove(element: element, parent: parent), at: 0)
}
alternateSibling = alternateSibling?.sibling
}
// When we walk back to the root, exit
guard let parent = node.parent,
parent !== currentRoot.alternate
else {
return
}
node = parent
}
// Walk across to the sibling, and repeat.
// swiftlint:disable:next force_unwrap
node = node.sibling!
}
}
}
func reconcile(from root: Fiber) {
// Create a list of mutations.
let visitor = ReconcilerVisitor(root: root, reconciler: self)
root.visitView(visitor)
// Apply mutations to the rendered output.
renderer.commit(visitor.mutations)
// Swap the root out for its alternate.
// Essentially, making the work in progress tree the current,
// and leaving the current available to be the work in progress
// on our next update.
let child = root.child
root.child = root.alternate?.child
root.alternate?.child = child
}
}

View File

@ -0,0 +1,39 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 2/15/22.
//
/// A renderer capable of performing mutations specified by a `FiberReconciler`.
public protocol FiberRenderer {
/// The element class this renderer uses.
associatedtype ElementType: FiberElement
/// Check whether a `View` is a primitive for this renderer.
static func isPrimitive<V>(_ view: V) -> Bool where V: View
/// Apply the mutations to the elements.
func commit(_ mutations: [Mutation<Self>])
/// The root element all top level views should be mounted on.
var rootElement: ElementType { get }
/// The smallest set of initial `EnvironmentValues` needed for this renderer to function.
var defaultEnvironment: EnvironmentValues { get }
}
public extension FiberRenderer {
var defaultEnvironment: EnvironmentValues { .init() }
@discardableResult
func render<V: View>(_ view: V) -> FiberReconciler<Self> {
.init(self, view)
}
}

View File

@ -0,0 +1,25 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 2/16/22.
//
import Foundation
/// A type that is able to propose sizes for its children.
protocol LayoutComputer {
/// Will be called every time a child is evaluated.
/// The calls will always be in order, and no more than one call will be made per child.
func proposeSize<V: View>(for child: V, at index: Int) -> CGSize
}

View File

@ -0,0 +1,31 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 2/15/22.
//
public enum Mutation<Renderer: FiberRenderer> {
case insert(
element: Renderer.ElementType,
parent: Renderer.ElementType,
index: Int
)
case remove(element: Renderer.ElementType, parent: Renderer.ElementType?)
case replace(
parent: Renderer.ElementType,
previous: Renderer.ElementType,
replacement: Renderer.ElementType
)
case update(previous: Renderer.ElementType, newContent: Renderer.ElementType.Content)
}

View File

@ -0,0 +1,86 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 2/7/22.
//
import Foundation
/// Data passed to `_makeView` to create the `ViewOutputs` used in reconciling/rendering.
public struct ViewInputs<V: View> {
let view: V
/// The size proposed by this view's parent.
let proposedSize: CGSize?
let environment: EnvironmentBox
}
/// Data used to reconcile and render a `View` and its children.
public struct ViewOutputs {
/// A container for the current `EnvironmentValues`.
/// This is stored as a reference to avoid copying the environment when unnecessary.
let environment: EnvironmentBox
let preferences: _PreferenceStore
/// The size requested by this view.
let size: CGSize
/// The `LayoutComputer` used to propose sizes for the children of this view.
let layoutComputer: LayoutComputer?
}
final class EnvironmentBox {
let environment: EnvironmentValues
init(_ environment: EnvironmentValues) {
self.environment = environment
}
}
extension ViewOutputs {
init<V: View>(
inputs: ViewInputs<V>,
environment: EnvironmentValues? = nil,
preferences: _PreferenceStore? = nil,
size: CGSize? = nil,
layoutComputer: LayoutComputer? = nil
) {
// Only replace the EnvironmentBox when we change the environment. Otherwise the same box can be reused.
self.environment = environment.map(EnvironmentBox.init) ?? inputs.environment
self.preferences = preferences ?? .init()
self.size = size ?? inputs.proposedSize ?? .zero
self.layoutComputer = layoutComputer
}
}
public extension View {
// By default, we simply pass the inputs through without modifications
// or layout considerations.
static func _makeView(_ inputs: ViewInputs<Self>) -> ViewOutputs {
.init(inputs: inputs)
}
}
public extension ModifiedContent where Content: View, Modifier: ViewModifier {
static func _makeView(_ inputs: ViewInputs<Self>) -> ViewOutputs {
// Update the environment if needed.
var environment = inputs.environment.environment
if let environmentWriter = inputs.view.modifier as? EnvironmentModifier {
environmentWriter.modifyEnvironment(&environment)
}
return .init(inputs: inputs, environment: environment)
}
func _visitChildren<V>(_ visitor: V) where V: ViewVisitor {
// Visit the computed body of the modifier.
visitor.visit(modifier.body(content: .init(modifier: modifier, view: content)))
}
}

View File

@ -0,0 +1,62 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 2/3/22.
//
public protocol ViewVisitor {
func visit<V: View>(_ view: V)
}
public extension View {
func _visitChildren<V: ViewVisitor>(_ visitor: V) {
visitor.visit(body)
}
}
typealias ViewVisitorF<V: ViewVisitor> = (V) -> ()
protocol ViewReducer {
associatedtype Result
static func reduce<V: View>(into partialResult: inout Result, nextView: V)
static func reduce<V: View>(partialResult: Result, nextView: V) -> Result
}
extension ViewReducer {
static func reduce<V: View>(into partialResult: inout Result, nextView: V) {
partialResult = Self.reduce(partialResult: partialResult, nextView: nextView)
}
static func reduce<V: View>(partialResult: Result, nextView: V) -> Result {
var result = partialResult
Self.reduce(into: &result, nextView: nextView)
return result
}
}
final class ReducerVisitor<R: ViewReducer>: ViewVisitor {
var result: R.Result
init(initialResult: R.Result) {
result = initialResult
}
func visit<V>(_ view: V) where V: View {
R.reduce(into: &result, nextView: view)
}
}
extension ViewReducer {
typealias Visitor = ReducerVisitor<Self>
}

View File

@ -0,0 +1,73 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 2/11/22.
//
enum WalkWorkResult<Success> {
case `continue`
case `break`(with: Success)
case pause
}
enum WalkResult<Renderer: FiberRenderer, Success> {
case success(Success)
case finished
case paused(at: FiberReconciler<Renderer>.Fiber)
}
@discardableResult
func walk<Renderer: FiberRenderer>(
_ root: FiberReconciler<Renderer>.Fiber,
_ work: @escaping (FiberReconciler<Renderer>.Fiber) throws -> Bool
) rethrows -> WalkResult<Renderer, ()> {
try walk(root) {
try work($0) ? .continue : .pause
}
}
/// Parent-first depth-first traversal of a `Fiber` tree.
func walk<Renderer: FiberRenderer, Success>(
_ root: FiberReconciler<Renderer>.Fiber,
_ work: @escaping (FiberReconciler<Renderer>.Fiber) throws -> WalkWorkResult<Success>
) rethrows -> WalkResult<Renderer, Success> {
var current = root
while true {
// Perform work on the node
switch try work(current) {
case .continue: break
case let .break(success): return .success(success)
case .pause: return .paused(at: current)
}
// Walk into the child
if let child = current.child {
current = child
continue
}
// When we walk back to the root, exit
if current === root {
return .finished
}
// Walk back up until we find a sibling
while current.sibling == nil {
// When we walk back to the root, exit
guard let parent = current.parent,
parent !== root else { return .finished }
current = parent
}
// Walk the sibling
// swiftlint:disable:next force_unwrap
current = current.sibling!
}
}

View File

@ -23,15 +23,27 @@ public struct _ViewModifier_Content<Modifier>: View
{
public let modifier: Modifier
public let view: AnyView
let visitChildren: (ViewVisitor) -> ()
public init(modifier: Modifier, view: AnyView) {
self.modifier = modifier
self.view = view
visitChildren = { $0.visit(view) }
}
public init<V: View>(modifier: Modifier, view: V) {
self.modifier = modifier
self.view = AnyView(view)
visitChildren = { $0.visit(view) }
}
public var body: some View {
view
}
public func _visitChildren<V>(_ visitor: V) where V: ViewVisitor {
visitChildren(visitor)
}
}
public extension View {

View File

@ -20,7 +20,21 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
public struct PropertyInfo {
public struct PropertyInfo: Hashable {
// Hashable/Equatable conformance is not synthesize for metatypes.
public static func == (lhs: PropertyInfo, rhs: PropertyInfo) -> Bool {
lhs.name == rhs.name && lhs.type == rhs.type && lhs.isVar == rhs.isVar && lhs.offset == rhs
.offset && lhs.ownerType == rhs.ownerType
}
public func hash(into hasher: inout Hasher) {
hasher.combine(name)
hasher.combine(ObjectIdentifier(type))
hasher.combine(isVar)
hasher.combine(offset)
hasher.combine(ObjectIdentifier(ownerType))
}
public let name: String
public let type: Any.Type
public let isVar: Bool

View File

@ -40,6 +40,8 @@ public struct AnyView: _PrimitiveView {
*/
let bodyType: Any.Type
let visitChildren: (ViewVisitor, Any) -> ()
public init<V>(_ view: V) where V: View {
if let anyView = view as? AnyView {
self = anyView
@ -52,8 +54,14 @@ public struct AnyView: _PrimitiveView {
self.view = view
// swiftlint:disable:next force_cast
bodyClosure = { AnyView(($0 as! V).body) }
// swiftlint:disable:next force_cast
visitChildren = { $0.visit($1 as! V) }
}
}
public func _visitChildren<V>(_ visitor: V) where V: ViewVisitor {
visitChildren(visitor, view)
}
}
public func mapAnyView<T, V>(_ anyView: AnyView, transform: (V) -> T) -> T? {

View File

@ -48,6 +48,12 @@ public struct ForEach<Data, ID, Content>: _PrimitiveView where Data: RandomAcces
self.id = id
self.content = content
}
public func _visitChildren<V>(_ visitor: V) where V: ViewVisitor {
for element in data {
visitor.visit(content(element))
}
}
}
extension ForEach: ForEachProtocol where Data.Index == Int {

View File

@ -22,26 +22,46 @@ public struct TupleView<T>: _PrimitiveView {
public let value: T
let _children: [AnyView]
private let visit: (ViewVisitor) -> ()
public init(_ value: T) {
self.value = value
_children = []
visit = { _ in }
}
public init(_ value: T, children: [AnyView]) {
self.value = value
_children = children
visit = {
for child in children {
$0.visit(child)
}
}
}
public func _visitChildren<V>(_ visitor: V) where V: ViewVisitor {
visit(visitor)
}
init<T1: View, T2: View>(_ v1: T1, _ v2: T2) where T == (T1, T2) {
value = (v1, v2)
_children = [AnyView(v1), AnyView(v2)]
visit = {
$0.visit(v1)
$0.visit(v2)
}
}
// swiftlint:disable large_tuple
init<T1: View, T2: View, T3: View>(_ v1: T1, _ v2: T2, _ v3: T3) where T == (T1, T2, T3) {
value = (v1, v2, v3)
_children = [AnyView(v1), AnyView(v2), AnyView(v3)]
visit = {
$0.visit(v1)
$0.visit(v2)
$0.visit(v3)
}
}
init<T1: View, T2: View, T3: View, T4: View>(_ v1: T1, _ v2: T2, _ v3: T3, _ v4: T4)
@ -49,6 +69,12 @@ public struct TupleView<T>: _PrimitiveView {
{
value = (v1, v2, v3, v4)
_children = [AnyView(v1), AnyView(v2), AnyView(v3), AnyView(v4)]
visit = {
$0.visit(v1)
$0.visit(v2)
$0.visit(v3)
$0.visit(v4)
}
}
init<T1: View, T2: View, T3: View, T4: View, T5: View>(
@ -60,6 +86,13 @@ public struct TupleView<T>: _PrimitiveView {
) where T == (T1, T2, T3, T4, T5) {
value = (v1, v2, v3, v4, v5)
_children = [AnyView(v1), AnyView(v2), AnyView(v3), AnyView(v4), AnyView(v5)]
visit = {
$0.visit(v1)
$0.visit(v2)
$0.visit(v3)
$0.visit(v4)
$0.visit(v5)
}
}
init<T1: View, T2: View, T3: View, T4: View, T5: View, T6: View>(
@ -72,6 +105,14 @@ public struct TupleView<T>: _PrimitiveView {
) where T == (T1, T2, T3, T4, T5, T6) {
value = (v1, v2, v3, v4, v5, v6)
_children = [AnyView(v1), AnyView(v2), AnyView(v3), AnyView(v4), AnyView(v5), AnyView(v6)]
visit = {
$0.visit(v1)
$0.visit(v2)
$0.visit(v3)
$0.visit(v4)
$0.visit(v5)
$0.visit(v6)
}
}
init<T1: View, T2: View, T3: View, T4: View, T5: View, T6: View, T7: View>(
@ -93,6 +134,15 @@ public struct TupleView<T>: _PrimitiveView {
AnyView(v6),
AnyView(v7),
]
visit = {
$0.visit(v1)
$0.visit(v2)
$0.visit(v3)
$0.visit(v4)
$0.visit(v5)
$0.visit(v6)
$0.visit(v7)
}
}
init<T1: View, T2: View, T3: View, T4: View, T5: View, T6: View, T7: View, T8: View>(
@ -116,6 +166,16 @@ public struct TupleView<T>: _PrimitiveView {
AnyView(v7),
AnyView(v8),
]
visit = {
$0.visit(v1)
$0.visit(v2)
$0.visit(v3)
$0.visit(v4)
$0.visit(v5)
$0.visit(v6)
$0.visit(v7)
$0.visit(v8)
}
}
init<T1: View, T2: View, T3: View, T4: View, T5: View, T6: View, T7: View, T8: View, T9: View>(
@ -141,6 +201,17 @@ public struct TupleView<T>: _PrimitiveView {
AnyView(v8),
AnyView(v9),
]
visit = {
$0.visit(v1)
$0.visit(v2)
$0.visit(v3)
$0.visit(v4)
$0.visit(v5)
$0.visit(v6)
$0.visit(v7)
$0.visit(v8)
$0.visit(v9)
}
}
init<
@ -179,6 +250,18 @@ public struct TupleView<T>: _PrimitiveView {
AnyView(v9),
AnyView(v10),
]
visit = {
$0.visit(v1)
$0.visit(v2)
$0.visit(v3)
$0.visit(v4)
$0.visit(v5)
$0.visit(v6)
$0.visit(v7)
$0.visit(v8)
$0.visit(v9)
$0.visit(v10)
}
}
}

View File

@ -65,7 +65,7 @@ public struct Button<Label>: View where Label: View {
}
}
public struct _PrimitiveButtonStyleBody<Label>: _PrimitiveView where Label: View {
public struct _PrimitiveButtonStyleBody<Label>: View where Label: View {
public let label: Label
public let role: ButtonRole?
public let action: () -> ()
@ -85,9 +85,17 @@ public struct _PrimitiveButtonStyleBody<Label>: _PrimitiveView where Label: View
}
@Environment(\.controlSize) public var controlSize
public var body: Never {
neverBody("_PrimitiveButtonStyleBody")
}
public func _visitChildren<V>(_ visitor: V) where V: ViewVisitor {
visitor.visit(label)
}
}
public struct _Button<Label>: _PrimitiveView where Label: View {
public struct _Button<Label>: View where Label: View {
public let label: Label
public let role: ButtonRole?
public let action: () -> ()
@ -104,6 +112,10 @@ public struct _Button<Label>: _PrimitiveView where Label: View {
)
)
}
public var body: some View {
makeStyleBody()
}
}
public extension Button where Label == Text {

View File

@ -32,7 +32,7 @@ public let defaultStackSpacing: CGFloat = 8
/// Text("Hello")
/// Text("World")
/// }
public struct HStack<Content>: _PrimitiveView where Content: View {
public struct HStack<Content>: View where Content: View {
public let alignment: VerticalAlignment
let spacing: CGFloat
public let content: Content
@ -46,6 +46,14 @@ public struct HStack<Content>: _PrimitiveView where Content: View {
self.spacing = spacing ?? defaultStackSpacing
self.content = content()
}
public var body: Never {
neverBody("HStack")
}
public func _visitChildren<V>(_ visitor: V) where V: ViewVisitor {
visitor.visit(content)
}
}
extension HStack: ParentView {

View File

@ -27,7 +27,7 @@ public enum HorizontalAlignment: Equatable {
/// Text("Hello")
/// Text("World")
/// }
public struct VStack<Content>: _PrimitiveView where Content: View {
public struct VStack<Content>: View where Content: View {
public let alignment: HorizontalAlignment
let spacing: CGFloat
public let content: Content
@ -41,6 +41,14 @@ public struct VStack<Content>: _PrimitiveView where Content: View {
self.spacing = spacing ?? defaultStackSpacing
self.content = content()
}
public var body: Never {
neverBody("VStack")
}
public func _visitChildren<V>(_ visitor: V) where V: ViewVisitor {
visitor.visit(content)
}
}
extension VStack: ParentView {

View File

@ -19,6 +19,9 @@ public protocol View {
associatedtype Body: View
@ViewBuilder var body: Self.Body { get }
func _visitChildren<V: ViewVisitor>(_ visitor: V)
static func _makeView(_ inputs: ViewInputs<Self>) -> ViewOutputs
}
public extension Never {
@ -38,6 +41,8 @@ public extension _PrimitiveView {
var body: Never {
neverBody(String(reflecting: Self.self))
}
func _visitChildren<V>(_ visitor: V) where V: ViewVisitor {}
}
/// A `View` type that renders with subviews, usually specified in the `Content` type argument

View File

@ -31,6 +31,15 @@ public struct _ConditionalContent<TrueContent, FalseContent>: _PrimitiveView
}
let storage: Storage
public func _visitChildren<V>(_ visitor: V) where V: ViewVisitor {
switch storage {
case let .trueContent(view):
visitor.visit(view)
case let .falseContent(view):
visitor.visit(view)
}
}
}
extension _ConditionalContent: GroupView {
@ -52,6 +61,15 @@ extension Optional: View where Wrapped: View {
EmptyView()
}
}
public func _visitChildren<V>(_ visitor: V) where V: ViewVisitor {
switch self {
case .none:
break
case let .some(wrapped):
visitor.visit(wrapped)
}
}
}
protocol AnyOptional {

View File

@ -13,7 +13,8 @@
// limitations under the License.
import Benchmark
import TokamakCore
@_spi(TokamakCore) import TokamakCore
import TokamakTestRenderer
private let bigType = NavigationView<HStack<VStack<Button<Text>>>>.self
@ -25,4 +26,233 @@ benchmark("typeConstructorName TokamakCore") {
_ = typeConstructorName(bigType)
}
struct UpdateWide: View {
@State var update = -1
var body: some View {
VStack {
ForEach(0..<1000) {
if update == $0 {
Text("Updated")
} else {
Text("\($0)")
}
}
Button("Update") {
update = 999
}
}
}
}
benchmark("update wide (StackReconciler)") { state in
let view = UpdateWide()
let renderer = TestRenderer(view)
var button: _PrimitiveButtonStyleBody<PrimitiveButtonStyleConfiguration.Label>?
mapAnyView(
renderer.rootTarget.subviews[0].subviews[1].subviews[0]
.view
) { (v: _PrimitiveButtonStyleBody<PrimitiveButtonStyleConfiguration.Label>) in
button = v
}
try state.measure {
button?.action()
}
}
benchmark("update wide (FiberReconciler)") { state in
let view = UpdateWide()
let reconciler = TestFiberRenderer(.root).render(view)
let button = reconciler.current // ModifiedContent
.child? // _ViewModifier_Content
.child? // UpdateLast
.child? // VStack
.child? // TupleView
.child?.sibling? // Button
.child? // ConditionalContent
.child? // AnyView
.child? // _PrimitiveButtonStyleBody<PrimitiveButtonStyleConfiguration.Label>
.view
try state.measure {
(button as? _PrimitiveButtonStyleBody<PrimitiveButtonStyleConfiguration.Label>)?.action()
}
}
struct UpdateNarrow: View {
@State var update = -1
var body: some View {
VStack {
ForEach(0..<1000) {
if update == $0 {
Text("Updated")
} else {
Text("\($0)")
}
}
Button("Update") {
update = 0
}
}
}
}
benchmark("update narrow (StackReconciler)") { state in
let view = UpdateNarrow()
let renderer = TestRenderer(view)
var button: _PrimitiveButtonStyleBody<PrimitiveButtonStyleConfiguration.Label>?
mapAnyView(
renderer.rootTarget.subviews[0].subviews[1].subviews[0]
.view
) { (v: _PrimitiveButtonStyleBody<PrimitiveButtonStyleConfiguration.Label>) in
button = v
}
try state.measure {
button?.action()
}
}
benchmark("update narrow (FiberReconciler)") { state in
let view = UpdateNarrow()
let reconciler = TestFiberRenderer(.root).render(view)
let button = reconciler.current // ModifiedContent
.child? // _ViewModifier_Content
.child? // UpdateLast
.child? // VStack
.child? // TupleView
.child?.sibling? // Button
.child? // ConditionalContent
.child? // AnyView
.child? // _PrimitiveButtonStyleBody<PrimitiveButtonStyleConfiguration.Label>
.view
try state.measure {
(button as? _PrimitiveButtonStyleBody<PrimitiveButtonStyleConfiguration.Label>)?.action()
}
}
struct UpdateDeep: View {
@State var update = "A"
struct RecursiveView: View {
let count: Int
let content: String
init(_ count: Int, content: String) {
self.count = count
self.content = content
}
var body: some View {
if count == 0 {
Text(content)
} else {
RecursiveView(count - 1, content: content)
}
}
}
var body: some View {
VStack {
RecursiveView(1000, content: update)
Button("Update") {
update = "B"
}
}
}
}
benchmark("update deep (StackReconciler)") { state in
let view = UpdateDeep()
let renderer = TestRenderer(view)
var button: _PrimitiveButtonStyleBody<PrimitiveButtonStyleConfiguration.Label>?
mapAnyView(
renderer.rootTarget.subviews[0].subviews[1].subviews[0]
.view
) { (v: _PrimitiveButtonStyleBody<PrimitiveButtonStyleConfiguration.Label>) in
button = v
}
try state.measure {
button?.action()
}
}
benchmark("update deep (FiberReconciler)") { state in
let view = UpdateDeep()
let reconciler = TestFiberRenderer(.root).render(view)
let button = reconciler.current // ModifiedContent
.child? // _ViewModifier_Content
.child? // UpdateLast
.child? // VStack
.child? // TupleView
.child?.sibling? // Button
.child? // ConditionalContent
.child? // AnyView
.child? // _PrimitiveButtonStyleBody<PrimitiveButtonStyleConfiguration.Label>
.view
try state.measure {
(button as? _PrimitiveButtonStyleBody<PrimitiveButtonStyleConfiguration.Label>)?.action()
}
}
struct UpdateShallow: View {
@State var update = "A"
struct RecursiveView: View {
let count: Int
init(_ count: Int) {
self.count = count
}
var body: some View {
if count == 0 {
Text("RecursiveView")
} else {
RecursiveView(count - 1)
}
}
}
var body: some View {
VStack {
Text(update)
RecursiveView(500)
Button("Update") {
update = "B"
}
}
}
}
benchmark("update shallow (StackReconciler)") { _ in
let view = UpdateShallow()
let renderer = TestRenderer(view)
var button: _PrimitiveButtonStyleBody<PrimitiveButtonStyleConfiguration.Label>?
mapAnyView(
renderer.rootTarget.subviews[0].subviews[1].subviews[0]
.view
) { (v: _PrimitiveButtonStyleBody<PrimitiveButtonStyleConfiguration.Label>) in
button = v
}
// Using state.measure here hangs the benchmark app?
button?.action()
}
benchmark("update shallow (FiberReconciler)") { _ in
let view = UpdateShallow()
let reconciler = TestFiberRenderer(.root).render(view)
let button = reconciler.current // ModifiedContent
.child? // _ViewModifier_Content
.child? // UpdateLast
.child? // VStack
.child? // TupleView
.child?.sibling? // Button
.child? // ConditionalContent
.child? // AnyView
.child? // _PrimitiveButtonStyleBody<PrimitiveButtonStyleConfiguration.Label>
.view
// Using state.measure here hangs the benchmark app?g
(button as? _PrimitiveButtonStyleBody<PrimitiveButtonStyleConfiguration.Label>)?.action()
}
Benchmark.main()

View File

@ -0,0 +1,171 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 2/7/22.
//
import JavaScriptKit
@_spi(TokamakCore) import TokamakCore
@_spi(TokamakStaticHTML) import TokamakStaticHTML
public final class DOMElement: FiberElement {
var reference: JSObject?
public struct Content: FiberElementContent {
let tag: String
let attributes: [HTMLAttribute: String]
let innerHTML: String?
let listeners: [String: Listener]
let debugData: [String: ConvertibleToJSValue]
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.tag == rhs.tag && lhs.attributes == rhs.attributes && lhs.innerHTML == rhs.innerHTML
}
}
public var content: Content
public init(from content: Content) {
self.content = content
}
public func update(with content: Content) {
self.content = content
}
}
public extension DOMElement.Content {
init<V>(from primitiveView: V) where V: View {
guard let primitiveView = primitiveView as? HTMLConvertible else { fatalError() }
tag = primitiveView.tag
attributes = primitiveView.attributes
innerHTML = primitiveView.innerHTML
if let primitiveView = primitiveView as? DOMNodeConvertible {
listeners = primitiveView.listeners
} else {
listeners = [:]
}
debugData = [
"view": String(reflecting: V.self),
]
}
}
protocol DOMNodeConvertible: HTMLConvertible {
var listeners: [String: Listener] { get }
}
public struct DOMFiberRenderer: FiberRenderer {
public let rootElement: DOMElement
public var defaultEnvironment: EnvironmentValues {
var environment = EnvironmentValues()
environment[_ColorSchemeKey.self] = .light
return environment
}
public init(_ rootSelector: String) {
guard let reference = document.querySelector!(rootSelector).object else {
fatalError("""
The root element with selector '\(rootSelector)' could not be found. \
Ensure this element exists in your site's index.html file.
""")
}
rootElement = .init(
from: .init(
tag: "",
attributes: [:],
innerHTML: nil,
listeners: [:],
debugData: ["view": "root"]
)
)
rootElement.reference = reference
}
public static func isPrimitive<V>(_ view: V) -> Bool where V: View {
view is HTMLConvertible || view is DOMNodeConvertible
}
private func createElement(_ element: DOMElement) -> JSObject {
let result = document.createElement!(element.content.tag).object!
apply(element.content, to: result)
element.reference = result
return result
}
private func apply(_ content: DOMElement.Content, to element: JSObject) {
for (attribute, value) in content.attributes {
if attribute.isUpdatedAsProperty {
element[attribute.value] = .string(value)
} else {
_ = element.setAttribute?(attribute.value, value)
}
}
if let innerHTML = content.innerHTML {
element.innerHTML = .string(innerHTML)
}
for (event, action) in content.listeners {
_ = element.addEventListener?(event, JSClosure {
action($0[0].object!)
return .undefined
})
}
for (key, value) in content.debugData {
element.dataset.object?[dynamicMember: key] = value.jsValue
}
}
public func commit(_ mutations: [Mutation<Self>]) {
for mutation in mutations {
switch mutation {
case let .insert(newElement, parent, index):
let element = createElement(newElement)
guard let parentElement = parent.reference ?? rootElement.reference
else { fatalError("The root element was not bound (trying to insert element).") }
if Int(parentElement.children.object?.length.number ?? 0) > index {
_ = parentElement.insertBefore?(element, parentElement.children[index])
} else {
_ = parentElement.appendChild?(element)
}
case let .remove(element, _):
_ = element.reference?.remove?()
case let .replace(parent, previous, replacement):
guard let parentElement = parent.reference ?? rootElement.reference
else { fatalError("The root element was not bound (trying to replace element).") }
guard let previousElement = previous.reference else {
fatalError("The previous element does not exist (trying to replace element).")
}
let replacementElement = createElement(replacement)
_ = parentElement.replaceChild?(previousElement, replacementElement)
case let .update(previous, newContent):
previous.update(with: newContent)
guard let previousElement = previous.reference
else { fatalError("The element does not exist (trying to update element).") }
apply(newContent, to: previousElement)
previous.reference = previousElement
}
}
}
}
extension _PrimitiveButtonStyleBody: DOMNodeConvertible {
public var tag: String { "button" }
public var attributes: [HTMLAttribute: String] { [:] }
var listeners: [String: Listener] {
["pointerup": { _ in self.action() }]
}
}

View File

@ -88,7 +88,9 @@ final class DOMRenderer: Renderer {
rootRef = ref
appendRootStyle(ref)
JavaScriptEventLoop.installGlobalExecutor()
if #available(macOS 10.15, *) {
JavaScriptEventLoop.installGlobalExecutor()
}
let scheduler = JSScheduler()
self.scheduler = scheduler

View File

@ -0,0 +1,169 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 2/6/22.
//
import TokamakCore
public final class HTMLElement: FiberElement, CustomStringConvertible {
public struct Content: FiberElementContent, Equatable {
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.tag == rhs.tag
&& lhs.attributes == rhs.attributes
&& lhs.innerHTML == rhs.innerHTML
&& lhs.children.map(\.content) == rhs.children.map(\.content)
}
var tag: String
var attributes: [HTMLAttribute: String]
var innerHTML: String?
var children: [HTMLElement] = []
public init<V>(from primitiveView: V) where V: View {
guard let primitiveView = primitiveView as? HTMLConvertible else { fatalError() }
tag = primitiveView.tag
attributes = primitiveView.attributes
innerHTML = primitiveView.innerHTML
}
public init(
tag: String,
attributes: [HTMLAttribute: String],
innerHTML: String?,
children: [HTMLElement]
) {
self.tag = tag
self.attributes = attributes
self.innerHTML = innerHTML
self.children = children
}
}
public var content: Content
public init(from content: Content) {
self.content = content
}
public init(
tag: String,
attributes: [HTMLAttribute: String],
innerHTML: String?,
children: [HTMLElement]
) {
content = .init(
tag: tag,
attributes: attributes,
innerHTML: innerHTML,
children: children
)
}
public func update(with content: Content) {
self.content = content
}
public var description: String {
"""
<\(content.tag)\(content.attributes.map { " \($0.key.value)=\"\($0.value)\"" }
.joined(separator: ""))>\(content.innerHTML != nil ? "\(content.innerHTML!)" : "")\(!content
.children
.isEmpty ? "\n" : "")\(content.children.map(\.description).joined(separator: "\n"))\(!content
.children
.isEmpty ? "\n" : "")</\(content.tag)>
"""
}
}
@_spi(TokamakStaticHTML) public protocol HTMLConvertible {
var tag: String { get }
var attributes: [HTMLAttribute: String] { get }
var innerHTML: String? { get }
}
public extension HTMLConvertible {
@_spi(TokamakStaticHTML) var innerHTML: String? { nil }
}
@_spi(TokamakStaticHTML) extension Text: HTMLConvertible {
@_spi(TokamakStaticHTML) public var innerHTML: String? {
_TextProxy(self).rawText
}
}
@_spi(TokamakStaticHTML) extension VStack: HTMLConvertible {
@_spi(TokamakStaticHTML) public var tag: String { "div" }
@_spi(TokamakStaticHTML) public var attributes: [HTMLAttribute: String] {
let spacing = _VStackProxy(self).spacing
return [
"style": """
justify-items: \(alignment.cssValue);
\(hasSpacer ? "height: 100%;" : "")
\(fillCrossAxis ? "width: 100%;" : "")
\(spacing != defaultStackSpacing ? "--tokamak-stack-gap: \(spacing)px;" : "")
""",
"class": "_tokamak-stack _tokamak-vstack",
]
}
}
@_spi(TokamakStaticHTML) extension HStack: HTMLConvertible {
@_spi(TokamakStaticHTML) public var tag: String { "div" }
@_spi(TokamakStaticHTML) public var attributes: [HTMLAttribute: String] {
let spacing = _HStackProxy(self).spacing
return [
"style": """
align-items: \(alignment.cssValue);
\(hasSpacer ? "width: 100%;" : "")
\(fillCrossAxis ? "height: 100%;" : "")
\(spacing != defaultStackSpacing ? "--tokamak-stack-gap: \(spacing)px;" : "")
""",
"class": "_tokamak-stack _tokamak-hstack",
]
}
}
public struct StaticHTMLFiberRenderer: FiberRenderer {
public let rootElement: HTMLElement
public let defaultEnvironment: EnvironmentValues
public init() {
rootElement = .init(tag: "body", attributes: [:], innerHTML: nil, children: [])
var environment = EnvironmentValues()
environment[_ColorSchemeKey.self] = .light
defaultEnvironment = environment
}
public static func isPrimitive<V>(_ view: V) -> Bool where V: View {
view is HTMLConvertible
}
public func commit(_ mutations: [Mutation<Self>]) {
for mutation in mutations {
switch mutation {
case let .insert(element, parent, index):
parent.content.children.insert(element, at: index)
case let .remove(element, parent):
parent?.content.children.removeAll(where: { $0 === element })
case let .replace(parent, previous, replacement):
guard let index = parent.content.children.firstIndex(where: { $0 === previous })
else { continue }
parent.content.children[index] = replacement
case let .update(previous, newContent):
previous.update(with: newContent)
}
}
}
}

View File

@ -43,4 +43,52 @@ benchmark("render List sorted attributes") {
_ = StaticHTMLRenderer(List(1..<100) { Text("\($0)") }).render(shouldSortAttributes: true)
}
benchmark("render Text (StackReconciler)") {
_ = StaticHTMLRenderer(Text("Hello, world!")).render(shouldSortAttributes: false)
}
benchmark("render Text (FiberReconciler)") {
StaticHTMLFiberRenderer().render(Text("Hello, world!"))
}
benchmark("render ForEach(100) (StackReconciler)") {
_ = StaticHTMLRenderer(ForEach(1..<100) { Text("\($0)") }).render(shouldSortAttributes: false)
}
benchmark("render ForEach(100) (FiberReconciler)") {
StaticHTMLFiberRenderer().render(ForEach(1..<100) { Text("\($0)") })
}
benchmark("render ForEach(1000) (StackReconciler)") {
_ = StaticHTMLRenderer(ForEach(1..<1000) { Text("\($0)") }).render(shouldSortAttributes: false)
}
benchmark("render ForEach(1000) (FiberReconciler)") {
StaticHTMLFiberRenderer().render(ForEach(1..<1000) { Text("\($0)") })
}
struct RecursiveView: View {
let count: Int
init(_ count: Int) {
self.count = count
}
var body: some View {
if count == 0 {
Text("RecursiveView")
} else {
RecursiveView(count - 1)
}
}
}
benchmark("render RecursiveView(1000) (StackReconciler)") {
_ = StaticHTMLRenderer(RecursiveView(1000)).render(shouldSortAttributes: false)
}
benchmark("render RecursiveView(1000) (FiberReconciler)") {
StaticHTMLFiberRenderer().render(RecursiveView(1000))
}
Benchmark.main()

View File

@ -0,0 +1,125 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 4/5/22.
//
@_spi(TokamakCore) import TokamakCore
public protocol TestFiberPrimitive {
var tag: String { get }
var attributes: [String: Any] { get }
}
public extension TestFiberPrimitive {
var tag: String { String(String(reflecting: Self.self).split(separator: "<")[0]) }
}
extension VStack: TestFiberPrimitive {
public var attributes: [String: Any] {
["spacing": _VStackProxy(self).spacing, "alignment": alignment]
}
}
extension HStack: TestFiberPrimitive {
public var attributes: [String: Any] {
["spacing": _HStackProxy(self).spacing, "alignment": alignment]
}
}
extension Text: TestFiberPrimitive {
public var attributes: [String: Any] {
let proxy = _TextProxy(self)
return ["content": proxy.storage.rawText, "modifiers": proxy.modifiers]
}
}
extension _Button: TestFiberPrimitive {
public var attributes: [String: Any] {
["action": action, "role": role as Any]
}
}
extension _PrimitiveButtonStyleBody: TestFiberPrimitive {
public var attributes: [String: Any] {
["size": controlSize, "role": role as Any]
}
}
public final class TestFiberElement: FiberElement, CustomStringConvertible {
public struct Content: FiberElementContent, Equatable {
let renderedValue: String
let closingTag: String
init(
renderedValue: String,
closingTag: String
) {
self.renderedValue = renderedValue
self.closingTag = closingTag
}
public init<V>(from primitiveView: V) where V: View {
guard let primitiveView = primitiveView as? TestFiberPrimitive else { fatalError() }
renderedValue =
"<\(primitiveView.tag) \(primitiveView.attributes.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\"\(String(describing: $0.value))\"" }.joined(separator: " "))>"
closingTag = "</\(primitiveView.tag)>"
}
}
public var content: Content
public init(from content: Content) {
self.content = content
}
public var description: String {
"\(content.renderedValue)\(content.closingTag)"
}
public init(renderedValue: String, closingTag: String) {
content = .init(renderedValue: renderedValue, closingTag: closingTag)
}
public func update(with content: Content) {
self.content = content
}
public static var root: Self { .init(renderedValue: "<root>", closingTag: "</root>") }
}
public struct TestFiberRenderer: FiberRenderer {
public typealias ElementType = TestFiberElement
public let rootElement: ElementType
public init(_ rootElement: ElementType) {
self.rootElement = rootElement
}
public static func isPrimitive<V>(_ view: V) -> Bool where V: View {
view is TestFiberPrimitive
}
public func commit(_ mutations: [Mutation<Self>]) {
for mutation in mutations {
switch mutation {
case .insert, .remove, .replace:
break
case let .update(previous, newContent):
previous.update(with: newContent)
}
}
}
}

View File

@ -0,0 +1,98 @@
// Copyright 2021 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Created by Carson Katri on 2/3/22.
//
import XCTest
@_spi(TokamakCore) @testable import TokamakCore
import TokamakTestRenderer
private struct TestView: View {
struct Counter: View {
@State private var count = 0
var body: some View {
VStack {
Text("\(count)")
HStack {
if count > 0 {
Button("Decrement") {
print("Decrement")
count -= 1
}
}
if count < 5 {
Button("Increment") {
print("Increment")
if count + 1 >= 5 {
print("Hit 5")
}
count += 1
}
}
}
}
}
}
public var body: some View {
Counter()
}
}
final class VisitorTests: XCTestCase {
func testRenderer() {
let reconciler = TestFiberRenderer(.root).render(TestView())
func decrement() {
(
reconciler.current // ModifiedContent
.child? // _ViewModifier_Content
.child? // TestView
.child? // Counter
.child? // VStack
.child? // TupleView
.child?.sibling? // HStack
.child? // TupleView
.child? // Optional
.child? // Button
.view as? Button<Text>
)?
.action()
}
func increment() {
(
reconciler.current // ModifiedContent
.child? // _ViewModifier_Content
.child? // TestView
.child? // Counter
.child? // VStack
.child? // TupleView
.child? // Text
.sibling? // HStack
.child? // TupleView
.child? // Optional
.sibling? // Optional
.child? // Button
.view as? Button<Text>
)?
.action()
}
for _ in 0..<5 {
increment()
}
decrement()
}
}