Skip to content

Commit 9329955

Browse files
authored
add onChange lifecycle feature (#32)
* straightening out scheduling a bit * reworked lifecycle stuff a bit * fixed embedded issue * formatting fix * tests
1 parent 97f7685 commit 9329955

File tree

14 files changed

+411
-143
lines changed

14 files changed

+411
-143
lines changed

Examples/Basic/Sources/App/Views.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ struct App {
2525
.environment(#Key(\.myText), data.name)
2626
.environment(data)
2727
}
28+
.onChange(of: bindingViewCount) { oldValue, newValue in
29+
print("bindingViewCount changed to \(oldValue) -> \(newValue)")
30+
}
31+
.onChange(of: bindingViewCount) {
32+
if bindingViewCount > 5 {
33+
data.name = "Binding View Count > 5"
34+
}
35+
}
36+
2837
hr()
2938
div {
3039
for _ in 0..<bindingViewCount {

Sources/ElementaryDOM/Data/Lifecycle/View+LifecycleEvents.swift

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,50 +2,88 @@ import Elementary
22
import _Concurrency
33

44
public extension View {
5-
// TODO: make rename this to onAppear and onDisappear and reserve mounting lingo for DOM nodes?
6-
func onMount(_ action: @escaping () -> Void) -> _LifecycleEventView<Self> {
5+
func onMount(_ action: @escaping () -> Void) -> some View<Tag> {
76
_LifecycleEventView(wrapped: self, listener: .onMount(action))
87
}
98

10-
func onUnmount(_ action: @escaping () -> Void) -> _LifecycleEventView<Self> {
9+
func onUnmount(_ action: @escaping () -> Void) -> some View<Tag> {
1110
_LifecycleEventView(wrapped: self, listener: .onUnmount(action))
1211
}
1312

14-
func task(_ task: @escaping () async -> Void) -> _LifecycleEventView<Self> {
13+
func task(_ task: @escaping () async -> Void) -> some View<Tag> {
1514
_LifecycleEventView(
1615
wrapped: self,
1716
listener: .onMountReturningCancelFunction({ Task { await task() }.cancel })
1817
)
1918
}
2019
}
2120

22-
public struct _LifecycleEventView<Wrapped: View>: View {
23-
public typealias Tag = Wrapped.Tag
24-
public typealias _MountedNode = _LifecycleNode
21+
enum LifecycleHook {
22+
case onMount(() -> Void)
23+
case onUnmount(() -> Void)
24+
case onMountReturningCancelFunction(() -> () -> Void)
25+
}
26+
27+
struct _LifecycleEventView<Wrapped: View>: View {
28+
typealias Tag = Wrapped.Tag
29+
typealias _MountedNode = _StatefulNode<LifecycleState, Wrapped._MountedNode>
2530

2631
let wrapped: Wrapped
2732
let listener: LifecycleHook
2833

29-
public static func _makeNode(
34+
final class LifecycleState: Unmountable {
35+
var onUnmount: (() -> Void)?
36+
let scheduler: Scheduler
37+
38+
init(hook: LifecycleHook, scheduler: Scheduler) {
39+
self.scheduler = scheduler
40+
41+
switch hook {
42+
case .onMount(let onMount):
43+
scheduler.onNextTick { onMount() }
44+
case .onUnmount(let callback):
45+
self.onUnmount = callback
46+
case .onMountReturningCancelFunction(let onMountReturningCancelFunction):
47+
scheduler.onNextTick {
48+
let cancelFunc = onMountReturningCancelFunction()
49+
self.onUnmount = cancelFunc
50+
}
51+
}
52+
}
53+
54+
func unmount(_ context: inout _CommitContext) {
55+
scheduler.onNextTick {
56+
self.onUnmount?()
57+
self.onUnmount = nil
58+
}
59+
}
60+
}
61+
62+
static func _makeNode(
3063
_ view: consuming Self,
3164
context: borrowing _ViewContext,
3265
reconciler: inout _RenderContext
3366
) -> _MountedNode {
34-
.init(
35-
value: view.listener,
36-
child: Wrapped._makeNode(view.wrapped, context: context, reconciler: &reconciler),
37-
context: &reconciler
38-
)
67+
let state = LifecycleState(hook: view.listener, scheduler: reconciler.scheduler)
68+
let child = Wrapped._makeNode(view.wrapped, context: context, reconciler: &reconciler)
69+
70+
let node = _StatefulNode(state: state, child: child)
71+
return node
3972
}
4073

41-
public static func _patchNode(
74+
static func _patchNode(
4275
_ view: consuming Self,
4376
node: _MountedNode,
4477
reconciler: inout _RenderContext
4578
) {
46-
//TODO: should we patch something? maybe update values?
47-
node.patch(context: &reconciler) { n, r in
48-
Wrapped._patchNode(view.wrapped, node: n, reconciler: &r)
79+
switch view.listener {
80+
case .onUnmount(let callback):
81+
// unmount is the only lifecycle hook that can be patched
82+
node.state.onUnmount = callback
83+
default:
84+
break
4985
}
86+
87+
Wrapped._patchNode(view.wrapped, node: node.child, reconciler: &reconciler)
5088
}
5189
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import Elementary
2+
3+
public extension View {
4+
/// Adds an action to perform when the given value changes.
5+
///
6+
/// - Parameters:
7+
/// - value: The value to observe for changes.
8+
/// - initial: Whether the action should be run when the view initially appears.
9+
/// - action: The action to perform when a change is detected.
10+
/// - Returns: A view that triggers the given action when the value changes.
11+
func onChange<V: Equatable>(
12+
of value: V,
13+
initial: Bool = false,
14+
_ action: @escaping () -> Void
15+
) -> some View<Tag> {
16+
_OnChangeView(wrapped: self, value: value, initial: initial) { _, _ in action() }
17+
}
18+
19+
/// Adds an action to perform when the given value changes, providing both the old and new values.
20+
///
21+
/// - Parameters:
22+
/// - value: The value to observe for changes.
23+
/// - initial: Whether the action should be run when the view initially appears.
24+
/// - action: The action to perform when a change is detected, receiving the old and new values.
25+
/// - Returns: A view that triggers the given action when the value changes.
26+
nonisolated func onChange<V: Equatable>(
27+
of value: V,
28+
initial: Bool = false,
29+
_ action: @escaping (V, V) -> Void
30+
) -> some View<Tag> {
31+
_OnChangeView(wrapped: self, value: value, initial: initial, action: action)
32+
}
33+
}
34+
35+
struct _OnChangeView<Wrapped: View, Value: Equatable>: View {
36+
typealias Tag = Wrapped.Tag
37+
typealias _MountedNode = _StatefulNode<State, Wrapped._MountedNode>
38+
39+
struct State {
40+
var value: Value
41+
var action: (Value, Value) -> Void
42+
43+
init(value: Value, action: @escaping (Value, Value) -> Void) {
44+
self.value = value
45+
self.action = action
46+
}
47+
}
48+
49+
let wrapped: Wrapped
50+
let value: Value
51+
let initial: Bool
52+
let action: (Value, Value) -> Void
53+
54+
static func _makeNode(
55+
_ view: consuming Self,
56+
context: borrowing _ViewContext,
57+
reconciler: inout _RenderContext
58+
) -> _MountedNode {
59+
let state = State(value: view.value, action: view.action)
60+
let child = Wrapped._makeNode(view.wrapped, context: context, reconciler: &reconciler)
61+
62+
if view.initial {
63+
let initialValue = view.value
64+
let action = view.action
65+
reconciler.scheduler.afterReconcile {
66+
action(initialValue, initialValue)
67+
}
68+
}
69+
70+
return .init(state: state, child: child)
71+
}
72+
73+
static func _patchNode(
74+
_ view: consuming Self,
75+
node: _MountedNode,
76+
reconciler: inout _RenderContext
77+
) {
78+
node.state.action = view.action
79+
80+
if node.state.value != view.value {
81+
let oldValue = node.state.value
82+
let newValue = view.value
83+
node.state.value = newValue
84+
85+
let action = view.action
86+
reconciler.scheduler.afterReconcile {
87+
action(oldValue, newValue)
88+
}
89+
}
90+
91+
Wrapped._patchNode(view.wrapped, node: node.child, reconciler: &reconciler)
92+
}
93+
}

Sources/ElementaryDOM/Data/Lifecycle/_LifecycleNode.swift

Lines changed: 0 additions & 70 deletions
This file was deleted.

Sources/ElementaryDOM/Data/Lifecycle/_StatefulNode.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
public final class _StatefulNode<State, Child: _Reconcilable> {
22
var state: State
33
var child: Child
4-
var onUnmount: ((inout _CommitContext) -> Void)?
4+
private var onUnmount: ((inout _CommitContext) -> Void)?
55

66
init(state: State, child: Child) {
77
self.state = state
88
self.child = child
99
}
1010

11-
init(_ state: State, _ child: Child) where State: Unmountable {
11+
private init(state: State, child: Child, onUnmount: ((inout _CommitContext) -> Void)? = nil) {
1212
self.state = state
1313
self.child = child
14-
self.onUnmount = state.unmount(_:)
14+
self.onUnmount = onUnmount
15+
}
16+
17+
// generic initializers must be convenience on final classes for embedded wasm
18+
// https://github.com/swiftlang/swift/issues/78150
19+
convenience init(state: State, child: Child) where State: Unmountable {
20+
self.init(state: state, child: child, onUnmount: state.unmount(_:))
1521
}
1622
}
1723

Sources/ElementaryDOM/HTMLViews/ElementModifiers/AttributeModifier.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ extension _AttributeModifier {
6363
guard !isDirty else { return }
6464
logTrace("invalidating attribute modifier")
6565
isDirty = true
66-
context.scheduler.addNodeAction(CommitAction(run: updateDOMNode(_:)))
66+
context.scheduler.addCommitAction(CommitAction(run: updateDOMNode(_:)))
6767
}
6868

6969
func updateDOMNode(_ context: inout _CommitContext) {

Sources/ElementaryDOM/HTMLViews/ElementModifiers/BindingModifiers.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ final class BindingModifier<Configuration>: DOMElementModifier, Unmountable wher
2828
guard !isDirty else { return }
2929
isDirty = true
3030

31-
context.scheduler.addNodeAction(
31+
context.scheduler.addCommitAction(
3232
CommitAction(run: updateDOMNode)
3333
)
3434
}

Sources/ElementaryDOM/HTMLViews/StyleModifiers/MountedStyleModifier.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ final class MountedStyleModifier<Instance: CSSAnimatedValueInstance>: Unmountabl
2525
func invalidate(_ context: inout _RenderContext) {
2626
guard !isDirty else { return }
2727
isDirty = true
28-
context.scheduler.addNodeAction(CommitAction(run: updateDOMNode(_:)))
28+
context.scheduler.addCommitAction(CommitAction(run: updateDOMNode(_:)))
2929
scheduler = context.scheduler // FIXME: this is a bit hacky
3030
}
3131

Sources/ElementaryDOM/HTMLViews/_ElementNode.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public final class _ElementNode: _Reconcilable {
3131
viewContext.parentElement = self
3232
let modifiers = viewContext.takeModifiers()
3333

34-
context.scheduler.addNodeAction(
34+
context.scheduler.addCommitAction(
3535
CommitAction { [self] context in
3636
precondition(self.domNode == nil, "element already has a DOM node")
3737
let ref = context.dom.createElement(tag)

Sources/ElementaryDOM/HTMLViews/_TextNode.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public final class _TextNode: _Reconcilable {
1212
self.parentElement?.reportChangedChildren(.elementAdded, context: &context)
1313

1414
isDirty = true
15-
context.scheduler.addNodeAction(
15+
context.scheduler.addCommitAction(
1616
CommitAction { [self] context in
1717
self.domNode = ManagedDOMReference(reference: context.dom.createText(newValue), status: .added)
1818
self.isDirty = false
@@ -27,7 +27,7 @@ public final class _TextNode: _Reconcilable {
2727
guard needsUpdate else { return }
2828

2929
isDirty = true
30-
context.scheduler.addNodeAction(
30+
context.scheduler.addCommitAction(
3131
CommitAction { [self] context in
3232
assert(isDirty, "text node is not dirty")
3333
assert(domNode != nil, "text node is not mounted")

0 commit comments

Comments
 (0)