UNPKG

16 kBPlain TextView Raw
1import { createStore, Store, StoreEnhancer } from 'redux';
2import { CombineReducersContext, Component, ComponentCreationContext, ComponentReducer } from './components';
3import { ComponentId, Computed, Connect, IgnoreState } from './decorators';
4import { ComponentInfo } from './info';
5import { AppOptions, globalOptions, GlobalOptions } from './options';
6import { IMap, Listener } from './types';
7import { isPrimitive, log, toPlainObject } from './utils';
8const getProp = require('lodash.get');
9
10// tslint:disable:ban-types
11
12//
13// internal
14//
15
16export const ROOT_COMPONENT_PATH = 'root';
17
18export const DEFAULT_APP_NAME = 'default';
19
20export const appsRepository: IMap<ReduxApp<any>> = {};
21
22export type AppWarehouse = Map<Function, Map<any, any>>;
23
24var appsCount = 0;
25
26class UpdateContext {
27
28 public visited = new Set();
29 public path = ROOT_COMPONENT_PATH;
30 public forceRecursion = false;
31
32 constructor(initial?: Partial<UpdateContext>) {
33 Object.assign(this, initial);
34 }
35}
36
37//
38// public
39//
40
41export class ReduxApp<T extends object> {
42
43 //
44 // static
45 //
46
47 /**
48 * Global redux-app options
49 */
50 public static options: GlobalOptions = globalOptions;
51
52 public static createApp<T extends object>(appCreator: T, enhancer?: StoreEnhancer<T>): ReduxApp<T>;
53 public static createApp<T extends object>(appCreator: T, options: AppOptions, enhancer?: StoreEnhancer<T>): ReduxApp<T>;
54 public static createApp<T extends object>(appCreator: T, options: AppOptions, preloadedState: any, enhancer?: StoreEnhancer<T>): ReduxApp<T>;
55 public static createApp<T extends object>(appCreator: T, ...params: any[]): ReduxApp<T> {
56 return new ReduxApp(appCreator, ...params);
57 }
58
59 /**
60 * Get an existing ReduxApp instance.
61 *
62 * @param appId The name of the ReduxApp instance to retrieve. If not
63 * specified will return the default app.
64 */
65 public static getApp<T extends object = any>(appId?: string): ReduxApp<T> {
66 const applicationId = appId || DEFAULT_APP_NAME;
67 const app = appsRepository[applicationId];
68 if (!app)
69 log.debug(`[ReduxApp] Application '${applicationId}' does not exist.`);
70 return app;
71 }
72
73 /**
74 * @param type The type of the component.
75 * @param componentId The ID of the component (assuming the ID was assigned
76 * to the component by the 'withId' decorator). If not specified will get to
77 * the first available component of that type.
78 * @param appId The name of the ReduxApp instance to search in. If not
79 * specified will search in default app.
80 */
81 public static getComponent<T extends Function>(type: T, componentId?: string, appId?: string): T {
82 const app = ReduxApp.getApp(appId);
83 if (!app)
84 return undefined;
85
86 // get the component to connect
87 const warehouse = app.getTypeWarehouse(type);
88 if (componentId) {
89
90 // get by id
91 return warehouse.get(componentId);
92 } else {
93
94 // get the first value
95 return warehouse.values().next().value;
96 }
97 }
98
99 /**
100 * INTERNAL: Should not appear on the public d.ts file.
101 */
102 public static registerComponent(comp: Component, creator: object, appName?: string): void {
103 appName = appName || DEFAULT_APP_NAME;
104 const app = appsRepository[appName];
105 if (app) { // this check exists for test reason only - in some unit tests we create orphan components that are not part of any app...
106 const warehouse = app.getTypeWarehouse(creator.constructor);
107 const key = ComponentInfo.getInfo(comp).id || ComponentId.nextAvailableId();
108 warehouse.set(key, comp);
109 }
110 }
111
112 //
113 // instance members
114 //
115
116 public readonly name: string;
117 /**
118 * The root component of the application.
119 */
120 public readonly root: T;
121 /**
122 * The underlying redux store.
123 */
124 public readonly store: Store<T>;
125
126 private readonly warehouse: AppWarehouse = new Map<Function, Map<any, any>>();
127
128 private initialStateUpdated = false;
129
130 private subscriptionDisposer: () => void;
131
132 //
133 // constructor
134 //
135
136 constructor(appCreator: T, enhancer?: StoreEnhancer<T>);
137 constructor(appCreator: T, options: AppOptions, enhancer?: StoreEnhancer<T>);
138 constructor(appCreator: T, options: AppOptions, preloadedState: any, enhancer?: StoreEnhancer<T>);
139 constructor(appCreator: T, ...params: any[]) {
140
141 // handle different overloads
142 var { options, preLoadedState, enhancer } = this.resolveParameters(appCreator, params);
143
144 // assign name and register self
145 this.name = this.getAppName(options.name);
146 if (appsRepository[this.name])
147 throw new Error(`An app with name '${this.name}' already exists.`);
148 appsRepository[this.name] = this;
149
150 // create the store
151 const initialReducer = (state: any) => state;
152 this.store = createStore<T>(initialReducer as any, preLoadedState, enhancer);
153
154 // create the app
155 const creationContext = new ComponentCreationContext({ appName: this.name });
156 const rootComponent = Component.create(this.store, appCreator, creationContext);
157 this.root = (rootComponent as any);
158
159 // create the root reducer
160 const reducersContext = new CombineReducersContext({
161 componentPaths: Object.keys(creationContext.createdComponents)
162 });
163 const rootReducer = ComponentReducer.combineReducersTree(this.root, reducersContext);
164
165 // update the store
166 if (options.updateState) {
167 const stateListener = this.updateState(creationContext.createdComponents, reducersContext);
168 this.subscriptionDisposer = this.store.subscribe(stateListener);
169 }
170 this.store.replaceReducer(rootReducer);
171 }
172
173 //
174 // public methods
175 //
176
177 public dispose(): void {
178 if (this.subscriptionDisposer) {
179 this.subscriptionDisposer();
180 this.subscriptionDisposer = null;
181 }
182 if (appsRepository[this.name]) {
183 delete appsRepository[this.name];
184 }
185 }
186
187 /**
188 * INTERNAL: Should not appear on the public d.ts file.
189 */
190 public getTypeWarehouse(type: Function): Map<any, any> {
191 if (!this.warehouse.has(type))
192 this.warehouse.set(type, new Map());
193 return this.warehouse.get(type);
194 }
195
196 //
197 // private utils
198 //
199
200 private resolveParameters(appCreator: any, params: any[]) {
201 var result: {
202 options?: AppOptions,
203 preLoadedState?: T,
204 enhancer?: StoreEnhancer<T>
205 } = {};
206
207 if (params.length === 0) {
208
209 // no parameters
210 result.options = new AppOptions();
211 result.preLoadedState = appCreator;
212
213 } else if (params.length === 1) {
214
215 if (typeof params[0] === 'function') {
216
217 // only enhancer
218 result.options = new AppOptions();
219 result.enhancer = params[0];
220 result.preLoadedState = appCreator;
221
222 } else {
223
224 // only options
225 result.options = Object.assign(new AppOptions(), params[0]);
226 result.preLoadedState = appCreator;
227
228 }
229 } else if (params.length === 2) {
230
231 // options and pre-loaded state
232 result.options = Object.assign(new AppOptions(), params[0]);
233 result.preLoadedState = params[1];
234
235 } else {
236
237 // options, pre-loaded state and enhancer
238 result.options = Object.assign(new AppOptions(), params[0]);
239 result.preLoadedState = params[1];
240 result.enhancer = params[2];
241 }
242
243 return result;
244 }
245
246 private getAppName(name: string): string {
247 if (name)
248 return name;
249
250 if (!Object.keys(appsRepository).length) {
251 return DEFAULT_APP_NAME;
252 } else {
253 return DEFAULT_APP_NAME + '_' + (++appsCount);
254 }
255 }
256
257 //
258 // update state
259 //
260
261 private updateState(allComponents: IMap<Component>, reducersContext: CombineReducersContext): Listener {
262
263 const withComputedProps = Computed.filterComponents(Object.values(allComponents));
264
265 return () => {
266
267 //
268 // Reducers are invoked with regular objects, therefor we use this
269 // method which copies the resulted values back to the components.
270 //
271
272 const start = Date.now();
273
274 // update the application tree
275 const newState = this.store.getState();
276 if (!this.initialStateUpdated || !reducersContext.invoked) {
277
278 // initial state, state rehydration, time-travel debugging, etc. - update the entire tree
279 this.initialStateUpdated = true;
280 this.updateStateRecursion(this.root, newState, new UpdateContext({ forceRecursion: true }));
281 } else {
282
283 // standard update - update only changed components
284 this.updateChangedComponents({ [ROOT_COMPONENT_PATH]: newState }, reducersContext.changedComponents);
285 }
286
287 // because computed props may be dependant on connected props their
288 // calculation is postponed to after the entire app tree is up-to-date
289 Computed.computeProps(withComputedProps);
290
291 // reset reducers context
292 reducersContext.reset();
293
294 const end = Date.now();
295
296 log.debug(`[updateState] Component tree updated in ${end - start}ms.`);
297 };
298 }
299
300 private updateChangedComponents(newState: any, changedComponents: IMap<Component>): void {
301
302 const changedPaths = Object.keys(changedComponents);
303 const updateContext = new UpdateContext();
304
305 for (let path of changedPaths) {
306
307 const curComponent = changedComponents[path];
308 var newSubState = getProp(newState, path);
309
310 this.updateStateRecursion(curComponent, newSubState, {
311 ...updateContext,
312 path
313 });
314 }
315 }
316
317 private updateStateRecursion(obj: any, newState: any, context: UpdateContext): any {
318
319 // same object
320 if (obj === newState)
321 return newState;
322
323 // primitive properties are updated by their owner objects
324 if (isPrimitive(obj) || isPrimitive(newState))
325 return newState;
326
327 // prevent endless loops on circular references
328 if (context.visited.has(obj))
329 return obj;
330 context.visited.add(obj);
331
332 // update
333 const newStateType = newState.constructor;
334
335 // convert to plain object (see comment on the option itself)
336 if (globalOptions.convertToPlainObject)
337 newState = toPlainObject(newState);
338
339 if (context.forceRecursion || (obj instanceof Component && newStateType === Object)) {
340
341 // update if new state is a plain object (so to keep methods while updating props)
342 var changeMessage: string;
343 if (Array.isArray(obj) && Array.isArray(newState)) {
344 changeMessage = this.updateArray(obj, newState, context);
345 } else {
346 changeMessage = this.updateObject(obj, newState, context);
347 }
348 } else {
349
350 // overwrite
351 obj = newState;
352 changeMessage = 'Object overwritten.';
353 }
354
355 // log changes
356 if (changeMessage && changeMessage.length) {
357 log.debug(`[updateState] Change in '${context.path}'. ${changeMessage}`);
358 log.verbose(`[updateState] New state: `, obj);
359 }
360
361 return obj;
362 }
363
364 private updateObject(obj: any, newState: any, context: UpdateContext): string {
365
366 // delete anything not in the new state
367 var propsDeleted: string[] = [];
368 Object.keys(obj).forEach(key => {
369
370 if (IgnoreState.isIgnoredProperty(obj, key))
371 return;
372
373 if (!newState.hasOwnProperty(key)) {
374 if (typeof obj[key] === 'function')
375 log.warn(`[updateState] Function property removed in path: ${context.path}.${key}. Consider using a method instead.`);
376 delete obj[key];
377 propsDeleted.push(key);
378 }
379 });
380
381 // assign new state recursively
382 var propsAssigned: string[] = [];
383 Object.keys(newState).forEach(key => {
384
385 // state of connected components is update on their source
386 if (Connect.isConnectedProperty(obj, key))
387 return;
388
389 // see comment about computed properties in updateState
390 if (Computed.isComputedProperty(obj, key))
391 return;
392
393 if (IgnoreState.isIgnoredProperty(obj, key))
394 return;
395
396 var subState = newState[key];
397 var subObj = obj[key];
398
399 // must update recursively, otherwise we may lose children types (and methods...)
400 const newSubObj = this.updateStateRecursion(subObj, subState, {
401 ...context,
402 path: context.path + '.' + key
403 });
404
405 // assign only if changed, in case anyone is monitoring assignments
406 if (newSubObj !== subObj) {
407 obj[key] = newSubObj;
408 propsAssigned.push(key);
409 }
410 });
411
412 // report changes
413 if (propsAssigned.length || propsDeleted.length) {
414 const propsAssignedMessage = propsAssigned.length ? `Props assigned: ${propsAssigned.join(', ')}.` : '';
415 const propsDeleteMessage = propsDeleted.length ? `Props deleted: ${propsDeleted.join(', ')}.` : '';
416 const space = (propsAssigned.length && propsDeleted.length) ? ' ' : '';
417 return propsAssignedMessage + space + propsDeleteMessage;
418
419 } else {
420 return null;
421 }
422 }
423
424 private updateArray(arr: any[], newState: any[], context: UpdateContext): string {
425
426 var changeMessage: string[] = [];
427
428 const prevLength = arr.length;
429 const newLength = newState.length;
430
431 // assign existing
432 var itemsAssigned: number[] = [];
433 for (let i = 0; i < Math.min(prevLength, newLength); i++) {
434 var subState = newState[i];
435 var subObj = arr[i];
436 const newSubObj = this.updateStateRecursion(subObj, subState, {
437 ...context,
438 path: context.path + '.' + i
439 });
440 if (newSubObj !== subObj) {
441 arr[i] = newSubObj;
442 itemsAssigned.push(i);
443 }
444 }
445 if (itemsAssigned.length)
446 changeMessage.push(`Assigned item(s) at indexes ${itemsAssigned.join(', ')}.`);
447
448 // add / remove
449 if (newLength > prevLength) {
450
451 // add new items
452 const newItems = newState.slice(prevLength);
453 Array.prototype.push.apply(arr, newItems);
454 changeMessage.push(`Added ${newLength - prevLength} item(s) at index ${prevLength}.`);
455
456 } else if (prevLength > newLength) {
457
458 // remove old items
459 arr.splice(newLength);
460 changeMessage.push(`Removed ${prevLength - newLength} item(s) at index ${newLength}.`);
461 }
462
463 // report changes
464 return changeMessage.join(' ');
465 }
466}
\No newline at end of file