Skip to content

Commit

Permalink
chore: add StateSnapshotManager class
Browse files Browse the repository at this point in the history
  • Loading branch information
Parkreiner committed Apr 23, 2024
1 parent 04a1c15 commit 1d956bd
Show file tree
Hide file tree
Showing 3 changed files with 378 additions and 0 deletions.
8 changes: 8 additions & 0 deletions plugins/backstage-plugin-coder/src/typesConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
204 changes: 204 additions & 0 deletions plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
const recastB = B as Record<string, unknown>;
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();
});
});
166 changes: 166 additions & 0 deletions plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts
Original file line number Diff line number Diff line change
@@ -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<TSnapshot extends ReadonlyJsonValue> = (
snapshot: TSnapshot,
) => void;

type DidSnapshotsChange<TSnapshot extends ReadonlyJsonValue> = (
oldSnapshot: TSnapshot,
newSnapshot: TSnapshot,
) => boolean;

type SnapshotManagerOptions<TSnapshot extends ReadonlyJsonValue> = 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<TSnapshot>;
}>;

interface SnapshotManagerApi<TSnapshot extends ReadonlyJsonValue> {
subscribe: (callback: SubscriptionCallback<TSnapshot>) => () => void;
unsubscribe: (callback: SubscriptionCallback<TSnapshot>) => 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<TSnapshot extends ReadonlyJsonValue>(
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<TSnapshot>
{
private subscriptions: Set<SubscriptionCallback<TSnapshot>>;
private didSnapshotsChange: DidSnapshotsChange<TSnapshot>;
private activeSnapshot: TSnapshot;

constructor(options: SnapshotManagerOptions<TSnapshot>) {
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<TSnapshot>): void => {
this.subscriptions.delete(callback);
};

subscribe = (callback: SubscriptionCallback<TSnapshot>): (() => 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();
};
}

0 comments on commit 1d956bd

Please sign in to comment.