Last active
November 12, 2024 16:46
-
-
Save markmals/e880043a5f59436b2cc581f9692e6fd6 to your computer and use it in GitHub Desktop.
Vanilla Reactive System
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
// Credit Ryan Carniato: https://frontendmasters.com/courses/reactivity-solidjs/ | |
// & Marc Grabanski: https://gist.github.com/1Marc/09e739caa6a82cc176ab4c2abd691814 | |
extension Array<Observer> { | |
fileprivate var current: Observer? { | |
last | |
} | |
} | |
private final class Observer { | |
// FIXME: This is not thread safe | |
// This is usually implemented as a thread local value in multi-threaded languages: | |
// - Example in Swift: https://github.com/unixzii/swift-signal/blob/main/Sources/SwiftSignal/Scope.swift#L8 | |
// - Example in Rust: https://github.com/leptos-rs/leptos/blob/main/reactive_graph/src/graph/subscriber.rs#L5-L7 | |
static var context: [Observer] = [] | |
private var sideEffect: (() -> Void) = {} | |
private var dependencies = Set<ReferenceSet<Observer>>() | |
func registerEffect(_ executor: @escaping () -> Void) { | |
sideEffect = executor | |
} | |
func execute() { | |
// start a fresh run to capture new dependencies and remove stale dependencies | |
cleanup() | |
// Add ourselves to the context so any signals about to run can associate themselves with us | |
Self.context.append(self) | |
// Run the side effect and capture dependencies | |
sideEffect() | |
// Remove ourselves from the context | |
Self.context.removeLast() | |
} | |
func observe<T>(_ signal: Signal<T>) { | |
signal.subscriptions.insert(self) | |
dependencies.insert(signal.subscriptions) | |
} | |
private func cleanup() { | |
for dependency in dependencies { | |
dependency.remove(self) | |
} | |
dependencies.removeAll() | |
} | |
} | |
extension Observer: Hashable { | |
static func == (lhs: Observer, rhs: Observer) -> Bool { | |
lhs === rhs | |
} | |
func hash(into hasher: inout Hasher) { | |
hasher.combine(ObjectIdentifier(self)) | |
} | |
} | |
// Reactive atomic value | |
// Reading from `value` inside of an `effect` will cause the effect to re-run when `value` is updated | |
public final class Signal<T> { | |
fileprivate var subscriptions = ReferenceSet<Observer>() | |
private var storage: T | |
public var value: T { | |
get { | |
// Get the current Observer from the context and start observing ourself | |
Observer.context.current?.observe(self) | |
return storage | |
} | |
set { | |
storage = newValue | |
for observer in subscriptions { | |
// Run the side effect for all of the Observers observing us | |
observer.execute() | |
} | |
} | |
} | |
public init(_ initialValue: T) { | |
storage = initialValue | |
} | |
} | |
// Reactive side-effect | |
// Any signals read inside of `sideEffectFunction` will be observed and `sideEffectFunction` | |
// will be re-run when any observed signals update | |
public func effect(_ sideEffectFunction: @escaping () -> Void) { | |
let observer = Observer() | |
observer.registerEffect(sideEffectFunction) | |
// Effects must run immediately to capture their dependencies | |
observer.execute() | |
} | |
// Returns a signal that only updates when signals used inside `cachedExpression` update | |
public func memoize<T>(_ cachedExpression: @escaping @autoclosure () -> T) -> (() -> T) { | |
let memoizedSignal = Signal<T?>(nil) | |
// Only update `memoizedSignal` when signals used inside `cachedExpression` change | |
effect { memoizedSignal.value = cachedExpression() } | |
// return a read-only signal (a closure) | |
return { memoizedSignal.value! } | |
} | |
// Reads the value returned by `nonReactiveReadsFn` without tracking any signals | |
// used inside `nonReactiveReadsFn` | |
public func untrack<T>(_ nonReactiveReadsFn: () -> T) -> T { | |
let prevContext = Observer.context | |
Observer.context = [] | |
let res = nonReactiveReadsFn() | |
Observer.context = prevContext | |
return res | |
} | |
// MARK: Usage Examples | |
let count = Signal(0) | |
let doubleCount = memoize(count.value * 2) | |
effect { | |
print("2 × \(count.value) is \(doubleCount())") | |
} | |
for _ in 0...4 { | |
try? await Task.sleep(.seconds(2)) | |
count.value = count.value + 1 | |
} | |
// Prints: | |
// 2 × 0 is 0 | |
// 2 × 1 is 2 | |
// 2 × 2 is 4 | |
// 2 × 3 is 6 | |
// 2 × 4 is 8 |
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
// A reference type Set | |
private final class ReferenceSet<Element: Hashable>: Hashable, Collection { | |
typealias Element = Element | |
typealias Iterator = Set<Element>.Iterator | |
typealias Index = Set<Element>.Index | |
typealias Indices = Set<Element>.Indices | |
typealias SubSequence = Set<Element>.SubSequence | |
private var inner = Set<Element>() | |
func makeIterator() -> Iterator { | |
inner.makeIterator() | |
} | |
var startIndex: Index { | |
inner.startIndex | |
} | |
var endIndex: Index { | |
inner.endIndex | |
} | |
var indices: Indices { | |
inner.indices | |
} | |
func index(after i: Index) -> Index { | |
inner.index(after: i) | |
} | |
subscript(position: Index) -> Element { | |
inner[position] | |
} | |
subscript(bounds: Range<Index>) -> SubSequence { | |
inner[bounds] | |
} | |
func insert(_ newMember: Element) { | |
inner.insert(newMember) | |
} | |
func remove(_ member: Element) { | |
inner.remove(member) | |
} | |
static func == (lhs: ReferenceSet, rhs: ReferenceSet) -> Bool { | |
lhs.inner == rhs.inner | |
} | |
func hash(into hasher: inout Hasher) { | |
inner.hash(into: &hasher) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This does look good, except for one thing. I actually don't know what happens when you ignore the result of an
async let
like that! I guess it is equivalent to but I'm not actually sure.