Skip to content

Instantly share code, notes, and snippets.

@markmals
Last active December 3, 2023 21:35
Show Gist options
  • Save markmals/0b08ec8a205efe2b1d9ec0f5aa90fb98 to your computer and use it in GitHub Desktop.
Save markmals/0b08ec8a205efe2b1d9ec0f5aa90fb98 to your computer and use it in GitHub Desktop.
Simple implementation of the observer design pattern in TypeScript with Lit integration
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;
}
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);
@markmals
Copy link
Author

markmals commented Dec 3, 2023

Example usage:

@observable()
class Contact {
    name: string;
    age: number;

    constructor({ name, age }: { name: string; age: number }) {
        this.name = name;
        this.age = age;
    }

    haveBirthday() {
        this.age += 1;
    }
}

type InputEvent = Event & { target: HTMLInputElement };

@customElement('observable-example')
class Example extends View {
    contact = new Contact({ name: 'Mark', age: 29 });

    render() {
        return html`
            <input
                value="${this.contact.name}"
                @input="${(e: InputEvent) => (this.contact.name = e.target.value)}"
            />
            <div>${this.contact.name} is ${this.contact.age} years old</div>
            <button @click="${() => this.contact.haveBirthday()}">Birthday!</button>
        `;
    }
}

@markmals
Copy link
Author

markmals commented Dec 3, 2023

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