-
Notifications
You must be signed in to change notification settings - Fork 5
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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(); | ||
}); | ||
}); |
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)); | ||
Comment on lines
+136
to
+137
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
}; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
JavaScript is wild lol
There was a problem hiding this comment.
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