1 | import { createStore, Store, StoreEnhancer } from 'redux';
|
2 | import { CombineReducersContext, Component, ComponentCreationContext, ComponentReducer } from './components';
|
3 | import { ComponentId, Computed, Connect, IgnoreState } from './decorators';
|
4 | import { ComponentInfo } from './info';
|
5 | import { AppOptions, globalOptions, GlobalOptions } from './options';
|
6 | import { IMap, Listener } from './types';
|
7 | import { isPrimitive, log, toPlainObject } from './utils';
|
8 | const getProp = require('lodash.get');
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 | export const ROOT_COMPONENT_PATH = 'root';
|
17 |
|
18 | export const DEFAULT_APP_NAME = 'default';
|
19 |
|
20 | export const appsRepository: IMap<ReduxApp<any>> = {};
|
21 |
|
22 | export type AppWarehouse = Map<Function, Map<any, any>>;
|
23 |
|
24 | var appsCount = 0;
|
25 |
|
26 | class 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 |
|
39 |
|
40 |
|
41 | export class ReduxApp<T extends object> {
|
42 |
|
43 |
|
44 |
|
45 |
|
46 |
|
47 | |
48 |
|
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 |
|
61 |
|
62 |
|
63 |
|
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 |
|
75 |
|
76 |
|
77 |
|
78 |
|
79 |
|
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 |
|
87 | const warehouse = app.getTypeWarehouse(type);
|
88 | if (componentId) {
|
89 |
|
90 |
|
91 | return warehouse.get(componentId);
|
92 | } else {
|
93 |
|
94 |
|
95 | return warehouse.values().next().value;
|
96 | }
|
97 | }
|
98 |
|
99 | |
100 |
|
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) {
|
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 |
|
114 |
|
115 |
|
116 | public readonly name: string;
|
117 | |
118 |
|
119 |
|
120 | public readonly root: T;
|
121 | |
122 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
218 | result.options = new AppOptions();
|
219 | result.enhancer = params[0];
|
220 | result.preLoadedState = appCreator;
|
221 |
|
222 | } else {
|
223 |
|
224 |
|
225 | result.options = Object.assign(new AppOptions(), params[0]);
|
226 | result.preLoadedState = appCreator;
|
227 |
|
228 | }
|
229 | } else if (params.length === 2) {
|
230 |
|
231 |
|
232 | result.options = Object.assign(new AppOptions(), params[0]);
|
233 | result.preLoadedState = params[1];
|
234 |
|
235 | } else {
|
236 |
|
237 |
|
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 |
|
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 |
|
269 |
|
270 |
|
271 |
|
272 | const start = Date.now();
|
273 |
|
274 |
|
275 | const newState = this.store.getState();
|
276 | if (!this.initialStateUpdated || !reducersContext.invoked) {
|
277 |
|
278 |
|
279 | this.initialStateUpdated = true;
|
280 | this.updateStateRecursion(this.root, newState, new UpdateContext({ forceRecursion: true }));
|
281 | } else {
|
282 |
|
283 |
|
284 | this.updateChangedComponents({ [ROOT_COMPONENT_PATH]: newState }, reducersContext.changedComponents);
|
285 | }
|
286 |
|
287 |
|
288 |
|
289 | Computed.computeProps(withComputedProps);
|
290 |
|
291 |
|
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 |
|
320 | if (obj === newState)
|
321 | return newState;
|
322 |
|
323 |
|
324 | if (isPrimitive(obj) || isPrimitive(newState))
|
325 | return newState;
|
326 |
|
327 |
|
328 | if (context.visited.has(obj))
|
329 | return obj;
|
330 | context.visited.add(obj);
|
331 |
|
332 |
|
333 | const newStateType = newState.constructor;
|
334 |
|
335 |
|
336 | if (globalOptions.convertToPlainObject)
|
337 | newState = toPlainObject(newState);
|
338 |
|
339 | if (context.forceRecursion || (obj instanceof Component && newStateType === Object)) {
|
340 |
|
341 |
|
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 |
|
351 | obj = newState;
|
352 | changeMessage = 'Object overwritten.';
|
353 | }
|
354 |
|
355 |
|
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 |
|
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 |
|
382 | var propsAssigned: string[] = [];
|
383 | Object.keys(newState).forEach(key => {
|
384 |
|
385 |
|
386 | if (Connect.isConnectedProperty(obj, key))
|
387 | return;
|
388 |
|
389 |
|
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 |
|
400 | const newSubObj = this.updateStateRecursion(subObj, subState, {
|
401 | ...context,
|
402 | path: context.path + '.' + key
|
403 | });
|
404 |
|
405 |
|
406 | if (newSubObj !== subObj) {
|
407 | obj[key] = newSubObj;
|
408 | propsAssigned.push(key);
|
409 | }
|
410 | });
|
411 |
|
412 |
|
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 |
|
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 |
|
449 | if (newLength > prevLength) {
|
450 |
|
451 |
|
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 |
|
459 | arr.splice(newLength);
|
460 | changeMessage.push(`Removed ${prevLength - newLength} item(s) at index ${newLength}.`);
|
461 | }
|
462 |
|
463 |
|
464 | return changeMessage.join(' ');
|
465 | }
|
466 | } |
\ | No newline at end of file |