Skip to content

chore: add StateSnapshotManager class #119

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Comment on lines +45 to +46
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JavaScript is wild lol

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I usually call Object.is the "quadruple equals" operator, but the flipside of that is that sometimes it gets too specific for its own good lol

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));
Comment on lines +136 to +137
Copy link
Member Author

@Parkreiner Parkreiner Apr 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to make sure that we store the starting snapshot in a separate variable, because the value of this.activeSnapshot is allowed to be replaced at any time

Otherwise, it's possible for the subscriptions to start getting processed, the snapshot to change halfway through, and then the subscriptions start notifying you about the updated snapshot, not the original one

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense.

}

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();
};
}