From 6d8fa3b74fe7167468998277f808463a75180083 Mon Sep 17 00:00:00 2001 From: nicholasrice Date: Sat, 11 May 2024 09:48:22 -0700 Subject: [PATCH 1/2] initial version of library extension working --- src/lib/library.ts | 197 ++++++++++++++++++++++++++++++--------- src/test/library.spec.ts | 154 ++++++++++++++++++++++++++++++ 2 files changed, 309 insertions(+), 42 deletions(-) diff --git a/src/lib/library.ts b/src/lib/library.ts index 1989eeb..491716c 100644 --- a/src/lib/library.ts +++ b/src/lib/library.ts @@ -12,6 +12,7 @@ export namespace Library { tokens: TokenLibrary; subscribe(subscriber: Library.Subscriber): void; unsubscribe(subscriber: Library.Subscriber): void; + extend(config: any): Library; // TODO should not be any } export interface Subscriber { @@ -121,7 +122,7 @@ export namespace Library { export const create = ( config: Library.Config ): Library.Library => { - return new LibraryImpl(config); + return LibraryImpl.create(config); }; } @@ -209,6 +210,112 @@ const recurseCreate = ( } }; +const recurseExtend = ( + name: string, + sourceTokens: Library.TokenLibrary, + extendedTokens: Library.TokenLibrary, + config: Library.Config, // TODO allow new config options + context: Library.TokenLibrary, + typeContext: DesignToken.Type | null, + queue: IQueue> +): void => { + const keys = new Set(Object.keys(sourceTokens).concat(Object.keys(config))); // Remove duplicate keys + + for (const key of keys) { + const sourceHasKey = key in sourceTokens; + const configHasKey = key in config; + const _name = name.length === 0 ? key : `${name}.${key}`; + const keyIsGroup = isGroup(sourceTokens[key]); + const keyIsToken = isToken(sourceTokens[key]); + + if (key === "type") { + typeContext = sourceTokens[key] as any; + continue; + } + + if (keyIsGroup) { + Reflect.defineProperty( + extendedTokens, + key, + Object.create(sourceTokens[key]) + ); + if (sourceHasKey) { + recurseExtend( + _name, + sourceTokens[key] as any, + extendedTokens[key] as any, + config[key] || {}, + context, + (sourceTokens.type || typeContext) as any, + queue + ); + } else if (configHasKey) { + // This will always be the case + recurseCreate( + _name, + sourceTokens[key] as any, + extendedTokens[key], + context, + (sourceTokens.type || typeContext) as any, + queue + ); + } + } else if (keyIsToken) { + const token = extendToken( + sourceTokens[key] as Library.Token, + context, + queue, + config[key] + ); + + Reflect.defineProperty(extendedTokens, key, { + get() { + // Token access needs to be tracked because an alias token + // is a function that returns a token + Watcher.track(token); + return token; + }, + enumerable: true, + }); + } + } +}; + +function extendToken( + token: Library.Token, + context: Library.Context, + queue: IQueue, + value?: any +) { + const extendingToken = Object.create(token); + extendingToken.context = context; + extendingToken.cached = empty; + extendingToken.watchContext = extendingToken; + extendingToken.queue = queue; + + if (value !== undefined) { + extendingToken.raw = value; + } else { + // Subscribe to changes + // spy on set, unsubscribe when set + const subscriber: ISubscriber> = { + onChange() { + extendingToken.onChange(); + }, + }; + // token.value; + getNotifier(token).subscribe(subscriber); + const set = extendingToken.set; + extendingToken.set = (value: any) => { + getNotifier(token).unsubscribe(subscriber); + set.call(extendingToken, value); + extendingToken.set = set; + }; + } + + return extendingToken; +} + const recurseResolve = (value: any, context: Library.Context) => { const r: any = Array.isArray(value) ? [] : {}; for (const key in value) { @@ -233,20 +340,33 @@ const recurseResolve = (value: any, context: Library.Context) => { }; class LibraryImpl implements Library.Library { - private readonly queue: IQueue> = - new Queue(); - constructor(config: Library.Config) { - const tokens: Library.TokenLibrary = {}; - recurseCreate("", tokens, config, tokens, null, this.queue); - this.tokens = tokens; - } - public tokens: Library.TokenLibrary; + constructor( + public readonly tokens: Library.TokenLibrary, + private readonly queue: IQueue> + ) {} public subscribe(subscriber: Library.Subscriber) { this.queue.subscribe(subscriber); } public unsubscribe(subscriber: Library.Subscriber) { this.queue.unsubscribe(subscriber); } + + public extend(config: Library.Config) { + // TODO should not type Library.Config + const queue = new Queue(); + const tokens: Library.TokenLibrary = {}; + recurseExtend("", this.tokens, tokens, config, tokens, null, queue); + + return new LibraryImpl(tokens, queue); + } + + public static create(config: Library.Config) { + const queue = new Queue(); + const tokens: Library.TokenLibrary = {}; + recurseCreate("", tokens, config, tokens, null, queue); + + return new LibraryImpl(tokens, queue); + } } /** @@ -258,90 +378,83 @@ class LibraryToken ISubscriber>, IWatcher { - #context: Library.Context; - #raw: DesignToken.ValueByToken | Library.Alias; - #cached: DesignToken.ValueByToken | typeof empty = empty; - #subscriptions: Set> = new Set(); - #type: DesignToken.TypeByToken; - #extensions: Record; + private raw: DesignToken.ValueByToken | Library.Alias; + private cached: DesignToken.ValueByToken | typeof empty = empty; + private subscriptions: Set> = new Set(); constructor( public readonly name: string, value: DesignToken.ValueByToken | Library.Alias, - type: DesignToken.TypeByToken, - context: Library.Context, - public readonly description: string, - extensions: Record, + private readonly _type: DesignToken.TypeByToken, + private readonly context: Library.Context, + private readonly _description: string, + private readonly _extensions: Record, private queue: IQueue> ) { - this.#raw = value; - this.#context = context; - this.#type = type; - this.#extensions = extensions; + this.raw = value; + this.context = context; } public get type() { - return this.#type; + return this._type; + } + + public get description() { + return this._description; } public get extensions() { - return this.#extensions; + return this._extensions; } /** * Gets the token value */ public get value(): T["value"] { - if (this.#cached !== empty) { - return this.#cached; + if (this.cached !== empty) { + return this.cached; } this.disconnect(); const stopWatching = Watcher.use(this); - const raw = isAlias(this.#raw) ? this.#raw(this.#context) : this.#raw; + const raw = isAlias(this.raw) ? this.raw(this.context) : this.raw; const normalized = isToken(raw) ? raw.value : raw; const value = isObject(normalized) - ? recurseResolve(normalized, this.#context) + ? recurseResolve(normalized, this.context) : normalized; - this.#cached = value; + this.cached = value; stopWatching(); return value; } public set(value: DesignToken.ValueByToken | Library.Alias) { - this.#raw = value; + this.raw = value; this.onChange(); } public onChange(): void { this.queue.add(this); - // Only react if the token hasn't already been invalidated - // This prevents the token notifying multiple times - // if a combination of it's dependencies change before - // the value is re-calculated - if (this.#cached !== empty) { - this.#cached = empty; - getNotifier(this).notify(); - } + this.cached = empty; + getNotifier(this).notify(); } public watch(source: Object): void { const notifier = getNotifier(source); notifier.subscribe(this); - this.#subscriptions.add(notifier); + this.subscriptions.add(notifier); } /** * Disconnect the token from it's subscriptions */ public disconnect() { - for (const record of this.#subscriptions.values()) { + for (const record of this.subscriptions.values()) { record.unsubscribe(this); - this.#subscriptions.delete(record); + this.subscriptions.delete(record); } } } diff --git a/src/test/library.spec.ts b/src/test/library.spec.ts index afe6637..c44d9af 100644 --- a/src/test/library.spec.ts +++ b/src/test/library.spec.ts @@ -3,9 +3,14 @@ import * as Assert from "uvu/assert"; import { spy } from "sinon"; import { Library } from "../lib/library.js"; import { DesignToken } from "../lib/design-token.js"; +interface ABTheme { + a: DesignToken.Color; + b: DesignToken.Color; +} const Description = suite("DesignToken.description"); const Lib = suite("DesignToken.Library"); +const Extend = suite("DesignToken.Library.Extend"); const Name = suite("DesignToken.name"); const Subscription = suite("DesignToken.subscription"); const Type = suite("DesignToken.type"); @@ -519,8 +524,157 @@ Lib("should be immutable", () => { ); }); +Extend( + "An extended library should have the same token metadata as the source library", + async () => { + interface Theme { + a: DesignToken.Color; + b: DesignToken.Color; + } + + const config: Library.Config = { + a: { + type: DesignToken.Type.Color, + value: "#FFFFFF", + description: "description", + extensions: { + e: "e", + }, + }, + b: { + type: DesignToken.Type.Color, + value: (context) => context.a, + }, + }; + const source = Library.create(config); + const extended = source.extend({}); + + Assert.is(extended.tokens.a.type, DesignToken.Type.Color); + Assert.is(extended.tokens.a.description, "description"); + Assert.equal(extended.tokens.a.extensions, { e: "e" }); + } +); +Extend( + "An extending library should use the extended library if no config value is set for the extending library", + () => { + const config: Library.Config = { + a: { + type: DesignToken.Type.Color, + value: "#FFFFFF", + }, + b: { + type: DesignToken.Type.Color, + value: (context) => context.a, + }, + }; + const source = Library.create(config); + const extended = source.extend({}); + + Assert.is(extended.tokens.a.value, "#FFFFFF"); + Assert.is(extended.tokens.b.value, "#FFFFFF"); + } +); +Extend( + "An extending library should use the extending if a config value is set for the extending library", + () => { + const config: Library.Config = { + a: { + type: DesignToken.Type.Color, + value: "#FFFFFF", + }, + b: { + type: DesignToken.Type.Color, + value: (context) => context.a, + }, + }; + const source = Library.create(config); + const extended = source.extend({ + b: { + value: "#000000", + }, + }); + + Assert.is(extended.tokens.b.value, "#000000"); + } +); + +Extend( + "An extending library should update its value for a token that isn't configured when the source changes", + async () => { + interface Theme { + a: DesignToken.Color; + b: DesignToken.Color; + } + + const config: Library.Config = { + a: { + type: DesignToken.Type.Color, + value: "#FFFFFF", + }, + b: { + type: DesignToken.Type.Color, + value: (context) => context.a, + }, + }; + const source = Library.create(config); + const extended = source.extend({}); + + Assert.is(extended.tokens.a.value, "#FFFFFF"); + Assert.is(extended.tokens.b.value, "#FFFFFF"); + + source.tokens.a.set("#111111"); + + Assert.is( + extended.tokens.a.value, + "#111111", + "Extended token 'a' is #111111" + ); + Assert.is( + extended.tokens.b.value, + "#111111", + "Extended token 'b' is #111111" + ); + } +); +Extend( + "An extending library should notify for tokens not configured by the extending library", + async () => { + const config: Library.Config = { + a: { + type: DesignToken.Type.Color, + value: "#FFFFFF", + }, + b: { + type: DesignToken.Type.Color, + value: (context) => context.a, + }, + }; + const source = Library.create(config); + const extending = source.extend({}); + const onChange = spy(); + const subscriber: Library.Subscriber = { + onChange, + }; + + extending.subscribe(subscriber); + + extending.tokens.b.value; // b needs to be accessed to set up watchers + source.tokens.a.set("#111111"); + await nextUpdate(); + Assert.ok(onChange.calledOnce); + Assert.is( + onChange.firstCall.args[0].length, + 2, + "Called with both a and b tokens" + ); + Assert.is(onChange.firstCall.args[0][0], extending.tokens.a, "a notified"); + Assert.is(onChange.firstCall.args[0][1], extending.tokens.b, "b notified"); + } +); + Description.run(); Lib.run(); +Extend.run(); Name.run(); Subscription.run(); Type.run(); From b68ad35094b03db7f81a20b881e2a6c1c1e8d143 Mon Sep 17 00:00:00 2001 From: nicholasrice Date: Sun, 12 May 2024 22:16:31 -0700 Subject: [PATCH 2/2] adding no-subscribe test case --- src/test/library.spec.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/test/library.spec.ts b/src/test/library.spec.ts index c44d9af..0d7f314 100644 --- a/src/test/library.spec.ts +++ b/src/test/library.spec.ts @@ -671,6 +671,36 @@ Extend( Assert.is(onChange.firstCall.args[0][1], extending.tokens.b, "b notified"); } ); +Extend( + "An extending library should not notify for tokens that are configured by the extending library", + async () => { + const config: Library.Config = { + a: { + type: DesignToken.Type.Color, + value: "#FFFFFF", + }, + b: { + type: DesignToken.Type.Color, + value: (context) => context.a, + }, + }; + const source = Library.create(config); + const extending = source.extend({ a: "#FFFFFF", b: "#000000" }); + const onChange = spy(); + const subscriber: Library.Subscriber = { + onChange, + }; + + extending.subscribe(subscriber); + + extending.tokens.b.value; // b needs to be accessed to set up watchers + extending.tokens.a.value; + source.tokens.a.set("#111111"); + + await nextUpdate(); + Assert.is(onChange.calledOnce, false); + } +); Description.run(); Lib.run();