diff --git a/plugins/backstage-plugin-coder/src/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts index 0b5151ca..d4b613c7 100644 --- a/plugins/backstage-plugin-coder/src/typesConstants.ts +++ b/plugins/backstage-plugin-coder/src/typesConstants.ts @@ -9,6 +9,14 @@ import { optional, } from 'valibot'; +export type ReadonlyJsonValue = + | string + | number + | boolean + | null + | readonly ReadonlyJsonValue[] + | Readonly<{ [key: string]: ReadonlyJsonValue }>; + export const DEFAULT_CODER_DOCS_LINK = 'https://coder.com/docs/v2/latest'; export const workspaceAgentStatusSchema = union([ diff --git a/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.test.ts b/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.test.ts new file mode 100644 index 00000000..42f92312 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.test.ts @@ -0,0 +1,204 @@ +import type { ReadonlyJsonValue } from '../typesConstants'; +import { + StateSnapshotManager, + defaultDidSnapshotsChange, +} from './StateSnapshotManager'; + +describe(`${defaultDidSnapshotsChange.name}`, () => { + type SampleInput = Readonly<{ + snapshotA: ReadonlyJsonValue; + snapshotB: ReadonlyJsonValue; + }>; + + it('Will detect when two JSON primitives are the same', () => { + const samples = [ + { snapshotA: true, snapshotB: true }, + { snapshotA: 'cat', snapshotB: 'cat' }, + { snapshotA: 2, snapshotB: 2 }, + { snapshotA: null, snapshotB: null }, + ] as const satisfies readonly SampleInput[]; + + for (const { snapshotA, snapshotB } of samples) { + expect(defaultDidSnapshotsChange(snapshotA, snapshotB)).toBe(false); + } + }); + + it('Will detect when two JSON primitives are different', () => { + const samples = [ + { snapshotA: true, snapshotB: false }, + { snapshotA: 'cat', snapshotB: 'dog' }, + { snapshotA: 2, snapshotB: 789 }, + { snapshotA: null, snapshotB: 'blah' }, + ] as const satisfies readonly SampleInput[]; + + for (const { snapshotA, snapshotB } of samples) { + expect(defaultDidSnapshotsChange(snapshotA, snapshotB)).toBe(true); + } + }); + + it('Will detect when a value flips from a primitive to an object (or vice versa)', () => { + expect(defaultDidSnapshotsChange(null, {})).toBe(true); + expect(defaultDidSnapshotsChange({}, null)).toBe(true); + }); + + it('Will reject numbers that changed by a very small floating-point epsilon', () => { + expect(defaultDidSnapshotsChange(3, 3 / 1.00000001)).toBe(false); + }); + + it('Will check array values one level deep', () => { + const snapshotA = [1, 2, 3]; + + const snapshotB = [...snapshotA]; + expect(defaultDidSnapshotsChange(snapshotA, snapshotB)).toBe(false); + + const snapshotC = [...snapshotA, 4]; + expect(defaultDidSnapshotsChange(snapshotA, snapshotC)).toBe(true); + + const snapshotD = [...snapshotA, {}]; + expect(defaultDidSnapshotsChange(snapshotA, snapshotD)).toBe(true); + }); + + it('Will check object values one level deep', () => { + const snapshotA = { cat: true, dog: true }; + + const snapshotB = { ...snapshotA, dog: true }; + expect(defaultDidSnapshotsChange(snapshotA, snapshotB)).toBe(false); + + const snapshotC = { ...snapshotA, bird: true }; + expect(defaultDidSnapshotsChange(snapshotA, snapshotC)).toBe(true); + + const snapshotD = { ...snapshotA, value: {} }; + expect(defaultDidSnapshotsChange(snapshotA, snapshotD)).toBe(true); + }); +}); + +describe(`${StateSnapshotManager.name}`, () => { + it('Lets external systems subscribe and unsubscribe to internal snapshot changes', () => { + type SampleData = Readonly<{ + snapshotA: ReadonlyJsonValue; + snapshotB: ReadonlyJsonValue; + }>; + + const samples = [ + { snapshotA: false, snapshotB: true }, + { snapshotA: 0, snapshotB: 1 }, + { snapshotA: 'cat', snapshotB: 'dog' }, + { snapshotA: null, snapshotB: 'neat' }, + { snapshotA: {}, snapshotB: { different: true } }, + { snapshotA: [], snapshotB: ['I have a value now!'] }, + ] as const satisfies readonly SampleData[]; + + for (const { snapshotA, snapshotB } of samples) { + const subscriptionCallback = jest.fn(); + const manager = new StateSnapshotManager({ + initialSnapshot: snapshotA, + didSnapshotsChange: defaultDidSnapshotsChange, + }); + + const unsubscribe = manager.subscribe(subscriptionCallback); + manager.updateSnapshot(snapshotB); + expect(subscriptionCallback).toHaveBeenCalledTimes(1); + expect(subscriptionCallback).toHaveBeenCalledWith(snapshotB); + + unsubscribe(); + manager.updateSnapshot(snapshotA); + expect(subscriptionCallback).toHaveBeenCalledTimes(1); + } + }); + + it('Lets user define a custom comparison algorithm during instantiation', () => { + type SampleData = Readonly<{ + snapshotA: ReadonlyJsonValue; + snapshotB: ReadonlyJsonValue; + compare: (A: ReadonlyJsonValue, B: ReadonlyJsonValue) => boolean; + }>; + + const exampleDeeplyNestedJson: ReadonlyJsonValue = { + value1: { + value2: { + value3: 'neat', + }, + }, + + value4: { + value5: [{ valueX: true }, { valueY: false }], + }, + }; + + const samples = [ + { + snapshotA: exampleDeeplyNestedJson, + snapshotB: { + ...exampleDeeplyNestedJson, + value4: { + value5: [{ valueX: false }, { valueY: false }], + }, + }, + compare: (A, B) => JSON.stringify(A) !== JSON.stringify(B), + }, + { + snapshotA: { tag: 'snapshot-993', value: 1 }, + snapshotB: { tag: 'snapshot-2004', value: 1 }, + compare: (A, B) => { + const recastA = A as Record; + const recastB = B as Record; + return recastA.tag !== recastB.tag; + }, + }, + ] as const satisfies readonly SampleData[]; + + for (const { snapshotA, snapshotB, compare } of samples) { + const subscriptionCallback = jest.fn(); + const manager = new StateSnapshotManager({ + initialSnapshot: snapshotA, + didSnapshotsChange: compare, + }); + + void manager.subscribe(subscriptionCallback); + manager.updateSnapshot(snapshotB); + expect(subscriptionCallback).toHaveBeenCalledWith(snapshotB); + } + }); + + it('Rejects new snapshots that are equivalent to old ones, and does NOT notify subscribers', () => { + type SampleData = Readonly<{ + snapshotA: ReadonlyJsonValue; + snapshotB: ReadonlyJsonValue; + }>; + + const samples = [ + { snapshotA: true, snapshotB: true }, + { snapshotA: 'kitty', snapshotB: 'kitty' }, + { snapshotA: null, snapshotB: null }, + { snapshotA: [], snapshotB: [] }, + { snapshotA: {}, snapshotB: {} }, + ] as const satisfies readonly SampleData[]; + + for (const { snapshotA, snapshotB } of samples) { + const subscriptionCallback = jest.fn(); + const manager = new StateSnapshotManager({ + initialSnapshot: snapshotA, + didSnapshotsChange: defaultDidSnapshotsChange, + }); + + void manager.subscribe(subscriptionCallback); + manager.updateSnapshot(snapshotB); + expect(subscriptionCallback).not.toHaveBeenCalled(); + } + }); + + it("Uses the default comparison algorithm if one isn't specified at instantiation", () => { + const snapshotA = { value: 'blah' }; + const snapshotB = { value: 'blah' }; + + const manager = new StateSnapshotManager({ + initialSnapshot: snapshotA, + }); + + const subscriptionCallback = jest.fn(); + void manager.subscribe(subscriptionCallback); + manager.updateSnapshot(snapshotB); + + expect(subscriptionCallback).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts b/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts new file mode 100644 index 00000000..a109909d --- /dev/null +++ b/plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts @@ -0,0 +1,166 @@ +/** + * @file A helper class that simplifies the process of connecting mutable class + * values (such as the majority of values from API factories) with React's + * useSyncExternalStore hook. + * + * This should not be used directly from within React, but should instead be + * composed into other classes (such as API factories). Those classes can then + * be brought into React. + * + * As long as you can figure out how to turn the mutable values in some other + * class into an immutable snapshot, all you have to do is pass the new snapshot + * into this class. It will then take care of notifying subscriptions, while + * reconciling old/new snapshots to minimize needless re-renders. + */ +import type { ReadonlyJsonValue } from '../typesConstants'; + +type SubscriptionCallback = ( + snapshot: TSnapshot, +) => void; + +type DidSnapshotsChange = ( + oldSnapshot: TSnapshot, + newSnapshot: TSnapshot, +) => boolean; + +type SnapshotManagerOptions = Readonly<{ + initialSnapshot: TSnapshot; + + /** + * Lets you define a custom comparison strategy for detecting whether a + * snapshot has really changed in a way that should be reflected in the UI. + */ + didSnapshotsChange?: DidSnapshotsChange; +}>; + +interface SnapshotManagerApi { + subscribe: (callback: SubscriptionCallback) => () => void; + unsubscribe: (callback: SubscriptionCallback) => void; + getSnapshot: () => TSnapshot; + updateSnapshot: (newSnapshot: TSnapshot) => void; +} + +function areSameByReference(v1: unknown, v2: unknown) { + // Comparison looks wonky, but Object.is handles more edge cases than === + // for these kinds of comparisons, but it itself has an edge case + // with -0 and +0. Still need === to handle that comparison + return Object.is(v1, v2) || (v1 === 0 && v2 === 0); +} + +/** + * Favors shallow-ish comparisons (will check one level deep for objects and + * arrays, but no more) + */ +export function defaultDidSnapshotsChange( + oldSnapshot: TSnapshot, + newSnapshot: TSnapshot, +): boolean { + if (areSameByReference(oldSnapshot, newSnapshot)) { + return false; + } + + const oldIsPrimitive = + typeof oldSnapshot !== 'object' || oldSnapshot === null; + const newIsPrimitive = + typeof newSnapshot !== 'object' || newSnapshot === null; + + if (oldIsPrimitive && newIsPrimitive) { + const numbersAreWithinTolerance = + typeof oldSnapshot === 'number' && + typeof newSnapshot === 'number' && + Math.abs(oldSnapshot - newSnapshot) < 0.00005; + + if (numbersAreWithinTolerance) { + return false; + } + + return oldSnapshot !== newSnapshot; + } + + const changedFromObjectToPrimitive = !oldIsPrimitive && newIsPrimitive; + const changedFromPrimitiveToObject = oldIsPrimitive && !newIsPrimitive; + + if (changedFromObjectToPrimitive || changedFromPrimitiveToObject) { + return true; + } + + if (Array.isArray(oldSnapshot) && Array.isArray(newSnapshot)) { + const sameByShallowComparison = + oldSnapshot.length === newSnapshot.length && + oldSnapshot.every((element, index) => + areSameByReference(element, newSnapshot[index]), + ); + + return !sameByShallowComparison; + } + + const oldInnerValues: unknown[] = Object.values(oldSnapshot as Object); + const newInnerValues: unknown[] = Object.values(newSnapshot as Object); + + if (oldInnerValues.length !== newInnerValues.length) { + return true; + } + + for (const [index, value] of oldInnerValues.entries()) { + if (value !== newInnerValues[index]) { + return true; + } + } + + return false; +} + +/** + * @todo Might eventually make sense to give the class the ability to merge + * snapshots more surgically and maximize structural sharing (which should be + * safe since the snapshots are immutable). But we can worry about that when it + * actually becomes a performance issue + */ +export class StateSnapshotManager< + TSnapshot extends ReadonlyJsonValue = ReadonlyJsonValue, +> implements SnapshotManagerApi +{ + private subscriptions: Set>; + private didSnapshotsChange: DidSnapshotsChange; + private activeSnapshot: TSnapshot; + + constructor(options: SnapshotManagerOptions) { + const { initialSnapshot, didSnapshotsChange } = options; + + this.subscriptions = new Set(); + this.activeSnapshot = initialSnapshot; + this.didSnapshotsChange = didSnapshotsChange ?? defaultDidSnapshotsChange; + } + + private notifySubscriptions(): void { + const snapshotBinding = this.activeSnapshot; + this.subscriptions.forEach(cb => cb(snapshotBinding)); + } + + unsubscribe = (callback: SubscriptionCallback): void => { + this.subscriptions.delete(callback); + }; + + subscribe = (callback: SubscriptionCallback): (() => void) => { + this.subscriptions.add(callback); + return () => this.unsubscribe(callback); + }; + + getSnapshot = (): TSnapshot => { + return this.activeSnapshot; + }; + + updateSnapshot = (newSnapshot: TSnapshot): void => { + const snapshotsChanged = this.didSnapshotsChange( + this.activeSnapshot, + newSnapshot, + ); + + if (!snapshotsChanged) { + return; + } + + this.activeSnapshot = newSnapshot; + this.notifySubscriptions(); + }; +}