Last active
December 3, 2023 21:35
-
-
Save markmals/0b08ec8a205efe2b1d9ec0f5aa90fb98 to your computer and use it in GitHub Desktop.
Simple implementation of the observer design pattern in TypeScript with Lit integration
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
interface Observer { | |
execute(): void; | |
dependencies: Set<Set<Observer>>; | |
} | |
let context: Observer[] = []; | |
interface Constructor<T> { | |
new (...args: any[]): T; | |
} | |
type PropertyContext = | |
| ClassAccessorDecoratorContext | |
| ClassGetterDecoratorContext | |
| ClassFieldDecoratorContext; | |
const ObservationIgnoredSymbol = Symbol(); | |
class ObservationRegistrar<Subject> { | |
#registries = new Map<keyof Subject, Set<Observer>>(); | |
#ignored: (keyof Subject)[]; | |
constructor(subject: Subject) { | |
const metadata = Object.getPrototypeOf(subject).constructor[Symbol.metadata]; | |
this.#ignored = metadata[ObservationIgnoredSymbol] ??= []; | |
} | |
/** Registers access to a specific property for observation. */ | |
access<KeyPath extends keyof Subject>(keyPath: KeyPath): void { | |
if (this.#ignored.includes(keyPath)) return; | |
let subscriptions = this.#registries.get(keyPath); | |
if (!subscriptions) { | |
subscriptions = new Set(); | |
this.#registries.set(keyPath, subscriptions); | |
} | |
const observer = context[context.length - 1]; | |
if (observer) { | |
// subscribe | |
subscriptions.add(observer); | |
observer.dependencies.add(subscriptions); | |
} | |
} | |
/** Identifies mutations to the transactions registered for observers. */ | |
withMutation<KeyPath extends keyof Subject>(keyPath: KeyPath, mutation: () => void): boolean { | |
mutation(); | |
if (this.#ignored.includes(keyPath)) return true; | |
let subscriptions = this.#registries.get(keyPath) ?? []; | |
for (const observer of [...subscriptions]) { | |
observer.execute(); | |
} | |
return true; | |
} | |
} | |
export function observable() { | |
return function <Target extends Constructor<any>>( | |
TargetCtor: Target, | |
context: ClassDecoratorContext, | |
) { | |
if (context.kind !== 'class') { | |
throw new Error('@observable() must be applied to a class.'); | |
} | |
return class Observed extends TargetCtor { | |
constructor(...args: any[]) { | |
super(...args); | |
const registrar = new ObservationRegistrar(this); | |
return new Proxy(this, { | |
get(observable, keyPath: string, receiver) { | |
registrar.access(keyPath); | |
return Reflect.get(observable, keyPath, receiver); | |
}, | |
set(observable, keyPath: string, newValue, receiver) { | |
return registrar.withMutation(keyPath, () => { | |
Reflect.set(observable, keyPath, newValue, receiver); | |
}); | |
}, | |
}); | |
} | |
}; | |
}; | |
} | |
export function observationIgnored() { | |
return function (_target: any, context: PropertyContext) { | |
if (context.static || context.private) { | |
throw new Error( | |
'@observationIgnored() can only be applied to public instance members.', | |
); | |
} | |
if (typeof context.name === 'symbol') { | |
throw new Error('@observationIgnored() cannot be applied to symbol-named properties.'); | |
} | |
const metadata = context.metadata; | |
const ignored = (metadata[ObservationIgnoredSymbol] ??= []) as string[]; | |
ignored.push(context.name); | |
}; | |
} | |
export function withObservationTracking(onChange: () => void) { | |
const observer: Observer = { | |
execute() { | |
queueMicrotask(() => { | |
// cleanup | |
for (const dependency of observer.dependencies) { | |
dependency.delete(observer); | |
} | |
observer.dependencies.clear(); | |
context.push(observer); | |
onChange(); | |
context.pop(); | |
}); | |
}, | |
dependencies: new Set(), | |
}; | |
observer.execute(); | |
} | |
export function ignoringObservation<T>(nonReactiveReadsFn: () => T) { | |
const prevContext = context; | |
context = []; | |
const result = nonReactiveReadsFn(); | |
context = prevContext; | |
return result; | |
} |
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
import { LitElement, ReactiveElement } from 'lit'; | |
import { withObservationTracking } from './observable.ts'; | |
export function Observing<ObservedElementConstructor extends Constructor<ReactiveElement>>( | |
ElementCtor: ObservedElementConstructor, | |
): ObservedElementConstructor { | |
return class extends ElementCtor { | |
override performUpdate() { | |
// ReactiveElement.performUpdate() also does this check, so we want to | |
// also bail early so we don't erroneously appear to not depend on any | |
// observable properties. | |
if (this.isUpdatePending === false) { | |
return; | |
} | |
// Tracks whether the effect callback is triggered by this performUpdate | |
// call directly, or by a observable property change. | |
let updateFromLit = true; | |
// We create a new effect to capture all observable property access within | |
// the performUpdate phase (update, render, updated, etc) of the element. | |
withObservationTracking(() => { | |
if (updateFromLit) { | |
updateFromLit = false; | |
super.performUpdate(); | |
} else { | |
// This branch is a side effect run from withObservationTracking. | |
// This will cause another call into performUpdate, which will | |
// then create a new side effect watching that update pass. | |
this.requestUpdate(); | |
} | |
}); | |
} | |
override connectedCallback(): void { | |
super.connectedCallback(); | |
// In order to listen for observable property access again after re-connection, | |
// we must re-render to capture all the current property accesses. | |
this.requestUpdate(); | |
} | |
}; | |
} | |
export const View = Observing(LitElement); |
Babel
babel.config.json
:
{
"plugins": [
[
"@babel/plugin-proposal-decorators",
{ "version": "2023-05", "decoratorsBeforeExport": true }
]
]
}
vite.config.ts
:
import { defineConfig } from 'vite';
import babel from '@rollup/plugin-babel';
export default defineConfig({
plugins: [
// ...
babel({
babelHelpers: 'bundled',
extensions: ['.js', '.ts'],
}),
],
});
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Example usage: