-
-
Notifications
You must be signed in to change notification settings - Fork 166
/
BaseControllerV2.ts
249 lines (226 loc) · 7.7 KB
/
BaseControllerV2.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
import { enablePatches, produceWithPatches } from 'immer';
// Imported separately because only the type is used
// eslint-disable-next-line no-duplicate-imports
import type { Draft, Patch } from 'immer';
import type {
RestrictedControllerMessenger,
Namespaced,
} from './ControllerMessenger';
enablePatches();
/**
* A state change listener.
*
* This function will get called for each state change, and is given a copy of
* the new state along with a set of patches describing the changes since the
* last update.
*
* @param state - The new controller state.
* @param patches - A list of patches describing any changes (see here for more
* information: https://immerjs.github.io/immer/docs/patches)
*/
export type Listener<T> = (state: T, patches: Patch[]) => void;
/**
* An function to derive state.
*
* This function will accept one piece of the controller state (one property),
* and will return some derivation of that state.
*
* @param value - A piece of controller state.
* @returns Something derived from controller state.
*/
export type StateDeriver<T extends Json> = (value: T) => Json;
/**
* State metadata.
*
* This metadata describes which parts of state should be persisted, and how to
* get an anonymized representation of the state.
*/
export type StateMetadata<T extends Record<string, Json>> = {
[P in keyof T]: StatePropertyMetadata<T[P]>;
};
/**
* Metadata for a single state property
*
* @property persist - Indicates whether this property should be persisted
* (`true` for persistent, `false` for transient), or is set to a function
* that derives the persistent state from the state.
* @property anonymous - Indicates whether this property is already anonymous,
* (`true` for anonymous, `false` if it has potential to be personally
* identifiable), or is set to a function that returns an anonymized
* representation of this state.
*/
export interface StatePropertyMetadata<T extends Json> {
persist: boolean | StateDeriver<T>;
anonymous: boolean | StateDeriver<T>;
}
export type Json =
| null
| boolean
| number
| string
| Json[]
| { [prop: string]: Json };
/**
* Controller class that provides state management, subscriptions, and state metadata
*/
export class BaseController<
N extends string,
S extends Record<string, Json>,
messenger extends RestrictedControllerMessenger<N, any, any, string, string>,
> {
private internalState: S;
protected messagingSystem: messenger;
/**
* The name of the controller.
*
* This is used by the ComposableController to construct a composed application state.
*/
public readonly name: N;
public readonly metadata: StateMetadata<S>;
/**
* The existence of the `subscribe` property is how the ComposableController detects whether a
* controller extends the old BaseController or the new one. We set it to `never` here to ensure
* this property is never used for new BaseController-based controllers, to ensure the
* ComposableController never mistakes them for an older style controller.
*/
public readonly subscribe: never;
/**
* Creates a BaseController instance.
*
* @param options - Controller options.
* @param options.messenger - Controller messaging system.
* @param options.metadata - State metadata, describing how to "anonymize" the state, and which
* parts should be persisted.
* @param options.name - The name of the controller, used as a namespace for events and actions.
* @param options.state - Initial controller state.
*/
constructor({
messenger,
metadata,
name,
state,
}: {
messenger: messenger;
metadata: StateMetadata<S>;
name: N;
state: S;
}) {
this.messagingSystem = messenger;
this.name = name;
this.internalState = state;
this.metadata = metadata;
this.messagingSystem.registerActionHandler(
`${name}:getState`,
() => this.state,
);
}
/**
* Retrieves current controller state.
*
* @returns The current state.
*/
get state() {
return this.internalState;
}
set state(_) {
throw new Error(
`Controller state cannot be directly mutated; use 'update' method instead.`,
);
}
/**
* Updates controller state. Accepts a callback that is passed a draft copy
* of the controller state. If a value is returned, it is set as the new
* state. Otherwise, any changes made within that callback to the draft are
* applied to the controller state.
*
* @param callback - Callback for updating state, passed a draft state
* object. Return a new state object or mutate the draft to update state.
*/
protected update(callback: (state: Draft<S>) => void | S) {
// We run into ts2589, "infinite type depth", if we don't cast
// produceWithPatches here.
// The final, omitted member of the returned tuple are the inverse patches.
const [nextState, patches] = (
produceWithPatches as unknown as (
state: S,
cb: typeof callback,
) => [S, Patch[], Patch[]]
)(this.internalState, callback);
this.internalState = nextState;
this.messagingSystem.publish(
`${this.name}:stateChange` as Namespaced<N, any>,
nextState,
patches,
);
}
/**
* Prepares the controller for garbage collection. This should be extended
* by any subclasses to clean up any additional connections or events.
*
* The only cleanup performed here is to remove listeners. While technically
* this is not required to ensure this instance is garbage collected, it at
* least ensures this instance won't be responsible for preventing the
* listeners from being garbage collected.
*/
protected destroy() {
this.messagingSystem.clearEventSubscriptions(
`${this.name}:stateChange` as Namespaced<N, any>,
);
}
}
/**
* Returns an anonymized representation of the controller state.
*
* By "anonymized" we mean that it should not contain any information that could be personally
* identifiable.
*
* @param state - The controller state.
* @param metadata - The controller state metadata, which describes how to derive the
* anonymized state.
* @returns The anonymized controller state.
*/
export function getAnonymizedState<S extends Record<string, Json>>(
state: S,
metadata: StateMetadata<S>,
): Record<string, Json> {
return deriveStateFromMetadata(state, metadata, 'anonymous');
}
/**
* Returns the subset of state that should be persisted.
*
* @param state - The controller state.
* @param metadata - The controller state metadata, which describes which pieces of state should be persisted.
* @returns The subset of controller state that should be persisted.
*/
export function getPersistentState<S extends Record<string, Json>>(
state: S,
metadata: StateMetadata<S>,
): Record<string, Json> {
return deriveStateFromMetadata(state, metadata, 'persist');
}
/**
* Use the metadata to derive state according to the given metadata property.
*
* @param state - The full controller state.
* @param metadata - The controller metadata.
* @param metadataProperty - The metadata property to use to derive state.
* @returns The metadata-derived controller state.
*/
function deriveStateFromMetadata<S extends Record<string, Json>>(
state: S,
metadata: StateMetadata<S>,
metadataProperty: 'anonymous' | 'persist',
): Record<string, Json> {
return Object.keys(state).reduce((persistedState, key) => {
const propertyMetadata = metadata[key as keyof S][metadataProperty];
const stateProperty = state[key];
if (typeof propertyMetadata === 'function') {
persistedState[key as string] = propertyMetadata(
stateProperty as S[keyof S],
);
} else if (propertyMetadata) {
persistedState[key as string] = stateProperty;
}
return persistedState;
}, {} as Record<string, Json>);
}