-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore: add StateSnapshotManager class
- Loading branch information
1 parent
04a1c15
commit 1d956bd
Showing
3 changed files
with
378 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
204 changes: 204 additions & 0 deletions
204
plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
166
plugins/backstage-plugin-coder/src/utils/StateSnapshotManager.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}; | ||
} |