Skip to content

Commit fc86b8c

Browse files
authored
chore: add StateSnapshotManager class (#119)
1 parent 04a1c15 commit fc86b8c

File tree

3 files changed

+378
-0
lines changed

3 files changed

+378
-0
lines changed

plugins/backstage-plugin-coder/src/typesConstants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ import {
99
optional,
1010
} from 'valibot';
1111

12+
export type ReadonlyJsonValue =
13+
| string
14+
| number
15+
| boolean
16+
| null
17+
| readonly ReadonlyJsonValue[]
18+
| Readonly<{ [key: string]: ReadonlyJsonValue }>;
19+
1220
export const DEFAULT_CODER_DOCS_LINK = 'https://coder.com/docs/v2/latest';
1321

1422
export const workspaceAgentStatusSchema = union([
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import type { ReadonlyJsonValue } from '../typesConstants';
2+
import {
3+
StateSnapshotManager,
4+
defaultDidSnapshotsChange,
5+
} from './StateSnapshotManager';
6+
7+
describe(`${defaultDidSnapshotsChange.name}`, () => {
8+
type SampleInput = Readonly<{
9+
snapshotA: ReadonlyJsonValue;
10+
snapshotB: ReadonlyJsonValue;
11+
}>;
12+
13+
it('Will detect when two JSON primitives are the same', () => {
14+
const samples = [
15+
{ snapshotA: true, snapshotB: true },
16+
{ snapshotA: 'cat', snapshotB: 'cat' },
17+
{ snapshotA: 2, snapshotB: 2 },
18+
{ snapshotA: null, snapshotB: null },
19+
] as const satisfies readonly SampleInput[];
20+
21+
for (const { snapshotA, snapshotB } of samples) {
22+
expect(defaultDidSnapshotsChange(snapshotA, snapshotB)).toBe(false);
23+
}
24+
});
25+
26+
it('Will detect when two JSON primitives are different', () => {
27+
const samples = [
28+
{ snapshotA: true, snapshotB: false },
29+
{ snapshotA: 'cat', snapshotB: 'dog' },
30+
{ snapshotA: 2, snapshotB: 789 },
31+
{ snapshotA: null, snapshotB: 'blah' },
32+
] as const satisfies readonly SampleInput[];
33+
34+
for (const { snapshotA, snapshotB } of samples) {
35+
expect(defaultDidSnapshotsChange(snapshotA, snapshotB)).toBe(true);
36+
}
37+
});
38+
39+
it('Will detect when a value flips from a primitive to an object (or vice versa)', () => {
40+
expect(defaultDidSnapshotsChange(null, {})).toBe(true);
41+
expect(defaultDidSnapshotsChange({}, null)).toBe(true);
42+
});
43+
44+
it('Will reject numbers that changed by a very small floating-point epsilon', () => {
45+
expect(defaultDidSnapshotsChange(3, 3 / 1.00000001)).toBe(false);
46+
});
47+
48+
it('Will check array values one level deep', () => {
49+
const snapshotA = [1, 2, 3];
50+
51+
const snapshotB = [...snapshotA];
52+
expect(defaultDidSnapshotsChange(snapshotA, snapshotB)).toBe(false);
53+
54+
const snapshotC = [...snapshotA, 4];
55+
expect(defaultDidSnapshotsChange(snapshotA, snapshotC)).toBe(true);
56+
57+
const snapshotD = [...snapshotA, {}];
58+
expect(defaultDidSnapshotsChange(snapshotA, snapshotD)).toBe(true);
59+
});
60+
61+
it('Will check object values one level deep', () => {
62+
const snapshotA = { cat: true, dog: true };
63+
64+
const snapshotB = { ...snapshotA, dog: true };
65+
expect(defaultDidSnapshotsChange(snapshotA, snapshotB)).toBe(false);
66+
67+
const snapshotC = { ...snapshotA, bird: true };
68+
expect(defaultDidSnapshotsChange(snapshotA, snapshotC)).toBe(true);
69+
70+
const snapshotD = { ...snapshotA, value: {} };
71+
expect(defaultDidSnapshotsChange(snapshotA, snapshotD)).toBe(true);
72+
});
73+
});
74+
75+
describe(`${StateSnapshotManager.name}`, () => {
76+
it('Lets external systems subscribe and unsubscribe to internal snapshot changes', () => {
77+
type SampleData = Readonly<{
78+
snapshotA: ReadonlyJsonValue;
79+
snapshotB: ReadonlyJsonValue;
80+
}>;
81+
82+
const samples = [
83+
{ snapshotA: false, snapshotB: true },
84+
{ snapshotA: 0, snapshotB: 1 },
85+
{ snapshotA: 'cat', snapshotB: 'dog' },
86+
{ snapshotA: null, snapshotB: 'neat' },
87+
{ snapshotA: {}, snapshotB: { different: true } },
88+
{ snapshotA: [], snapshotB: ['I have a value now!'] },
89+
] as const satisfies readonly SampleData[];
90+
91+
for (const { snapshotA, snapshotB } of samples) {
92+
const subscriptionCallback = jest.fn();
93+
const manager = new StateSnapshotManager({
94+
initialSnapshot: snapshotA,
95+
didSnapshotsChange: defaultDidSnapshotsChange,
96+
});
97+
98+
const unsubscribe = manager.subscribe(subscriptionCallback);
99+
manager.updateSnapshot(snapshotB);
100+
expect(subscriptionCallback).toHaveBeenCalledTimes(1);
101+
expect(subscriptionCallback).toHaveBeenCalledWith(snapshotB);
102+
103+
unsubscribe();
104+
manager.updateSnapshot(snapshotA);
105+
expect(subscriptionCallback).toHaveBeenCalledTimes(1);
106+
}
107+
});
108+
109+
it('Lets user define a custom comparison algorithm during instantiation', () => {
110+
type SampleData = Readonly<{
111+
snapshotA: ReadonlyJsonValue;
112+
snapshotB: ReadonlyJsonValue;
113+
compare: (A: ReadonlyJsonValue, B: ReadonlyJsonValue) => boolean;
114+
}>;
115+
116+
const exampleDeeplyNestedJson: ReadonlyJsonValue = {
117+
value1: {
118+
value2: {
119+
value3: 'neat',
120+
},
121+
},
122+
123+
value4: {
124+
value5: [{ valueX: true }, { valueY: false }],
125+
},
126+
};
127+
128+
const samples = [
129+
{
130+
snapshotA: exampleDeeplyNestedJson,
131+
snapshotB: {
132+
...exampleDeeplyNestedJson,
133+
value4: {
134+
value5: [{ valueX: false }, { valueY: false }],
135+
},
136+
},
137+
compare: (A, B) => JSON.stringify(A) !== JSON.stringify(B),
138+
},
139+
{
140+
snapshotA: { tag: 'snapshot-993', value: 1 },
141+
snapshotB: { tag: 'snapshot-2004', value: 1 },
142+
compare: (A, B) => {
143+
const recastA = A as Record<string, unknown>;
144+
const recastB = B as Record<string, unknown>;
145+
return recastA.tag !== recastB.tag;
146+
},
147+
},
148+
] as const satisfies readonly SampleData[];
149+
150+
for (const { snapshotA, snapshotB, compare } of samples) {
151+
const subscriptionCallback = jest.fn();
152+
const manager = new StateSnapshotManager({
153+
initialSnapshot: snapshotA,
154+
didSnapshotsChange: compare,
155+
});
156+
157+
void manager.subscribe(subscriptionCallback);
158+
manager.updateSnapshot(snapshotB);
159+
expect(subscriptionCallback).toHaveBeenCalledWith(snapshotB);
160+
}
161+
});
162+
163+
it('Rejects new snapshots that are equivalent to old ones, and does NOT notify subscribers', () => {
164+
type SampleData = Readonly<{
165+
snapshotA: ReadonlyJsonValue;
166+
snapshotB: ReadonlyJsonValue;
167+
}>;
168+
169+
const samples = [
170+
{ snapshotA: true, snapshotB: true },
171+
{ snapshotA: 'kitty', snapshotB: 'kitty' },
172+
{ snapshotA: null, snapshotB: null },
173+
{ snapshotA: [], snapshotB: [] },
174+
{ snapshotA: {}, snapshotB: {} },
175+
] as const satisfies readonly SampleData[];
176+
177+
for (const { snapshotA, snapshotB } of samples) {
178+
const subscriptionCallback = jest.fn();
179+
const manager = new StateSnapshotManager({
180+
initialSnapshot: snapshotA,
181+
didSnapshotsChange: defaultDidSnapshotsChange,
182+
});
183+
184+
void manager.subscribe(subscriptionCallback);
185+
manager.updateSnapshot(snapshotB);
186+
expect(subscriptionCallback).not.toHaveBeenCalled();
187+
}
188+
});
189+
190+
it("Uses the default comparison algorithm if one isn't specified at instantiation", () => {
191+
const snapshotA = { value: 'blah' };
192+
const snapshotB = { value: 'blah' };
193+
194+
const manager = new StateSnapshotManager({
195+
initialSnapshot: snapshotA,
196+
});
197+
198+
const subscriptionCallback = jest.fn();
199+
void manager.subscribe(subscriptionCallback);
200+
manager.updateSnapshot(snapshotB);
201+
202+
expect(subscriptionCallback).not.toHaveBeenCalled();
203+
});
204+
});
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* @file A helper class that simplifies the process of connecting mutable class
3+
* values (such as the majority of values from API factories) with React's
4+
* useSyncExternalStore hook.
5+
*
6+
* This should not be used directly from within React, but should instead be
7+
* composed into other classes (such as API factories). Those classes can then
8+
* be brought into React.
9+
*
10+
* As long as you can figure out how to turn the mutable values in some other
11+
* class into an immutable snapshot, all you have to do is pass the new snapshot
12+
* into this class. It will then take care of notifying subscriptions, while
13+
* reconciling old/new snapshots to minimize needless re-renders.
14+
*/
15+
import type { ReadonlyJsonValue } from '../typesConstants';
16+
17+
type SubscriptionCallback<TSnapshot extends ReadonlyJsonValue> = (
18+
snapshot: TSnapshot,
19+
) => void;
20+
21+
type DidSnapshotsChange<TSnapshot extends ReadonlyJsonValue> = (
22+
oldSnapshot: TSnapshot,
23+
newSnapshot: TSnapshot,
24+
) => boolean;
25+
26+
type SnapshotManagerOptions<TSnapshot extends ReadonlyJsonValue> = Readonly<{
27+
initialSnapshot: TSnapshot;
28+
29+
/**
30+
* Lets you define a custom comparison strategy for detecting whether a
31+
* snapshot has really changed in a way that should be reflected in the UI.
32+
*/
33+
didSnapshotsChange?: DidSnapshotsChange<TSnapshot>;
34+
}>;
35+
36+
interface SnapshotManagerApi<TSnapshot extends ReadonlyJsonValue> {
37+
subscribe: (callback: SubscriptionCallback<TSnapshot>) => () => void;
38+
unsubscribe: (callback: SubscriptionCallback<TSnapshot>) => void;
39+
getSnapshot: () => TSnapshot;
40+
updateSnapshot: (newSnapshot: TSnapshot) => void;
41+
}
42+
43+
function areSameByReference(v1: unknown, v2: unknown) {
44+
// Comparison looks wonky, but Object.is handles more edge cases than ===
45+
// for these kinds of comparisons, but it itself has an edge case
46+
// with -0 and +0. Still need === to handle that comparison
47+
return Object.is(v1, v2) || (v1 === 0 && v2 === 0);
48+
}
49+
50+
/**
51+
* Favors shallow-ish comparisons (will check one level deep for objects and
52+
* arrays, but no more)
53+
*/
54+
export function defaultDidSnapshotsChange<TSnapshot extends ReadonlyJsonValue>(
55+
oldSnapshot: TSnapshot,
56+
newSnapshot: TSnapshot,
57+
): boolean {
58+
if (areSameByReference(oldSnapshot, newSnapshot)) {
59+
return false;
60+
}
61+
62+
const oldIsPrimitive =
63+
typeof oldSnapshot !== 'object' || oldSnapshot === null;
64+
const newIsPrimitive =
65+
typeof newSnapshot !== 'object' || newSnapshot === null;
66+
67+
if (oldIsPrimitive && newIsPrimitive) {
68+
const numbersAreWithinTolerance =
69+
typeof oldSnapshot === 'number' &&
70+
typeof newSnapshot === 'number' &&
71+
Math.abs(oldSnapshot - newSnapshot) < 0.00005;
72+
73+
if (numbersAreWithinTolerance) {
74+
return false;
75+
}
76+
77+
return oldSnapshot !== newSnapshot;
78+
}
79+
80+
const changedFromObjectToPrimitive = !oldIsPrimitive && newIsPrimitive;
81+
const changedFromPrimitiveToObject = oldIsPrimitive && !newIsPrimitive;
82+
83+
if (changedFromObjectToPrimitive || changedFromPrimitiveToObject) {
84+
return true;
85+
}
86+
87+
if (Array.isArray(oldSnapshot) && Array.isArray(newSnapshot)) {
88+
const sameByShallowComparison =
89+
oldSnapshot.length === newSnapshot.length &&
90+
oldSnapshot.every((element, index) =>
91+
areSameByReference(element, newSnapshot[index]),
92+
);
93+
94+
return !sameByShallowComparison;
95+
}
96+
97+
const oldInnerValues: unknown[] = Object.values(oldSnapshot as Object);
98+
const newInnerValues: unknown[] = Object.values(newSnapshot as Object);
99+
100+
if (oldInnerValues.length !== newInnerValues.length) {
101+
return true;
102+
}
103+
104+
for (const [index, value] of oldInnerValues.entries()) {
105+
if (value !== newInnerValues[index]) {
106+
return true;
107+
}
108+
}
109+
110+
return false;
111+
}
112+
113+
/**
114+
* @todo Might eventually make sense to give the class the ability to merge
115+
* snapshots more surgically and maximize structural sharing (which should be
116+
* safe since the snapshots are immutable). But we can worry about that when it
117+
* actually becomes a performance issue
118+
*/
119+
export class StateSnapshotManager<
120+
TSnapshot extends ReadonlyJsonValue = ReadonlyJsonValue,
121+
> implements SnapshotManagerApi<TSnapshot>
122+
{
123+
private subscriptions: Set<SubscriptionCallback<TSnapshot>>;
124+
private didSnapshotsChange: DidSnapshotsChange<TSnapshot>;
125+
private activeSnapshot: TSnapshot;
126+
127+
constructor(options: SnapshotManagerOptions<TSnapshot>) {
128+
const { initialSnapshot, didSnapshotsChange } = options;
129+
130+
this.subscriptions = new Set();
131+
this.activeSnapshot = initialSnapshot;
132+
this.didSnapshotsChange = didSnapshotsChange ?? defaultDidSnapshotsChange;
133+
}
134+
135+
private notifySubscriptions(): void {
136+
const snapshotBinding = this.activeSnapshot;
137+
this.subscriptions.forEach(cb => cb(snapshotBinding));
138+
}
139+
140+
unsubscribe = (callback: SubscriptionCallback<TSnapshot>): void => {
141+
this.subscriptions.delete(callback);
142+
};
143+
144+
subscribe = (callback: SubscriptionCallback<TSnapshot>): (() => void) => {
145+
this.subscriptions.add(callback);
146+
return () => this.unsubscribe(callback);
147+
};
148+
149+
getSnapshot = (): TSnapshot => {
150+
return this.activeSnapshot;
151+
};
152+
153+
updateSnapshot = (newSnapshot: TSnapshot): void => {
154+
const snapshotsChanged = this.didSnapshotsChange(
155+
this.activeSnapshot,
156+
newSnapshot,
157+
);
158+
159+
if (!snapshotsChanged) {
160+
return;
161+
}
162+
163+
this.activeSnapshot = newSnapshot;
164+
this.notifySubscriptions();
165+
};
166+
}

0 commit comments

Comments
 (0)