Skip to content

Commit

Permalink
Experimental "React Fiber"-like Reconciler (#471)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
carson-katri and MaxDesiatov authored May 23, 2022
1 parent a41ac37 commit 8177fc8
Show file tree
Hide file tree
Showing 28 changed files with 1,970 additions and 8 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- uses: actions/checkout@v2
- uses: swiftwasm/[email protected]
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:
Expand Down
8 changes: 8 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ let package = Package(
dependencies: [
.product(name: "Benchmark", package: "swift-benchmark"),
"TokamakCore",
"TokamakTestRenderer",
]
),
.executableTarget(
Expand Down Expand Up @@ -194,6 +195,13 @@ let package = Package(
name: "TokamakTestRenderer",
dependencies: ["TokamakCore"]
),
.testTarget(
name: "TokamakReconcilerTests",
dependencies: [
"TokamakCore",
"TokamakTestRenderer",
]
),
.testTarget(
name: "TokamakTests",
dependencies: ["TokamakTestRenderer"]
Expand Down
268 changes: 268 additions & 0 deletions Sources/TokamakCore/Fiber/Fiber.swift
Original file line number Diff line number Diff line change
@@ -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) ?? "")
"""
}
}
}
33 changes: 33 additions & 0 deletions Sources/TokamakCore/Fiber/FiberElement.swift
Original file line number Diff line number Diff line change
@@ -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)
}
Loading

0 comments on commit 8177fc8

Please sign in to comment.