Skip to content

Commit 35001e7

Browse files
authored
Custom view comparing and environment dependency tracking (#22)
* add view comparison and environment access tracking * fold environent dependencies into same run * embedded fixes - still crashing... :( * improved reactive object handling and fixed embedded build * fix 6.1 build * refined view equatability * added view equating tests
1 parent be52d74 commit 35001e7

35 files changed

+787
-229
lines changed

.swift-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
6.2-snapshot-2025-08-21
1+
6.2-snapshot-2025-08-30

Examples/Basic/Sources/App/Views.swift

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import ElementaryDOM
22

33
extension EnvironmentValues {
4-
@Entry var myText = ""
4+
@Entry var myText: String = ""
55
}
66

77
@View
@@ -11,7 +11,7 @@ struct App {
1111
@State var data = SomeData()
1212

1313
var content: some View {
14-
TextField(value: #Binding(data.name))
14+
TextField(value: Binding(get: { data.name }, set: { data.name = $0 }))
1515
div {
1616
p { "Via Binding: \(data.name)" }
1717
p { TestValueView() }
@@ -93,13 +93,15 @@ struct Counter {
9393

9494
@View
9595
struct TextField {
96-
@Binding var value: String
96+
@Binding<String> var value: String
9797

9898
var content: some View {
99-
// TODO: make proper two-way binding for DOM elements
99+
// // TODO: make proper two-way binding for DOM elements
100100
input(.type(.text))
101101
.onInput { event in
102-
value = event.targetValue ?? ""
102+
let text: String = event.targetValue ?? ""
103+
print(event.targetValue ?? "No target value")
104+
_value.wrappedValue = text
103105
}
104106
}
105107
}
@@ -121,13 +123,13 @@ struct TestValueView {
121123

122124
@View
123125
struct TestObjectView {
124-
//@Environment<SomeData>() var data
125-
// @Environment<SomeData?>() var optionalData
126+
@Environment(SomeData.self) var data: SomeData
127+
@Environment(SomeData.self) var optionalData: SomeData?
126128

127129
var content: some View {
128-
//span { "Via environment object: \(data.name)" }
130+
span { "Via environment object: \(data.name)" }
129131
// TODO: figure out how to make optional environment object work in embedded
130-
// br()
131-
// span { "Via optional environment object: \(optionalData?.name ?? "")" }
132+
br()
133+
//span { "Via optional environment object: \(optionalData?.name ?? "")" }
132134
}
133135
}
Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
11
import ElementaryDOM
22

33
App().mount(in: .body)
4-
5-
//div {}.mount(in: .body)

Examples/Basic/build-full.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
OUTDIR=Public/lib/example
2+
3+
set -ex
4+
rm -rf $OUTDIR
5+
6+
swift package \
7+
--swift-sdk "$(swiftc -print-target-info | jq -r '.swiftCompilerTag')_wasm" \
8+
--allow-writing-to-package-directory \
9+
js -c release --output $OUTDIR --use-cdn

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,16 @@ For embedded builds, a recent main or 6.2 snapshot with matching *Swift SDKs for
2828
- ~~dependencies on versioned packages (i.e., build without unsafe flags)~~
2929
- ~~fix DOM not child-diffing to preserve animations/nodes (the current solution based on `replaceChildren` will not work, it seems)~~
3030
- "model-bindings" for inputs (i.e., bind a @Binding<String> to a text box, or bind a @Binding<Bool> to a checkbox)
31-
- view value comparing (Equatable and/or memcmp if possible)
32-
- different handling of environment (individual reactivity needed)
31+
- ~~view value comparing (generated comparing and custom equatable support)~~
32+
- ~~different handling of environment (individual reactivity needed)~~
3333
- transitions and animations (ideally CSS-based, probably svelte-like custom easing functions applied through WAAPI)
34-
- proper unit testing (once APIs firm up a bit more)
34+
- proper unit testing (once APIs firm up a bit more, partially started)
35+
- implement @ViewEquatableIgnored
3536
- split out JavaScriptKit stuff in separate module to contain spread, maybe one day we can switch to faster interop somehow
3637
- add basic docs, a good intro readme, and push a 0.1 out the door! (probably best to wait for Swift 6.2 to drop)
3738
- a router implementation (probably in extra module?)
3839
- maybe conditionally support @Observable for non-embedded builds?
39-
- figure out why `@Environment` with optional `ReactiveObject` does not build in embedded
40+
- ~~figure out why `@Environment` with optional `ReactiveObject` does not build in embedded~~
4041
- preference system (i.e., bubbling up values)
4142
- embedded-friendly Browser APIs (Storage, History, maybe in swiftwasm package with new JavaScriptKit macros)
4243
- ~~think about how to deal with the lack of `Codable` in embedded (wait for new serialization macros)~~
Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,46 @@
1-
public extension ReactiveObject {
1+
internal extension ReactiveObject {
22
static var environmentKey: EnvironmentValues._Key<Self> {
33
EnvironmentValues._Key(_$typeID)
44
}
5+
6+
static var _$typeID: PropertyID {
7+
.init(ObjectIdentifier(self))
8+
}
59
}
610

711
public extension Environment {
812
init(_: V.Type = V.self) where V: ReactiveObject {
913
self.init(ObjectStorageReader(V.self))
1014
}
1115

12-
// NOTE: in embedded for some reason this causes a compiler crash around the (actually unused) StoredValue<Optional<O>> type
13-
// ¯\_(ツ)_/¯ - try again with a newer toolchain in the future
14-
@_unavailableInEmbedded
16+
// // NOTE: in embedded for some reason this causes a compiler crash around the (actually unused) StoredValue<Optional<O>> type
17+
// // ¯\_(ツ)_/¯ - try again with a newer toolchain in the future
18+
// @_unavailableInEmbedded
1519
init<O: ReactiveObject>(_: O.Type = O.self) where V == O? {
1620
self.init(ObjectStorageReader(V.self))
1721
}
1822
}
1923

2024
struct ObjectStorageReader<Value> {
21-
let read: (borrowing [PropertyID: StoredValue]) -> Value
25+
let propertyID: PropertyID
26+
let read: (borrowing AnyObject?) -> Value
2227

2328
init(_: Value.Type) where Value: ReactiveObject {
24-
let propertyID = Value.environmentKey.propertyID
25-
read = {
26-
if let value = $0[propertyID] {
27-
return value[as: Value.self]
29+
propertyID = Value._$typeID
30+
read = { box in
31+
if let box = box {
32+
return (box as! EnvironmentValues._Box<Value>).value
2833
} else {
29-
fatalError("No value for \(propertyID.description) in environment")
34+
fatalError("No value for \(Value._$typeID) in environment")
3035
}
3136
}
3237
}
3338

3439
init<O: ReactiveObject>(_: Value.Type) where Value == O? {
35-
let propertyID = O.environmentKey.propertyID
36-
read = { $0[propertyID]?[as: O.self] }
40+
propertyID = O._$typeID
41+
read = { box in
42+
guard let box else { return nil }
43+
return (box as! EnvironmentValues._Box<O>).value
44+
}
3745
}
3846
}

Sources/ElementaryDOM/Data/Environment/Environment.swift

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
public struct Environment<V> {
33
enum Storage {
44
case value(V)
5+
case valueBox(EnvironmentValues._Box<V>)
56
case valueKey(EnvironmentValues._Key<V>)
6-
case objectReader(ObjectStorageReader<V>)
7+
case objectReader(ObjectStorageReader<V>, AnyObject?)
78
}
89

910
var storage: Storage
@@ -13,17 +14,19 @@ public struct Environment<V> {
1314
}
1415

1516
init(_ objectReader: ObjectStorageReader<V>) {
16-
storage = .objectReader(objectReader)
17+
storage = .objectReader(objectReader, nil)
1718
}
1819

1920
public var wrappedValue: V {
2021
switch storage {
2122
case let .value(value):
2223
value
24+
case let .valueBox(box):
25+
box.value
2326
case let .valueKey(accessor):
2427
accessor.defaultValue
25-
case let .objectReader(reader):
26-
reader.read([:])
28+
case let .objectReader(reader, box):
29+
reader.read(box)
2730
}
2831
}
2932

@@ -34,24 +37,68 @@ public struct Environment<V> {
3437
mutating func __load(from values: borrowing EnvironmentValues) {
3538
switch storage {
3639
case let .valueKey(key):
37-
storage = .value(values[key])
38-
case let .objectReader(reader):
39-
storage = .value(reader.read(values.values))
40+
if let box = values.boxes[key.propertyID] {
41+
storage = .valueBox(box as! EnvironmentValues._Box<V>)
42+
} else {
43+
storage = .value(key.defaultValue)
44+
}
45+
case let .objectReader(reader, _):
46+
storage = .objectReader(reader, values.boxes[reader.propertyID])
47+
#if hasFeature(Embedded)
48+
// FIXME: embedded - create issue and check with main
49+
if __omg_this_was_annoying_I_am_false {
50+
// NOTE: this is only to force inclusion of the the box type for V
51+
storage = .valueBox(EnvironmentValues._Box<V>(reader.read(values.boxes[reader.propertyID])))
52+
}
53+
#endif
4054
default:
4155
fatalError("Cannot load environment value twice")
4256
}
4357
}
4458
}
4559

46-
public struct EnvironmentValues: _ValueStorage {
47-
var values: [PropertyID: StoredValue] = [:]
60+
public struct EnvironmentValues {
61+
public typealias _Key<Value> = _StorageKey<Self, Value>
62+
63+
var boxes: [PropertyID: AnyObject] = [:]
4864

4965
public subscript<Value>(key: _Key<Value>) -> Value {
5066
get {
51-
values[key.propertyID]?[as: Value.self] ?? key.defaultValue
67+
(boxes[key.propertyID] as? _Box<Value>)?.value ?? key.defaultValue
5268
}
5369
set {
54-
values[key.propertyID] = StoredValue(newValue)
70+
boxes[key.propertyID] = _Box<Value>(newValue)
71+
}
72+
}
73+
}
74+
75+
extension EnvironmentValues {
76+
public final class _Box<Value> {
77+
let _value_id = PropertyID(0)
78+
var _registrar = ReactivityRegistrar()
79+
80+
var _value: Value
81+
82+
var value: Value {
83+
get {
84+
_registrar.access(_value_id)
85+
return _value
86+
}
87+
set {
88+
_registrar.willSet(_value_id)
89+
_value = newValue
90+
_registrar.didSet(_value_id)
91+
}
92+
_modify {
93+
_registrar.access(_value_id)
94+
_registrar.willSet(_value_id)
95+
defer { _registrar.didSet(_value_id) }
96+
yield &_value
97+
}
98+
}
99+
100+
init(_ value: Value) {
101+
self._value = value
55102
}
56103
}
57104
}
Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,51 @@
11
public extension View {
22
consuming func environment<V>(_ key: EnvironmentValues._Key<V>, _ value: V) -> _EnvironmentView<V, Self> {
3-
_EnvironmentView(wrapped: self, key: key, value: value)
3+
_EnvironmentView(wrapped: self, key: key, value: value, isEqual: nil)
4+
}
5+
6+
consuming func environment<V>(_ key: EnvironmentValues._Key<V>, _ value: V) -> _EnvironmentView<V, Self>
7+
where V: Equatable {
8+
_EnvironmentView(wrapped: self, key: key, value: value, isEqual: ==)
9+
}
10+
11+
consuming func environment(_ key: EnvironmentValues._Key<String>, _ value: String) -> _EnvironmentView<String, Self> {
12+
_EnvironmentView(wrapped: self, key: key, value: value, isEqual: String.utf8Equals)
13+
}
14+
15+
consuming func environment<V>(_ key: EnvironmentValues._Key<V>, _ value: V) -> _EnvironmentView<V, Self>
16+
where V: Equatable & AnyObject {
17+
_EnvironmentView(wrapped: self, key: key, value: value, isEqual: ===)
18+
}
19+
20+
consuming func environment<V>(_ key: EnvironmentValues._Key<V>, _ value: V) -> _EnvironmentView<V, Self>
21+
where V: AnyObject {
22+
_EnvironmentView(wrapped: self, key: key, value: value, isEqual: ===)
423
}
524

625
consuming func environment<V: ReactiveObject>(_ object: V) -> _EnvironmentView<V, Self> {
7-
_EnvironmentView(wrapped: self, key: V.environmentKey, value: object)
26+
_EnvironmentView(wrapped: self, key: V.environmentKey, value: object, isEqual: ===)
827
}
928
}
1029

1130
public struct _EnvironmentView<V, Wrapped: View>: View {
12-
public typealias _MountedNode = Wrapped._MountedNode
13-
31+
public typealias _MountedNode = _StatefulNode<EnvironmentValues._Box<V>, Wrapped._MountedNode>
1432
public typealias Tag = Wrapped.Tag
33+
1534
let wrapped: Wrapped
1635
let key: EnvironmentValues._Key<V>
1736
let value: V
37+
let isEqual: ((V, V) -> Bool)?
1838

1939
public static func _makeNode(
2040
_ view: consuming Self,
2141
context: consuming _ViewContext,
2242
reconciler: inout _RenderContext
2343
) -> _MountedNode {
24-
context.environment[view.key] = view.value
25-
return Wrapped._makeNode(view.wrapped, context: context, reconciler: &reconciler)
44+
45+
let box = EnvironmentValues._Box<V>(view.value)
46+
context.environment.boxes[view.key.propertyID] = box
47+
48+
return .init(state: box, child: Wrapped._makeNode(view.wrapped, context: context, reconciler: &reconciler))
2649
}
2750

2851
public static func _patchNode(
@@ -31,7 +54,20 @@ public struct _EnvironmentView<V, Wrapped: View>: View {
3154
node: inout _MountedNode,
3255
reconciler: inout _RenderContext
3356
) {
34-
context.environment[view.key] = view.value
35-
Wrapped._patchNode(view.wrapped, context: context, node: &node, reconciler: &reconciler)
57+
// IMPORTANT: _value does not cause access tracking!
58+
if view.isEqual?(node.state._value, view.value) ?? true {
59+
node.state._value = view.value
60+
} else {
61+
// NOTE: a bit of a hack to allow dependent functions to run in the same reconciler run
62+
reconciler.scheduler.withAmbientRenderContext(
63+
&reconciler,
64+
{
65+
node.state.value = view.value
66+
}
67+
)
68+
}
69+
70+
context.environment.boxes[view.key.propertyID] = node.state
71+
Wrapped._patchNode(view.wrapped, context: context, node: &node.child, reconciler: &reconciler)
3672
}
3773
}

Sources/ElementaryDOM/Data/State/Binding.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ public struct Binding<V> {
3030
case let .stateAccessor(accessor):
3131
accessor.value = newValue
3232
case let .getSet(_, set):
33+
#if hasFeature(Embedded)
34+
// FIXME: embedded - create issue and check with main
35+
if __omg_this_was_annoying_I_am_false {
36+
_ = AnyValueBox.init(newValue)
37+
}
38+
#endif
39+
3340
set(newValue)
3441
}
3542
}

Sources/ElementaryDOM/Data/State/ViewStateStorage.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import Reactivity
44
public final class _ViewStateStorage {
55
// TODO: maybe we can store AnyObjects directly instread of double-boxing them
66
@ReactiveIgnored
7-
private var values: [StoredValue] = []
7+
private var values: [AnyValueBox] = []
88

99
public init() {}
1010

@@ -14,12 +14,12 @@ public final class _ViewStateStorage {
1414

1515
public func initializeValueStorage<V>(initialValue: V, index: Int) {
1616
precondition(index == values.count, "State storage must be initialized in order")
17-
values.append(StoredValue(initialValue))
17+
values.append(AnyValueBox(initialValue))
1818
}
1919

2020
public func initializeValueStorage<V: AnyObject>(initialValue: V, index: Int) {
2121
precondition(index == values.count, "State storage must be initialized in order")
22-
values.append(StoredValue(initialValue))
22+
values.append(AnyValueBox(initialValue))
2323
}
2424

2525
public subscript<V>(_ index: Int, as type: V.Type = V.self) -> V {

0 commit comments

Comments
 (0)