-
-
Notifications
You must be signed in to change notification settings - Fork 112
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <[email protected]>
- Loading branch information
1 parent
a41ac37
commit 8177fc8
Showing
28 changed files
with
1,970 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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: | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) ?? "") | ||
""" | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
Oops, something went wrong.