Skip to content

Instantly share code, notes, and snippets.

@markmals
Last active November 12, 2024 16:46
Show Gist options
  • Save markmals/e880043a5f59436b2cc581f9692e6fd6 to your computer and use it in GitHub Desktop.
Save markmals/e880043a5f59436b2cc581f9692e6fd6 to your computer and use it in GitHub Desktop.
Vanilla Reactive System
// 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
// 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)
}
}
@mattmassicotte
Copy link

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.

Task {
  await background()
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment