diff --git a/examples/react/develop/functional-component-ts/package.json b/examples/react/develop/functional-component-ts/package.json index be742bb8..08b5324c 100644 --- a/examples/react/develop/functional-component-ts/package.json +++ b/examples/react/develop/functional-component-ts/package.json @@ -30,7 +30,7 @@ "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", - "install:agile": "yalc add @agile-ts/core @agile-ts/react @agile-ts/api @agile-ts/multieditor @agile-ts/event & yarn install" + "install:agile": "yalc add @agile-ts/core @agile-ts/react @agile-ts/api @agile-ts/multieditor @agile-ts/event @agile-ts/proxytree & yarn install" }, "eslintConfig": { "extends": "react-app" diff --git a/examples/react/develop/functional-component-ts/src/App.tsx b/examples/react/develop/functional-component-ts/src/App.tsx index ad7478e5..0574cf89 100644 --- a/examples/react/develop/functional-component-ts/src/App.tsx +++ b/examples/react/develop/functional-component-ts/src/App.tsx @@ -4,6 +4,7 @@ import { useAgile, useWatcher, useProxy } from '@agile-ts/react'; import { useEvent } from '@agile-ts/event'; import { COUNTUP, + externalCreatedItem, MY_COLLECTION, MY_COMPUTED, MY_EVENT, @@ -12,7 +13,7 @@ import { MY_STATE_3, STATE_OBJECT, } from './core'; -import { generateId, globalBind } from '@agile-ts/core'; +import { generateId, globalBind, Item } from '@agile-ts/core'; let rerenderCount = 0; let rerenderCountInCountupView = 0; @@ -42,11 +43,10 @@ const App = (props: any) => { ]); const [myGroup] = useAgile([MY_COLLECTION.getGroupWithReference('myGroup')]); - const [stateObject, item2, collection2] = useProxy([ - STATE_OBJECT, - MY_COLLECTION.getItem('id2'), - MY_COLLECTION, - ]); + const [stateObject, item2, collection2] = useProxy( + [STATE_OBJECT, MY_COLLECTION.getItem('id2'), MY_COLLECTION], + { key: 'useProxy' } + ); console.log('Item1: ', item2?.name); console.log('Collection: ', collection2.slice(0, 2)); @@ -142,6 +142,12 @@ const App = (props: any) => { }> Collect + diff --git a/examples/react/develop/functional-component-ts/src/core/index.ts b/examples/react/develop/functional-component-ts/src/core/index.ts index c55426d9..cf448f46 100644 --- a/examples/react/develop/functional-component-ts/src/core/index.ts +++ b/examples/react/develop/functional-component-ts/src/core/index.ts @@ -1,4 +1,4 @@ -import { Agile, clone, Collection, Logger } from '@agile-ts/core'; +import { Agile, clone, Item, Logger } from '@agile-ts/core'; import Event from '@agile-ts/event'; export const myStorage: any = {}; @@ -86,7 +86,7 @@ export const MY_COLLECTION = App.createCollection( ).persist(); MY_COLLECTION.collect({ key: 'id1', name: 'test' }); MY_COLLECTION.collect({ key: 'id2', name: 'test2' }, 'myGroup'); -MY_COLLECTION.update('id1', { id: 'id1Updated', name: 'testUpdated' }); +MY_COLLECTION.update('id1', { key: 'id1Updated', name: 'testUpdated' }); MY_COLLECTION.getGroup('myGroup')?.persist({ followCollectionPersistKeyPattern: true, }); @@ -94,6 +94,11 @@ MY_COLLECTION.onLoad(() => { console.log('On Load MY_COLLECTION'); }); +export const externalCreatedItem = new Item(MY_COLLECTION, { + key: 'id10', + name: 'test', +}).persist({ followCollectionPersistKeyPattern: true }); + console.log('Initial: myCollection ', clone(MY_COLLECTION)); export const MY_EVENT = new Event<{ name: string }>(App, { diff --git a/examples/react/develop/functional-component-ts/yarn.lock b/examples/react/develop/functional-component-ts/yarn.lock index 54955758..21b151e8 100644 --- a/examples/react/develop/functional-component-ts/yarn.lock +++ b/examples/react/develop/functional-component-ts/yarn.lock @@ -3,46 +3,46 @@ "@agile-ts/api@file:.yalc/@agile-ts/api": - version "0.0.16" + version "0.0.18" dependencies: - "@agile-ts/utils" "^0.0.2" + "@agile-ts/utils" "^0.0.4" "@agile-ts/core@file:.yalc/@agile-ts/core": - version "0.0.15" + version "0.0.17" dependencies: - "@agile-ts/logger" "^0.0.2" - "@agile-ts/utils" "^0.0.2" + "@agile-ts/logger" "^0.0.4" + "@agile-ts/utils" "^0.0.4" "@agile-ts/event@file:.yalc/@agile-ts/event": - version "0.0.5" + version "0.0.7" -"@agile-ts/logger@^0.0.2": - version "0.0.2" - resolved "https://registry.yarnpkg.com/@agile-ts/logger/-/logger-0.0.2.tgz#80a726531dd63ca7d1c9a123383e57b5501efbb0" - integrity sha512-rJJ5pqXtOriYxjuZPhHs2J9N1FnIaAZqItCw0MXW9/5od/uhJ28aiG7w9RUBZts9SjDcICYEfjFMcTJ/kYJsMg== +"@agile-ts/logger@^0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@agile-ts/logger/-/logger-0.0.4.tgz#7f4d82ef8f03b13089af0878c360575c43f0962d" + integrity sha512-qm0obAKqJMaPKM+c76gktRXyw3OL1v39AnhMZ0FBGwJqHWU+fLRkCzlQwjaROCr3F1XP01Lc/Ls3efF0WzyEPw== dependencies: - "@agile-ts/utils" "^0.0.2" + "@agile-ts/utils" "^0.0.4" "@agile-ts/multieditor@file:.yalc/@agile-ts/multieditor": - version "0.0.15" + version "0.0.17" -"@agile-ts/proxytree@^0.0.2": - version "0.0.2" - resolved "https://registry.yarnpkg.com/@agile-ts/proxytree/-/proxytree-0.0.2.tgz#516ed19ee8d58aeecb291788a1e47be3dc23df8c" - integrity sha512-PbSiChF0GcUoWnrbnHauzBxZ5r/+4pZSZWpYjkBcIFa48DgTtFzg5DfQzsW3Rc1Y0QSFGYqcZOvCK1xAjLIQ2g== +"@agile-ts/proxytree@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@agile-ts/proxytree/-/proxytree-0.0.3.tgz#e3dacab123a311f2f0d4a0369793fe90fdab7569" + integrity sha512-auO6trCo7ivLJYuLjxrnK4xuUTangVPTq8UuOMTlGbJFjmb8PLEkaXuRoVGSzv9jsT2FeS7KsP7Fs+yvv0WPdg== "@agile-ts/proxytree@file:.yalc/@agile-ts/proxytree": - version "0.0.1" + version "0.0.3" "@agile-ts/react@file:.yalc/@agile-ts/react": - version "0.0.16" + version "0.0.18" dependencies: - "@agile-ts/proxytree" "^0.0.2" + "@agile-ts/proxytree" "^0.0.3" -"@agile-ts/utils@^0.0.2": - version "0.0.2" - resolved "https://registry.yarnpkg.com/@agile-ts/utils/-/utils-0.0.2.tgz#5f03761ace569b6c9ddd28c22f7b0fbec8b006b1" - integrity sha512-LqgQyMdK+zDuTCmOX6FOxTH4JNXhEvGFqIyNqRDoP99BK6MHGrK+n7nOW+1b4x6ZCYe0+VmwtG5CeOPOm3Siow== +"@agile-ts/utils@^0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@agile-ts/utils/-/utils-0.0.4.tgz#66e9536e561796489a37155da6b74ce2dc482697" + integrity sha512-GiZyTYmCm4j2N57oDjeMuPpfQdgn9clb0Cxpfuwi2Bq5T/KPXlaROLsVGwHLjwwT+NX7xxr5qNJH8pZTnHnYRQ== "@babel/code-frame@7.8.3": version "7.8.3" diff --git a/examples/vue/develop/my-project/src/core.js b/examples/vue/develop/my-project/src/core.js index fe4efb2f..ecb19e8b 100644 --- a/examples/vue/develop/my-project/src/core.js +++ b/examples/vue/develop/my-project/src/core.js @@ -1,4 +1,4 @@ -import { Agile, Logger } from '@agile-ts/core'; +import { Agile, Logger, globalBind } from '@agile-ts/core'; import vueIntegration from '@agile-ts/vue'; // Create Agile Instance @@ -7,9 +7,14 @@ export const App = new Agile({ }).integrate(vueIntegration); // Create State -export const MY_STATE = App.createState('Hello World'); +export const MY_STATE = App.createState('Hello World', { key: 'my-state' }); // Create Collection export const TODOS = App.createCollection({ initialData: [{ id: 1, name: 'Clean Bathroom' }], + selectors: [1], }).persist('todos'); + +// TODOS.collect({ id: 2, name: 'jeff' }); + +globalBind('__core__', { App, MY_STATE, TODOS }); diff --git a/examples/vue/develop/my-project/yarn.lock b/examples/vue/develop/my-project/yarn.lock index 9bd6b58a..04c336c0 100644 --- a/examples/vue/develop/my-project/yarn.lock +++ b/examples/vue/develop/my-project/yarn.lock @@ -3,25 +3,25 @@ "@agile-ts/core@file:.yalc/@agile-ts/core": - version "0.0.16" + version "0.0.17" dependencies: - "@agile-ts/logger" "^0.0.3" - "@agile-ts/utils" "^0.0.3" + "@agile-ts/logger" "^0.0.4" + "@agile-ts/utils" "^0.0.4" -"@agile-ts/logger@^0.0.3": - version "0.0.3" - resolved "https://registry.yarnpkg.com/@agile-ts/logger/-/logger-0.0.3.tgz#21f460bab99b5a1f50fbe6be95e1e9ed471ef456" - integrity sha512-8yejNCB7LXJ334smxovGaBWoqyXIUTHHO0/l2jPJt7WiMag0337KWbo1jyx6D8IkDioI9lunsN2U4CIBsRRhYA== +"@agile-ts/logger@^0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@agile-ts/logger/-/logger-0.0.4.tgz#7f4d82ef8f03b13089af0878c360575c43f0962d" + integrity sha512-qm0obAKqJMaPKM+c76gktRXyw3OL1v39AnhMZ0FBGwJqHWU+fLRkCzlQwjaROCr3F1XP01Lc/Ls3efF0WzyEPw== dependencies: - "@agile-ts/utils" "^0.0.3" + "@agile-ts/utils" "^0.0.4" -"@agile-ts/utils@^0.0.3": - version "0.0.3" - resolved "https://registry.yarnpkg.com/@agile-ts/utils/-/utils-0.0.3.tgz#f0e99d9ed9b21744f31effd99f7f7f32d26e3aec" - integrity sha512-h/gbPRRnFYxpIH4D0F/+6gVcZoZ2YPreT+cl8TCysjkjR6XnZ4YgC7patHIopX7ZvR97IMiu+BtpmS1UDbOftg== +"@agile-ts/utils@^0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@agile-ts/utils/-/utils-0.0.4.tgz#66e9536e561796489a37155da6b74ce2dc482697" + integrity sha512-GiZyTYmCm4j2N57oDjeMuPpfQdgn9clb0Cxpfuwi2Bq5T/KPXlaROLsVGwHLjwwT+NX7xxr5qNJH8pZTnHnYRQ== "@agile-ts/vue@file:.yalc/@agile-ts/vue": - version "0.0.4" + version "0.0.5" "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13": version "7.12.13" diff --git a/packages/core/src/collection/collection.persistent.ts b/packages/core/src/collection/collection.persistent.ts index d7e099a8..380451b8 100644 --- a/packages/core/src/collection/collection.persistent.ts +++ b/packages/core/src/collection/collection.persistent.ts @@ -138,6 +138,14 @@ export class CollectionPersistent< if (defaultGroup.persistent?.ready) await defaultGroup.persistent.initialLoading(); + // TODO rebuild the default Group once at the end when all Items were loaded into the Collection + // because otherwise it rebuilds the Group for each loaded Item + // (-> warnings are printed for all not yet loaded Items when rebuilding the Group) + // or rethink the whole Group rebuild process by adding a 'addItem()', 'removeItem()' and 'updateItem()' function + // so that there is no need for rebuilding the whole Group when for example only Item B changed or Item C was added + // + // See Issue by starting the vue develop example app and adding some todos to the _todo_ list + // Persist Items found in the default Group's value for (const itemKey of defaultGroup._value) { const item = this.collection().getItem(itemKey); @@ -170,11 +178,11 @@ export class CollectionPersistent< if (dummyItem?.persistent?.ready) { const loadedPersistedValueIntoItem = await dummyItem.persistent.loadPersistedValue( itemStorageKey - ); + ); // TODO FIRST GROUP REBUILD (by assigning loaded value to Item) // If successfully loaded Item value, assign Item to Collection if (loadedPersistedValueIntoItem) - this.collection().assignItem(dummyItem); + this.collection().assignItem(dummyItem); // TODO SECOND GROUP REBUILD (by calling rebuildGroupThatInclude() in the assignItem() method) } } } diff --git a/packages/core/src/collection/index.ts b/packages/core/src/collection/index.ts index 4e944901..8ac6a0e0 100644 --- a/packages/core/src/collection/index.ts +++ b/packages/core/src/collection/index.ts @@ -85,17 +85,20 @@ export class Collection { this.initGroups(_config.groups as any); this.initSelectors(_config.selectors as any); - if (_config.initialData) this.collect(_config.initialData); - this.isInstantiated = true; + // Add 'initialData' to Collection + // (after 'isInstantiated' to add them properly to the Collection) + if (_config.initialData) this.collect(_config.initialData); + // Reselect Selector Items // Necessary because the selection of an Item - // hasn't worked with a not 'instantiated' Collection before + // hasn't worked with a not correctly 'instantiated' Collection before for (const key in this.selectors) this.selectors[key].reselect(); // Rebuild of Groups // Not necessary because if Items are added to the Collection, + // (after 'isInstantiated = true') // the Groups which contain these added Items are rebuilt. // for (const key in this.groups) this.groups[key].rebuild(); } @@ -1486,12 +1489,12 @@ export type ItemKey = string | number; export interface CreateCollectionConfigInterface { /** - * Initial Groups of Collection. + * Initial Groups of the Collection. * @default [] */ groups?: { [key: string]: Group } | string[]; /** - * Initial Selectors of Collection + * Initial Selectors of the Collection * @default [] */ selectors?: { [key: string]: Selector } | string[]; diff --git a/packages/core/src/computed/index.ts b/packages/core/src/computed/index.ts index 983fc42c..cea3cdc7 100644 --- a/packages/core/src/computed/index.ts +++ b/packages/core/src/computed/index.ts @@ -153,7 +153,7 @@ export class Computed extends State< newDeps.push(observer); // Make this Observer depend on the found dep Observers - observer.depend(this.observer); + observer.addDependent(this.observer); }); this.deps = newDeps; diff --git a/packages/core/src/runtime/index.ts b/packages/core/src/runtime/index.ts index e3b1c409..ec0b841d 100644 --- a/packages/core/src/runtime/index.ts +++ b/packages/core/src/runtime/index.ts @@ -6,75 +6,109 @@ import { ComponentSubscriptionContainer, defineConfig, notEqual, - isValidObject, LogCodeManager, } from '../internal'; export class Runtime { + // Agile Instance the Runtime belongs to public agileInstance: () => Agile; - // Queue system + // Job that is currently being performed public currentJob: RuntimeJob | null = null; + // Jobs to be performed public jobQueue: Array = []; - public notReadyJobsToRerender: Set = new Set(); // Jobs that got performed but aren't ready to get rerendered (wait for mount) - public jobsToRerender: Array = []; // Jobs that are performed and will be rendered + + // Jobs that were performed and are ready to re-render + public jobsToRerender: Array = []; + // Jobs that were performed and couldn't be re-rendered yet. + // That is the case when at least one Subscription Container (UI-Component) in the Job + // wasn't ready to update (re-render). + public notReadyJobsToRerender: Set = new Set(); + + // Whether the `jobQueue` is currently being actively processed + public isPerformingJobs = false; /** + * The Runtime queues and executes incoming Observer-based Jobs + * to prevent [race conditions](https://en.wikipedia.org/wiki/Race_condition#:~:text=A%20race%20condition%20or%20race,the%20possible%20behaviors%20is%20undesirable.) + * and optimized the re-rendering of the Observer's subscribed UI-Components. + * + * Each queued Job is executed when it is its turn + * by calling the Job Observer's `perform()` method. + * + * After successful execution, the Job is added to a re-render queue, + * which is first put into the browser's 'Bucket' and started to work off + * when resources are left. + * + * The re-render queue is designed for optimizing the render count + * by batching multiple re-render Jobs of the same UI-Component + * and ignoring re-render requests for unmounted UI-Components. + * * @internal - * Runtime - Performs ingested Observers - * @param agileInstance - An instance of Agile + * @param agileInstance - Instance of Agile the Runtime belongs to. */ constructor(agileInstance: Agile) { this.agileInstance = () => agileInstance; } - //========================================================================================================= - // Ingest - //========================================================================================================= /** - * @internal - * Ingests Job into Runtime that gets performed - * @param job - Job - * @param config - Config + * Adds the specified Observer-based Job to the internal Job queue, + * where it is executed when it is its turn. + * + * After successful execution, the Job is assigned to the re-render queue, + * where all the Observer's subscribed Subscription Containers (UI-Components) + * are updated (re-rendered). + * + * @public + * @param job - Job to be added to the Job queue. + * @param config - Configuration object */ public ingest(job: RuntimeJob, config: IngestConfigInterface = {}): void { config = defineConfig(config, { - perform: true, + perform: !this.isPerformingJobs, }); + // Add specified Job to the queue this.jobQueue.push(job); Agile.logger.if .tag(['runtime']) .info(LogCodeManager.getLog('16:01:00', [job._key]), job); - // Perform Job + // Run first Job from the queue if (config.perform) { const performJob = this.jobQueue.shift(); if (performJob) this.perform(performJob); } } - //========================================================================================================= - // Perform - //========================================================================================================= /** + * Performs the specified Job + * and assigns it to the re-render queue if necessary. + * + * After the execution of the provided Job, it is checked whether + * there are still Jobs left in the Job queue. + * - If so, the next Job in the `jobQueue` is performed. + * - If not, the `jobsToRerender` queue is started to work off. + * * @internal - * Performs Job and adds it to the rerender queue if necessary - * @param job - Job that gets performed + * @param job - Job to be performed. */ public perform(job: RuntimeJob): void { + this.isPerformingJobs = true; this.currentJob = job; // Perform Job job.observer.perform(job); job.performed = true; - // Ingest Dependents of Observer into Runtime + // Ingest dependents of the Observer into runtime, + // since they depend on the Observer and therefore have properly changed too job.observer.dependents.forEach((observer) => observer.ingest({ perform: false }) ); + // Add Job to rerender queue and reset current Job property if (job.rerender) this.jobsToRerender.push(job); this.currentJob = null; @@ -82,11 +116,14 @@ export class Runtime { .tag(['runtime']) .info(LogCodeManager.getLog('16:01:01', [job._key]), job); - // Perform Jobs as long as Jobs are left in queue, if no job left update/rerender Subscribers of jobsToRerender + // Perform Jobs as long as Jobs are left in the queue. + // If no Job is left start updating (re-rendering) Subscription Container (UI-Components) + // of the Job based on the 'jobsToRerender' queue. if (this.jobQueue.length > 0) { const performJob = this.jobQueue.shift(); if (performJob) this.perform(performJob); } else { + this.isPerformingJobs = false; if (this.jobsToRerender.length > 0) { // https://stackoverflow.com/questions/9083594/call-settimeout-without-delay setTimeout(() => { @@ -96,50 +133,63 @@ export class Runtime { } } - //========================================================================================================= - // Update Subscribers - //========================================================================================================= /** + * Processes the `jobsToRerender` queue by updating (causing a re-render on) + * the subscribed Subscription Containers (UI-Components) of each Job Observer. + * + * It returns a boolean indicating whether + * any Subscription Container (UI-Component) was updated (re-rendered) or not. + * * @internal - * Updates/Rerenders all Subscribed Components (SubscriptionContainer) of the Job (Observer) - * @return If any subscriptionContainer got updated (-> triggered a rerender on the Component it represents) */ public updateSubscribers(): boolean { - if (!this.agileInstance().hasIntegration()) { - this.jobsToRerender = []; - this.notReadyJobsToRerender = new Set(); - return false; - } - if ( - this.jobsToRerender.length <= 0 && - this.notReadyJobsToRerender.size <= 0 - ) - return false; - - // Subscriptions that has to be updated/rerendered - // A Set() to combine several equal SubscriptionContainers into one (optimizes rerender) - // (Even better would be to combine SubscriptionContainer based on the Component, - // since a Component can have multiple SubscriptionContainers) - const subscriptionsToUpdate = new Set(); - - // Build final jobsToRerender array based on new jobsToRerender and not ready jobsToRerender + // Build final 'jobsToRerender' array + // based on the new 'jobsToRerender' array and the 'notReadyJobsToRerender' array const jobsToRerender = this.jobsToRerender.concat( Array.from(this.notReadyJobsToRerender) ); this.notReadyJobsToRerender = new Set(); this.jobsToRerender = []; - // Check if Job SubscriptionContainers should be updated and if so add them to the subscriptionsToUpdate array - jobsToRerender.forEach((job) => { + if (!this.agileInstance().hasIntegration() || jobsToRerender.length <= 0) + return false; + + // Extract the Subscription Container to be re-rendered from the Jobs + const subscriptionContainerToUpdate = this.extractToUpdateSubscriptionContainer( + jobsToRerender + ); + if (subscriptionContainerToUpdate.length <= 0) return false; + + // Update Subscription Container (trigger re-render on the UI-Component they represent) + this.updateSubscriptionContainer(subscriptionContainerToUpdate); + + return true; + } + + /** + * Extracts the Subscription Containers (UI-Components) + * to be updated (re-rendered) from the specified Runtime Jobs. + * + * @internal + * @param jobs - Jobs from which to extract the Subscription Containers to be updated. + */ + public extractToUpdateSubscriptionContainer( + jobs: Array + ): Array { + const subscriptionsToUpdate = new Set(); + + jobs.forEach((job) => { job.subscriptionContainersToUpdate.forEach((subscriptionContainer) => { + let updateSubscriptionContainer = true; + + // Handle not ready Subscription Container if (!subscriptionContainer.ready) { if ( - !job.config.numberOfTriesToUpdate || - job.triesToUpdate < job.config.numberOfTriesToUpdate + !job.config.maxTriesToUpdate || + job.triedToUpdateCount < job.config.maxTriesToUpdate ) { - job.triesToUpdate++; + job.triedToUpdateCount++; this.notReadyJobsToRerender.add(job); - LogCodeManager.log( '16:02:00', [subscriptionContainer.key], @@ -148,165 +198,145 @@ export class Runtime { } else { LogCodeManager.log( '16:02:01', - [job.config.numberOfTriesToUpdate], + [job.config.maxTriesToUpdate], subscriptionContainer ); } return; } - // Handle Object based Subscription - if (subscriptionContainer.isObjectBased) - this.handleObjectBasedSubscription(subscriptionContainer, job); - - // Check if subscriptionContainer should be updated - const updateSubscriptionContainer = subscriptionContainer.proxyBased - ? this.handleProxyBasedSubscription(subscriptionContainer, job) - : true; - - if (updateSubscriptionContainer) + // TODO has to be overthought because when it is a Component based Subscription + // the rerender is triggered via merging the changed properties into the Component. + // Although the 'componentId' might be equal, it doesn't mean + // that the changed properties are equal! (-> changed properties might get missing) + // Check if Subscription Container with same 'componentId' + // is already in the 'subscriptionToUpdate' queue (rerender optimisation) + // updateSubscriptionContainer = + // updateSubscriptionContainer && + // Array.from(subscriptionsToUpdate).findIndex( + // (sc) => sc.componentId === subscriptionContainer.componentId + // ) === -1; + + // Check whether a selected part of the Observer value has changed + updateSubscriptionContainer = + updateSubscriptionContainer && + this.handleSelectors(subscriptionContainer, job); + + // Add Subscription Container to the 'subscriptionsToUpdate' queue + if (updateSubscriptionContainer) { + subscriptionContainer.updatedSubscribers.add(job.observer); subscriptionsToUpdate.add(subscriptionContainer); + } job.subscriptionContainersToUpdate.delete(subscriptionContainer); }); }); - if (subscriptionsToUpdate.size <= 0) return false; + return Array.from(subscriptionsToUpdate); + } - // Update Subscription Containers (trigger rerender on subscribed Component) + /** + * Updates the specified Subscription Containers. + * + * Updating a Subscription Container triggers a re-render + * on the Component it represents, based on the type of the Subscription Containers. + * + * @internal + * @param subscriptionsToUpdate - Subscription Containers to be updated. + */ + public updateSubscriptionContainer( + subscriptionsToUpdate: Array + ): void { subscriptionsToUpdate.forEach((subscriptionContainer) => { // Call 'callback function' if Callback based Subscription if (subscriptionContainer instanceof CallbackSubscriptionContainer) subscriptionContainer.callback(); - // Call 'update method' if Component based Subscription + // Call 'update method' in Integrations if Component based Subscription if (subscriptionContainer instanceof ComponentSubscriptionContainer) this.agileInstance().integrations.update( subscriptionContainer.component, - this.getObjectBasedProps(subscriptionContainer) + this.getUpdatedObserverValues(subscriptionContainer) ); + + subscriptionContainer.updatedSubscribers.clear(); }); Agile.logger.if .tag(['runtime']) .info(LogCodeManager.getLog('16:01:02'), subscriptionsToUpdate); - - return true; } - //========================================================================================================= - // Handle Object Based Subscription - //========================================================================================================= /** + * Maps the values of the updated Observers (`updatedSubscribers`) + * of the specified Subscription Container into a key map object. + * + * The key containing the Observer value is extracted from the Observer itself + * or from the Subscription Container's `subscriberKeysWeakMap`. + * * @internal - * Finds key of Observer (Job) in subsObject and adds it to 'changedObjectKeys' - * @param subscriptionContainer - Object based SubscriptionContainer - * @param job - Job that holds the searched Observer + * @param subscriptionContainer - Subscription Container from which the `updatedSubscribers` are to be mapped into a key map. */ - public handleObjectBasedSubscription( - subscriptionContainer: SubscriptionContainer, - job: RuntimeJob - ): void { - let foundKey: string | null = null; - - // Check if SubscriptionContainer is Object based - if (!subscriptionContainer.isObjectBased) return; - - // Find Key of Job Observer in SubscriptionContainer - for (const key in subscriptionContainer.subsObject) - if (subscriptionContainer.subsObject[key] === job.observer) - foundKey = key; - - if (foundKey) subscriptionContainer.observerKeysToUpdate.push(foundKey); - } - - //========================================================================================================= - // Get Object Based Props - //========================================================================================================= - /** - * @internal - * Builds Object out of changedObjectKeys with Observer Value - * @param subscriptionContainer - Object based SubscriptionContainer - */ - public getObjectBasedProps( + public getUpdatedObserverValues( subscriptionContainer: SubscriptionContainer ): { [key: string]: any } { const props: { [key: string]: any } = {}; - - // Map trough observerKeysToUpdate and build object out of Observer value - if (subscriptionContainer.subsObject) - for (const updatedKey of subscriptionContainer.observerKeysToUpdate) - props[updatedKey] = subscriptionContainer.subsObject[updatedKey]?.value; - - subscriptionContainer.observerKeysToUpdate = []; + for (const observer of subscriptionContainer.updatedSubscribers) { + const key = + subscriptionContainer.subscriberKeysWeakMap.get(observer) ?? + observer.key; + if (key != null) props[key] = observer.value; + } return props; } - //========================================================================================================= - // Handle Proxy Based Subscription - //========================================================================================================= /** + * Returns a boolean indicating whether the specified Subscription Container can be updated or not, + * based on its selector functions (`selectorsWeakMap`). + * + * This is done by checking the '.value' and the '.previousValue' property of the Observer represented by the Job. + * If a selected property differs, the Subscription Container (UI-Component) is allowed to update (re-render) + * and `true` is returned. + * + * If the Subscription Container has no selector function at all, `true` is returned. + * * @internal - * Checks if the subscriptionContainer should be updated. - * Therefore it reviews the '.value' and the '.previousValue' property of the Observer the Job represents. - * If a property at the proxy detected path differs, the subscriptionContainer is allowed to update. - * @param subscriptionContainer - SubscriptionContainer - * @param job - Job - * @return {boolean} If the subscriptionContainer should be updated - * -> If a from the Proxy Tree detected property differs from the same property in the previous value - * or the passed subscriptionContainer isn't properly proxy based + * @param subscriptionContainer - Subscription Container to be checked if it can be updated. + * @param job - Job containing the Observer that is subscribed to the Subscription Container. */ - public handleProxyBasedSubscription( + public handleSelectors( subscriptionContainer: SubscriptionContainer, job: RuntimeJob ): boolean { - // Return true because in this cases the subscriptionContainer isn't properly proxyBased - if ( - !subscriptionContainer.proxyBased || - !job.observer._key || - !subscriptionContainer.proxyKeyMap[job.observer._key] - ) - return true; - - const paths = subscriptionContainer.proxyKeyMap[job.observer._key].paths; - - if (paths) { - for (const path of paths) { - // Get property in new Value located at path - let newValue = job.observer.value; - let newValueDeepness = 0; - for (const branch of path) { - if (!isValidObject(newValue, true)) break; - newValue = newValue[branch]; - newValueDeepness++; - } - - // Get property in previous Value located at path - let previousValue = job.observer.previousValue; - let previousValueDeepness = 0; - for (const branch of path) { - if (!isValidObject(previousValue, true)) break; - previousValue = previousValue[branch]; - previousValueDeepness++; - } - - // Check if found values differ - if ( - notEqual(newValue, previousValue) || - newValueDeepness !== previousValueDeepness - ) { - return true; - } - } + const selectorMethods = subscriptionContainer.selectorsWeakMap.get( + job.observer + )?.methods; + + // If no selector functions found, return true. + // Because no specific part of the Observer was selected. + // -> The Subscription Container should be updated + // no matter what has updated in the Observer. + if (selectorMethods == null) return true; + + // Check if a selected part of the Observer value has changed + const previousValue = job.observer.previousValue; + const newValue = job.observer.value; + for (const selectorMethod of selectorMethods) { + if ( + notEqual(selectorMethod(newValue), selectorMethod(previousValue)) + // || newValueDeepness !== previousValueDeepness // Not possible to check the object deepness + ) + return true; } return false; } } -/** - * @param perform - If Job gets performed immediately - */ export interface IngestConfigInterface { + /** + * Whether the ingested Job should be performed immediately + * or added to the queue first and then executed when it is his turn. + */ perform?: boolean; } diff --git a/packages/core/src/runtime/observer.ts b/packages/core/src/runtime/observer.ts index 5710bd18..eb76f80c 100644 --- a/packages/core/src/runtime/observer.ts +++ b/packages/core/src/runtime/observer.ts @@ -7,25 +7,54 @@ import { IngestConfigInterface, CreateRuntimeJobConfigInterface, LogCodeManager, + generateId, } from '../internal'; export type ObserverKey = string | number; export class Observer { + // Agile Instance the Observer belongs to public agileInstance: () => Agile; + // Key/Name identifier of the Observer public _key?: ObserverKey; - public dependents: Set = new Set(); // Observers that depend on this Observer - public subscribedTo: Set = new Set(); // SubscriptionContainers (Components) that this Observer is subscribed to - public value?: ValueType; // Value of Observer - public previousValue?: ValueType; // Previous Value of Observer + // Observers that depend on this Observer + public dependents: Set = new Set(); + // Subscription Containers (UI-Components) the Observer is subscribed to + public subscribedTo: Set = new Set(); + + // Current value of the Observer + public value?: ValueType; + // Previous value of the Observer + public previousValue?: ValueType; /** + * An Observer manages the subscriptions to Subscription Containers (UI-Components) + * and dependencies to other Observers (Agile Classes) + * for an Agile Class like the `State Class`. + * + * Agile Classes often use an Observer as an interface to the Runtime. + * In doing so, they ingest their own Observer into the Runtime + * when the Agile Class has changed in such a way + * that these changes need to be applied to UI-Components + * or dependent other Observers. + * + * After the Observer has been ingested into the Runtime + * wrapped into a Runtime-Job, it is first added to the Jobs queue + * to prevent race conditions. + * When it is executed, the Observer's `perform()` method is called, + * where the accordingly changes are applied to the Agile Class. + * + * Now that the Job was performed, it is added to the rerender queue, + * where the subscribed Subscription Container (UI-Components) + * of the Observer are updated (re-rendered). + * + * Note that the Observer itself is no standalone class + * and should be adapted to the Agile Class needs it belongs to. + * * @internal - * Observer - Handles subscriptions and dependencies of an Agile Class and is like an instance to the Runtime - * Note: No stand alone class!! - * @param agileInstance - An instance of Agile - * @param config - Config + * @param agileInstance - Instance of Agile the Observer belongs to. + * @param config - Configuration object */ constructor( agileInstance: Agile, @@ -39,35 +68,40 @@ export class Observer { this._key = config.key; this.value = config.value; this.previousValue = config.value; - config.dependents?.forEach((observer) => this.depend(observer)); + config.dependents?.forEach((observer) => this.addDependent(observer)); config.subs?.forEach((subscriptionContainer) => - this.subscribe(subscriptionContainer) + subscriptionContainer.addSubscription(this) ); } /** - * @internal - * Set Key/Name of Observer + * Updates the key/name identifier of the Observer. + * + * @public + * @param value - New key/name identifier. */ public set key(value: StateKey | undefined) { this._key = value; } /** - * @internal - * Get Key/Name of Observer + * Returns the key/name identifier of the Observer. + * + * @public */ public get key(): StateKey | undefined { return this._key; } - //========================================================================================================= - // Ingest - //========================================================================================================= /** - * @internal - * Ingests Observer into Runtime - * @param config - Configuration + * Passes the Observer into the runtime wrapped into a Runtime-Job + * where it is executed accordingly. + * + * During the execution the runtime performs the Observer's `perform()` method, + * updates its dependents and re-renders the UI-Components it is subscribed to. + * + * @public + * @param config - Configuration object */ public ingest(config: ObserverIngestConfigInterface = {}): void { config = defineConfig(config, { @@ -80,86 +114,80 @@ export class Observer { force: false, }); - // Create Job + // Create Runtime-Job const job = new RuntimeJob(this, { force: config.force, sideEffects: config.sideEffects, background: config.background, - key: config.key || this._key, + key: + config.key ?? + `${this._key != null ? this._key + '_' : ''}${generateId()}`, }); + // Pass created Job into the Runtime this.agileInstance().runtime.ingest(job, { perform: config.perform, }); } - //========================================================================================================= - // Perform - //========================================================================================================= /** - * @internal - * Performs Job of Runtime - * @param job - Job that gets performed + * Method executed by the Runtime to perform the Runtime-Job, + * previously ingested via the `ingest()` method. + * + * Note that this method should be overwritten + * to correctly apply the changes to the Agile Class + * to which the Observer belongs. + * + * @public + * @param job - Runtime-Job to be performed. */ public perform(job: RuntimeJob): void { LogCodeManager.log('17:03:00'); } - //========================================================================================================= - // Depend - //========================================================================================================= /** - * @internal - * Adds Dependent to Observer which gets ingested into the Runtime whenever this Observer mutates - * @param observer - Observer that will depend on this Observer + * Makes the specified Observer depend on the Observer. + * + * A dependent Observer is always ingested into the Runtime, + * when the Observer it depends on has also been ingested. + * + * @public + * @param observer - Observer to depend on the Observer. */ - public depend(observer: Observer): void { + public addDependent(observer: Observer): void { if (!this.dependents.has(observer)) this.dependents.add(observer); } +} - //========================================================================================================= - // Subscribe - //========================================================================================================= +export interface CreateObserverConfigInterface { /** - * @internal - * Adds Subscription to Observer - * @param subscriptionContainer - SubscriptionContainer(Component) that gets subscribed by this Observer + * Initial Observers to depend on the Observer. + * @default [] */ - public subscribe(subscriptionContainer: SubscriptionContainer): void { - if (!this.subscribedTo.has(subscriptionContainer)) { - this.subscribedTo.add(subscriptionContainer); - - // Add this to subscriptionContainer to keep track of the Observers the subscriptionContainer hold - subscriptionContainer.subscribers.add(this); - } - } - - //========================================================================================================= - // Unsubscribe - //========================================================================================================= + dependents?: Array; /** - * @internal - * Removes Subscription from Observer - * @param subscriptionContainer - SubscriptionContainer(Component) that gets unsubscribed by this Observer + * Initial Subscription Containers the Observer is subscribed to. + * @default [] */ - public unsubscribe(subscriptionContainer: SubscriptionContainer): void { - if (this.subscribedTo.has(subscriptionContainer)) { - this.subscribedTo.delete(subscriptionContainer); - subscriptionContainer.subscribers.delete(this); - } - } -} - -/** - * @param deps - Initial Dependents of Observer - * @param subs - Initial Subscriptions of Observer - * @param key - Key/Name of Observer - * @param value - Initial Value of Observer - */ -export interface CreateObserverConfigInterface { - dependents?: Array; subs?: Array; + /** + * Key/Name identifier of the Observer. + * @default undefined + */ key?: ObserverKey; + /** + * Initial value of the Observer. + * + * The value of an Observer is given to the Integration's `updateMethod()` method + * (Component Subscription Container) where it can be, + * for example, merged in a local State Management property of the UI-Component + * it is subscribed to. + * + * Also the selection of specific properties of an Agile Class value + * is based on the Observer `value` and `previousValue`. + * + * @default undefined + */ value?: ValueType; } diff --git a/packages/core/src/runtime/runtime.job.ts b/packages/core/src/runtime/runtime.job.ts index a3e559ea..c61b7c02 100644 --- a/packages/core/src/runtime/runtime.job.ts +++ b/packages/core/src/runtime/runtime.job.ts @@ -1,19 +1,32 @@ import { Observer, defineConfig, SubscriptionContainer } from '../internal'; export class RuntimeJob { - public _key?: RuntimeJobKey; public config: RuntimeJobConfigInterface; - public observer: ObserverType; // Observer the Job represents - public rerender: boolean; // If Job will cause rerender on subscriptionContainer in Observer - public performed = false; // If Job has been performed by Runtime - public subscriptionContainersToUpdate = new Set(); // SubscriptionContainer (from Observer) that have to be updated/rerendered - public triesToUpdate = 0; // How often not ready subscriptionContainers of this Job have been tried to update + + // Key/Name identifier of the Runtime Job + public _key?: RuntimeJobKey; + // Observer the Job represents + public observer: ObserverType; + // Whether the Subscription Containers (UI-Components) of the Observer should be updated (re-rendered) + public rerender: boolean; + // Subscription Containers (UI-Components) of the Observer that have to be updated (re-rendered) + public subscriptionContainersToUpdate = new Set(); + // How often not ready Subscription Containers of the Observer have been tried to update + public triedToUpdateCount = 0; + + // Whether the Job has been performed by the runtime + public performed = false; /** + * A Runtime Job is sent to the Runtime on behalf of the Observer it represents. + * + * In the Runtime, the Observer is performed via its `perform()` method + * and the Subscription Containers (UI-Components) + * to which it is subscribed are updated (re-rendered) accordingly. + * * @internal - * Job - Represents Observer that gets performed by the Runtime - * @param observer - Observer - * @param config - Config + * @param observer - Observer to be represented by the Runtime Job. + * @param config - Configuration object */ constructor( observer: ObserverType, @@ -26,13 +39,13 @@ export class RuntimeJob { exclude: [], }, force: false, - numberOfTriesToUpdate: 3, + maxTriesToUpdate: 3, }); this.config = { background: config.background, force: config.force, sideEffects: config.sideEffects, - numberOfTriesToUpdate: config.numberOfTriesToUpdate, + maxTriesToUpdate: config.maxTriesToUpdate, }; this.observer = observer; this.rerender = @@ -42,45 +55,76 @@ export class RuntimeJob { this.subscriptionContainersToUpdate = new Set(observer.subscribedTo); } - public get key(): RuntimeJobKey | undefined { - return this._key; - } - + /** + * Updates the key/name identifier of the Runtime Job. + * + * @public + * @param value - New key/name identifier. + */ public set key(value: RuntimeJobKey | undefined) { this._key = value; } + + /** + * Returns the key/name identifier of the Runtime Job. + * + * @public + */ + public get key(): RuntimeJobKey | undefined { + return this._key; + } } export type RuntimeJobKey = string | number; -/** - * @param key - Key/Name of RuntimeJob - */ export interface CreateRuntimeJobConfigInterface extends RuntimeJobConfigInterface { + /** + * Key/Name identifier of the Runtime Job. + * @default undefined + */ key?: RuntimeJobKey; } -/** - * @param background - If Job gets executed in the background -> not causing any rerender - * @param sideEffects - If SideEffects get executed - * @param force - Force performing Job - * @param numberOfTriesToUpdate - How often the runtime should try to update not ready SubscriptionContainers of this Job - * If 'null' the runtime tries to update the not ready SubscriptionContainer until they are ready (infinite). - * But be aware that this can lead to an overflow of 'old' Jobs after some time. (affects performance) - */ export interface RuntimeJobConfigInterface { + /** + * Whether to perform the Runtime Job in background. + * So that the Subscription Containers (UI-Components) aren't notified + * of these changes and thus doesn't update (re-render). + * @default false + */ background?: boolean; + /** + * Configuration of the execution of defined side effects. + * @default {enabled: true, exclude: []} + */ sideEffects?: SideEffectConfigInterface; + /** + * Whether the Runtime Job should be forced through the runtime + * although it might be useless from the current viewpoint of the runtime. + * @default false + */ force?: boolean; - numberOfTriesToUpdate?: number | null; + /** + * How often the Runtime should try to update not ready Subscription Containers + * subscribed by the Observer which the Job represents. + * + * When `null` the Runtime tries to update the not ready Subscription Containers + * until they are ready (infinite). + * @default 3 + */ + maxTriesToUpdate?: number | null; } -/** - * @param enabled - If SideEffects get executed - * @param exclude - SideEffect at Keys that doesn't get executed - */ export interface SideEffectConfigInterface { + /** + * Whether to execute the defined side effects. + * @default true + */ enabled?: boolean; + /** + * Side effect key identifier that won't be executed. + * @default [] + */ exclude?: string[]; } diff --git a/packages/core/src/runtime/subscription/container/CallbackSubscriptionContainer.ts b/packages/core/src/runtime/subscription/container/CallbackSubscriptionContainer.ts index e9771a0b..bf974bea 100644 --- a/packages/core/src/runtime/subscription/container/CallbackSubscriptionContainer.ts +++ b/packages/core/src/runtime/subscription/container/CallbackSubscriptionContainer.ts @@ -5,18 +5,30 @@ import { } from '../../../internal'; export class CallbackSubscriptionContainer extends SubscriptionContainer { + /** + * Callback function to trigger a re-render + * on the UI-Component which is represented by the Subscription Container. + */ public callback: Function; /** + * A Callback Subscription Container represents a UI-Component in AgileTs + * and triggers re-renders on the UI-Component via the specified callback function. + * + * The Callback Subscription Container doesn't keep track of the Component itself. + * It only knows how to trigger re-renders on it by calling the callback function. + * + * [Learn more..](https://agile-ts.org/docs/core/integration#callback-based) + * * @internal - * CallbackSubscriptionContainer - Subscription Container for Callback based Subscriptions - * @param callback - Callback Function that causes rerender on Component that is subscribed by Agile - * @param subs - Initial Subscriptions - * @param config - Config + * @param callback - Callback function to cause a rerender on the Component + * to be represented by the Subscription Container. + * @param subs - Observers to be initial subscribed to the Subscription Container. + * @param config - Configuration object */ constructor( callback: Function, - subs: Array = [], + subs: Array | { [key: string]: Observer }, config: SubscriptionContainerConfigInterface = {} ) { super(subs, config); diff --git a/packages/core/src/runtime/subscription/container/ComponentSubscriptionContainer.ts b/packages/core/src/runtime/subscription/container/ComponentSubscriptionContainer.ts index d71c3e2f..ff80eded 100644 --- a/packages/core/src/runtime/subscription/container/ComponentSubscriptionContainer.ts +++ b/packages/core/src/runtime/subscription/container/ComponentSubscriptionContainer.ts @@ -4,19 +4,63 @@ import { SubscriptionContainerConfigInterface, } from '../../../internal'; -export class ComponentSubscriptionContainer extends SubscriptionContainer { - public component: any; +export class ComponentSubscriptionContainer< + C = any +> extends SubscriptionContainer { + /** + * UI-Component which is represented by the Subscription Container + * and mutated via the Integration's `updateMethod()` method + * to cause re-renders on it. + */ + public component: C; /** + * A Component Subscription Container represents a UI-Component in AgileTs + * and triggers re-renders on the UI-Component by muting the specified Component Instance + * via the Integration's `updateMethod()` method. + * For example by updating a local State Management property of the Component + * (like in React Class Components the `this.state` property). + * + * The Component Subscription Container keeps track of the Component itself, + * to mutate it appropriately so that re-renders can be triggered on it. + * + * For this to work well, a Component Subscription Container is often object based. + * Meaning that each Observer was provided in an object keymap + * with a unique key identifier. + * ``` + * // Object based (guaranteed unique key identifier) + * { + * state1: Observer, + * state2: Observer + * } + * + * // Array based (no guaranteed unique key identifier) + * [Observer, Observer] + * ``` + * Thus the Integration's 'updateMethod()' method can be called + * with a complete object of updated Observer values. + * ``` + * updateMethod: (componentInstance, updatedData) => { + * console.log(componentInstance); // Returns 'this.component' + * console.log(updatedData); // Returns updated Observer values keymap (see below) + * // { + * // state1: Observer.value, + * // state2: Observer.value, + * // } + * } + * ``` + * + * [Learn more..](https://agile-ts.org/docs/core/integration#component-based) + * * @internal - * ComponentSubscriptionContainer - SubscriptionContainer for Component based Subscription - * @param component - Component that is subscribed by Agile - * @param subs - Initial Subscriptions - * @param config - Config + * @param component - UI-Component to be represented by the Subscription Container + * and mutated via the Integration's 'updateMethod()' method to trigger re-renders on it. + * @param subs - Observers to be initial subscribed to the Subscription Container. + * @param config - Configuration object */ constructor( - component: any, - subs: Array = [], + component: C, + subs: Array | { [key: string]: Observer }, config: SubscriptionContainerConfigInterface = {} ) { super(subs, config); diff --git a/packages/core/src/runtime/subscription/container/SubscriptionContainer.ts b/packages/core/src/runtime/subscription/container/SubscriptionContainer.ts index ef7c7bb5..4c0dcb17 100644 --- a/packages/core/src/runtime/subscription/container/SubscriptionContainer.ts +++ b/packages/core/src/runtime/subscription/container/SubscriptionContainer.ts @@ -1,73 +1,283 @@ import { defineConfig, generateId, - notEqual, + isValidObject, Observer, } from '../../../internal'; export class SubscriptionContainer { + /** + * Key/Name identifier of the Subscription Container. + */ public key?: SubscriptionContainerKeyType; + /** + * Whether the Subscription Container + * and the UI-Component it represents are ready. + * + * When both instances are ready, + * the Subscription Container is allowed + * to trigger re-renders on the UI-Component. + */ public ready = false; + /** + * Unique identifier of the UI-Component + * the Subscription Container represents. + */ + public componentId?: ComponentIdType; - public subscribers: Set; // Observers that are Subscribed to this SubscriptionContainer (Component) - - // Represents the paths to the accessed properties of the State/s this SubscriptionContainer represents - public proxyKeyMap: ProxyKeyMapInterface; - public proxyBased = false; + /** + * Observers that are subscribed to the Subscription Container. + * + * The subscribed Observers use the Subscription Container + * as an interface to the UI-Component it represents. + * + * Through the Subscription Container, the Observers can easily trigger re-renders + * on the UI-Component, for example, when their value updates. + * + * [Learn more..](https://agile-ts.org/docs/core/integration#-subscriptions) + */ + public subscribers: Set; + /** + * Temporary stores the subscribed Observers, + * that were updated by the runtime + * and are currently running through + * the update (rerender) Subscription Container (UI-Component) process. + */ + public updatedSubscribers: Set = new Set(); - // For Object based Subscription + /** + * Whether the Subscription Container is object based. + * + * A Subscription Container is object based when the subscribed Observers + * have been provided in an Observer keymap object + * ``` + * { + * state1: Observer, + * state2: Observer + * } + * ``` + * Thus each Observer has its 'external' unique key stored in the `subscribersWeakMap`. + * + * Often Component based Subscriptions are object based, + * because each Observer requires in such Subscription a unique identifier. + * Mainly to be properly represented in the `updatedData` object + * sent to the Integration's `updateMethod()` method + * when the Subscription Container updates (re-renders the UI-Component). + */ public isObjectBased = false; - public observerKeysToUpdate: Array = []; // Holds temporary keys of Observers that got updated (Note: keys based on 'subsObject') - public subsObject?: { [key: string]: Observer }; // Same as subs but in Object shape + /** + * Weak map for storing 'external' key identifiers for subscribed Observers. + * + * Why is the key not applied directly to the Observer? + * + * Because the key defined here should be only valid + * for the scope of the Subscription Container. + * + * https://stackoverflow.com/questions/29413222/what-are-the-actual-uses-of-es6-weakmap + */ + public subscriberKeysWeakMap: WeakMap; + + /** + * Weak Map for storing selector functions for subscribed Observers. + * + * A selector function allows the partial subscription to an Observer value. + * Only when the selected Observer value part changes, + * the Subscription Container is updated (-> re-renders the UI-Component). + * + * Why are the selector functions not applied directly to the Observer? + * + * Because the selector function defined here should be only valid + * for the scope of the Subscription Container. + * + * https://stackoverflow.com/questions/29413222/what-are-the-actual-uses-of-es6-weakmap + */ + public selectorsWeakMap: SelectorWeakMapType; /** + * A Subscription Container represents a UI-Component in AgileTs + * that can be subscribed by multiple Observer Instances. + * + * The subscribed Observers can use the Subscription Container as an interface + * to the UI-Component it represents. + * For example, to trigger re-renders on the UI-Component, + * when their value has changed. + * * @internal - * SubscriptionContainer - Represents Component/(Way to rerender Component) that is subscribed by Observer/s (Agile) - * -> Used to cause rerender on Component - * @param subs - Initial Subscriptions - * @param config - Config + * @param subs - Observers to be initial subscribed to the Subscription Container. + * @param config - Configuration object */ constructor( - subs: Array = [], + subs: Array | { [key: string]: Observer }, config: SubscriptionContainerConfigInterface = {} ) { config = defineConfig(config, { - proxyKeyMap: {}, + proxyWeakMap: new WeakMap(), + selectorWeakMap: new WeakMap(), key: generateId(), }); - this.subscribers = new Set(subs); + this.subscribers = new Set(); this.key = config.key; - this.proxyKeyMap = config.proxyKeyMap as any; - this.proxyBased = notEqual(this.proxyKeyMap, {}); + this.componentId = config?.componentId; + this.subscriberKeysWeakMap = new WeakMap(); + this.selectorsWeakMap = new WeakMap(); + this.isObjectBased = !Array.isArray(subs); + + // Assign initial Observers to the Subscription Container + for (const key in subs) { + this.addSubscription(subs[key], { + proxyPaths: config.proxyWeakMap?.get(subs[key])?.paths, + selectorMethods: config.selectorWeakMap?.get(subs[key])?.methods, + key: this.isObjectBased ? key : undefined, + }); + } + } + + /** + * Subscribes the specified Observer to the Subscription Container. + * + * @internal + * @param sub - Observer to be subscribed to the Subscription Container + * @param config - Configuration object + */ + public addSubscription( + sub: Observer, + config: AddSubscriptionMethodConfigInterface = {} + ): void { + const toAddSelectorMethods: SelectorMethodType[] = + config.selectorMethods ?? []; + const proxyPaths = config.proxyPaths ?? []; + + // Create additional selector methods based on the specified proxy paths + for (const path of proxyPaths) { + toAddSelectorMethods.push((value) => { + let _value = value; + for (const branch of path) { + if (!isValidObject(_value, true)) break; + _value = _value[branch]; + } + return _value; + }); + } + + // Assign defined/created selector methods to the 'selectorsWeakMap' + // (Not to the Observer itself, since the selector methods specified here + // only count for the scope of the Subscription Container) + const existingSelectorMethods = + this.selectorsWeakMap.get(sub)?.methods ?? []; + const newSelectorMethods = existingSelectorMethods.concat( + toAddSelectorMethods + ); + if (newSelectorMethods.length > 0) + this.selectorsWeakMap.set(sub, { methods: newSelectorMethods }); + + // Assign specified key to the 'subscriberKeysWeakMap' + // (Not to the Observer itself, since the key specified here + // only counts for the scope of the Subscription Container) + if (config.key != null) this.subscriberKeysWeakMap.set(sub, config.key); + + // Add Observer to subscribers + this.subscribers.add(sub); + + // Add Subscription Container to Observer + // so that the Observer can cause updates on it + // (trigger re-render on the UI-Component it represents). + sub.subscribedTo.add(this); + } + + /** + * Unsubscribes the specified Observer from the Subscription Container. + * + * @internal + * @param sub - Observer to be unsubscribed from the Subscription Container. + */ + public removeSubscription(sub: Observer) { + if (this.subscribers.has(sub)) { + this.selectorsWeakMap.delete(sub); + this.subscriberKeysWeakMap.delete(sub); + this.subscribers.delete(sub); + sub.subscribedTo.delete(this); + } } } export type SubscriptionContainerKeyType = string | number; -/** - * @param proxyKeyMap - A keymap with a 2 dimensional arrays with paths/routes to particular properties in the State at key. - * The subscriptionContainer will then only rerender the Component, when a property at a given path changes. - * Not anymore if anything in the State object mutates, although it might not even be displayed in the Component. - * For example: - * { - * myState1: {paths: [['data', 'name']]}, - * myState2: {paths: [['car', 'speed']]} - * } - * Now the subscriptionContain will only trigger a rerender on the Component - * if 'data.name' in myState1 or 'car.speed' in myState2 changes. - * If, for instance, 'data.age' in myState1 mutates it won't trigger a rerender, - * since 'data.age' isn't represented in the proxyKeyMap. - * - * These particular paths can be tracked with the ProxyTree. - * https://github.com/agile-ts/agile/tree/master/packages/proxytree - * @param key - Key/Name of Subscription Container - */ export interface SubscriptionContainerConfigInterface { - proxyKeyMap?: ProxyKeyMapInterface; + /** + * Key/Name identifier of the Subscription Container + * @default undefined + */ key?: SubscriptionContainerKeyType; + /** + * Key/Name identifier of the UI-Component to be represented by the Subscription Container. + * @default undefined + */ + componentId?: ComponentIdType; + /** + * A Weak Map with a set of proxy paths to certain properties + * in an Observer value for subscribed Observers. + * + * These paths are then selected via selector functions + * which allow the partly subscription to an Observer value. + * Only if the selected Observer value part changes, + * the Subscription Container re-renders the UI-Component it represents. + * + * For example: + * ``` + * WeakMap: { + * Observer1: {paths: [['data', 'name']]}, + * Observer2: {paths: [['car', 'speed']]} + * } + * ``` + * Now the Subscription Container will only trigger a re-render on the UI-Component + * if 'data.name' in Observer1 or 'car.speed' in Observer2 updates. + * If, for instance, 'data.age' in Observer1 mutates it won't trigger a re-render, + * since 'data.age' isn't represented in the specified Proxy Weak Map. + * + * These particular paths can, for example, be tracked via the ProxyTree. + * https://github.com/agile-ts/agile/tree/master/packages/proxytree + * + * @default new WeakMap() + */ + proxyWeakMap?: ProxyWeakMapType; + /** + * A Weak Map with a set of selector functions for Observers. + * + * Selector functions allow the partly subscription to Observer values. + * Only if the selected Observer value part changes, + * the Subscription Container re-renders the UI-Component it represents. + * + * For example: + * ``` + * WeakMap: { + * Observer1: {methods: [(value) => value.data.name]}, + * Observer2: {methods: [(value) => value.car.speed]} + * } + * ``` + * Now the Subscription Container will only trigger a re-render on the UI-Component + * if 'data.name' in Observer1 or 'car.speed' in Observer2 updates. + * If, for instance, 'data.age' in Observer1 mutates it won't trigger a re-render, + * since 'data.age' isn't selected by any selector method in the specified Selector Weak Map. + * + * @default new WeakMap() + */ + selectorWeakMap?: SelectorWeakMapType; } -export interface ProxyKeyMapInterface { - [key: string]: { paths: string[][] }; +export interface AddSubscriptionMethodConfigInterface { + proxyPaths?: ProxyPathType[]; + selectorMethods?: SelectorMethodType[]; + key?: string; } + +export type ProxyPathType = string[]; +export type ProxyWeakMapType = WeakMap; + +export type SelectorMethodType = (value: T) => any; +export type SelectorWeakMapType = WeakMap< + Observer, + { methods: SelectorMethodType[] } +>; + +export type ComponentIdType = string | number; diff --git a/packages/core/src/runtime/subscription/sub.controller.ts b/packages/core/src/runtime/subscription/sub.controller.ts index fbd9b37c..d1ac3641 100644 --- a/packages/core/src/runtime/subscription/sub.controller.ts +++ b/packages/core/src/runtime/subscription/sub.controller.ts @@ -12,164 +12,185 @@ import { } from '../../internal'; export class SubController { + // Agile Instance the SubController belongs to public agileInstance: () => Agile; - public componentSubs: Set = new Set(); // Holds all registered Component based Subscriptions - public callbackSubs: Set = new Set(); // Holds all registered Callback based Subscriptions + // Keeps track of all registered Component based Subscriptions + public componentSubs: Set = new Set(); + // Keeps track of all registered Callback based Subscriptions + public callbackSubs: Set = new Set(); - public mountedComponents: Set = new Set(); // Holds all mounted Components (only if agileInstance.config.mount = true) + // Keeps track of all mounted Components (only if agileInstance.config.mount = true) + public mountedComponents: Set = new Set(); /** + * The Subscription Controller manages and simplifies the subscription to UI-Components. + * + * Thus it creates Subscription Containers (Interfaces to UI-Components) + * and assigns them to the specified Observers. + * These Observers can then easily trigger re-renders on UI-Components + * via the created Subscription Containers. + * * @internal - * SubController - Handles subscriptions to Components - * @param agileInstance - An instance of Agile + * @param agileInstance - Instance of Agile the Subscription Controller belongs to. */ public constructor(agileInstance: Agile) { this.agileInstance = () => agileInstance; } - //========================================================================================================= - // Subscribe with Subs Object - //========================================================================================================= /** - * @internal - * Subscribe with Object shaped Subscriptions - * @param integrationInstance - Callback Function or Component - * @param subs - Initial Subscription Object - * @param config - Config + * Creates a so called Subscription Container that represents an UI-Component in AgileTs. + * Such Subscription Container know how to trigger a re-render on the UI-Component it represents + * through the provided `integrationInstance`. + * + * Currently, there are two different ways the Subscription Container can trigger a re-render on the UI-Component. + * - 1. Via a callback function that directly triggers a rerender on the UI-Component. + * (= Callback based Subscription) + * [Learn more..](https://agile-ts.org/docs/core/integration/#callback-based) + * - 2. Via the Component instance itself. + * For example by mutating a local State Management property. + * (= Component based Subscription) + * [Learn more..](https://agile-ts.org/docs/core/integration/#component-based) + * + * The in an array specified Observers are then automatically subscribed + * to the created Subscription Container and thus to the UI-Component it represents. + * + * @public + * @param integrationInstance - Callback function or Component Instance to trigger a rerender on a UI-Component. + * @param subs - Observers to be subscribed to the to create Subscription Container. + * @param config - Configuration object */ - public subscribeWithSubsObject( + public subscribe( integrationInstance: any, - subs: { [key: string]: Observer } = {}, - config: RegisterSubscriptionConfigInterface = {} + subs: Array, + config?: RegisterSubscriptionConfigInterface + ): SubscriptionContainer; + /** + * Creates a so called Subscription Container that represents an UI-Component in AgileTs. + * Such Subscription Container know how to trigger a re-render on the UI-Component it represents + * through the provided `integrationInstance`. + * + * Currently, there are two different ways the Subscription Container can trigger a re-render on the UI-Component. + * - 1. Via a callback function that directly triggers a rerender on the UI-Component. + * (= Callback based Subscription) + * [Learn more..](https://agile-ts.org/docs/core/integration/#callback-based) + * - 2. Via the Component instance itself. + * For example by mutating a local State Management property. + * (= Component based Subscription) + * [Learn more..](https://agile-ts.org/docs/core/integration/#component-based) + * + * The in an object keymap specified Observers are then automatically subscribed + * to the created Subscription Container and thus to the UI-Component it represents. + * + * The advantage of subscribing the Observer via a object keymap, + * is that each Observer has its own unique 'external' key identifier. + * Such key identifier is, for example, required when merging the Observer value into + * a local UI-Component State Management property. + * ``` + * this.state = {...this.state, {state1: Observer1.value, state2: Observer2.value}} + * ``` + * + * @public + * @param integrationInstance - Callback function or Component Instance to trigger a rerender on a UI-Component. + * @param subs - Observers to be subscribed to the to create Subscription Container. + * @param config - Configuration object + */ + public subscribe( + integrationInstance: any, + subs: { [key: string]: Observer }, + config?: RegisterSubscriptionConfigInterface ): { subscriptionContainer: SubscriptionContainer; props: { [key: string]: Observer['value'] }; - } { - const props: { [key: string]: Observer['value'] } = {}; - - // Create subsArray - const subsArray: Observer[] = []; - for (const key in subs) subsArray.push(subs[key]); - - // Register Subscription -> decide weather subscriptionInstance is callback or component based - const subscriptionContainer = this.registerSubscription( - integrationInstance, - subsArray, - config - ); - - // Set SubscriptionContainer to Object based - subscriptionContainer.isObjectBased = true; - subscriptionContainer.subsObject = subs; - - // Register subs and build props object - for (const key in subs) { - const observer = subs[key]; - observer.subscribe(subscriptionContainer); - if (observer.value) props[key] = observer.value; - } - - return { - subscriptionContainer: subscriptionContainer, - props: props, - }; - } - - //========================================================================================================= - // Subscribe with Subs Array - //========================================================================================================= - /** - * @internal - * Subscribe with Array shaped Subscriptions - * @param integrationInstance - Callback Function or Component - * @param subs - Initial Subscription Array - * @param config - Config - */ - public subscribeWithSubsArray( + }; + public subscribe( integrationInstance: any, - subs: Array = [], + subs: { [key: string]: Observer } | Array, config: RegisterSubscriptionConfigInterface = {} - ): SubscriptionContainer { - // Register Subscription -> decide weather subscriptionInstance is callback or component based - const subscriptionContainer = this.registerSubscription( - integrationInstance, - subs, - config - ); + ): + | SubscriptionContainer + | { + subscriptionContainer: SubscriptionContainer; + props: { [key: string]: Observer['value'] }; + } { + config = defineConfig(config, { + waitForMount: this.agileInstance().config.waitForMount, + }); - // Register subs - subs.forEach((observer) => observer.subscribe(subscriptionContainer)); + // Create Subscription Container based on specified 'integrationInstance' + const subscriptionContainer = isFunction(integrationInstance) + ? this.createCallbackSubscriptionContainer( + integrationInstance, + subs, + config + ) + : this.createComponentSubscriptionContainer( + integrationInstance, + subs, + config + ); + + // Return object based Subscription Container and an Observer value keymap + if (subscriptionContainer.isObjectBased && !Array.isArray(subs)) { + const props: { [key: string]: Observer['value'] } = {}; + for (const key in subs) if (subs[key].value) props[key] = subs[key].value; + return { subscriptionContainer, props }; + } + // Return array based Subscription Container return subscriptionContainer; } - //========================================================================================================= - // Unsubscribe - //========================================================================================================= /** - * @internal - * Unsubscribes SubscriptionContainer(Component) - * @param subscriptionInstance - SubscriptionContainer or Component that holds an SubscriptionContainer + * Removes the Subscription Container extracted from the specified 'subscriptionInstance' + * from all Observers that were subscribed to it. + * + * We should always unregister a Subscription Container when it is no longer in use. + * For example, when the UI-Component it represents has been unmounted. + * + * @public + * @param subscriptionInstance - UI-Component that contains an instance of a Subscription Container + * or a Subscription Container to be unsubscribed/unregistered. */ public unsubscribe(subscriptionInstance: any) { - // Helper function to remove SubscriptionContainer from Observer + // Helper function to remove Subscription Container from Observer const unsub = (subscriptionContainer: SubscriptionContainer) => { subscriptionContainer.ready = false; - - // Remove SubscriptionContainers from Observer subscriptionContainer.subscribers.forEach((observer) => { - observer.unsubscribe(subscriptionContainer); + subscriptionContainer.removeSubscription(observer); }); }; - // Unsubscribe callback based Subscription + // Unsubscribe Callback based Subscription Container if (subscriptionInstance instanceof CallbackSubscriptionContainer) { unsub(subscriptionInstance); this.callbackSubs.delete(subscriptionInstance); - Agile.logger.if .tag(['runtime', 'subscription']) .info(LogCodeManager.getLog('15:01:00'), subscriptionInstance); return; } - // Unsubscribe component based Subscription + // Unsubscribe Component based Subscription Container if (subscriptionInstance instanceof ComponentSubscriptionContainer) { unsub(subscriptionInstance); this.componentSubs.delete(subscriptionInstance); - Agile.logger.if .tag(['runtime', 'subscription']) .info(LogCodeManager.getLog('15:01:01'), subscriptionInstance); return; } - // Unsubscribe component based Subscription with subscriptionInstance that holds a componentSubscriptionContainer - if (subscriptionInstance.componentSubscriptionContainer) { - unsub( - subscriptionInstance.componentSubscriptionContainer as ComponentSubscriptionContainer - ); - this.componentSubs.delete( - subscriptionInstance.componentSubscriptionContainer - ); - - Agile.logger.if - .tag(['runtime', 'subscription']) - .info(LogCodeManager.getLog('15:01:01'), subscriptionInstance); - return; - } - - // Unsubscribe component based Subscription with subscriptionInstance that holds componentSubscriptionContainers + // Unsubscribe Component based Subscription Container + // extracted from the 'componentSubscriptionContainers' property if ( - subscriptionInstance.componentSubscriptionContainers && + subscriptionInstance['componentSubscriptionContainers'] !== null && Array.isArray(subscriptionInstance.componentSubscriptionContainers) ) { subscriptionInstance.componentSubscriptionContainers.forEach( (subContainer) => { unsub(subContainer as ComponentSubscriptionContainer); this.componentSubs.delete(subContainer); - Agile.logger.if .tag(['runtime', 'subscription']) .info(LogCodeManager.getLog('15:01:01'), subscriptionInstance); @@ -179,52 +200,18 @@ export class SubController { } } - //========================================================================================================= - // Register Subscription - //========================================================================================================= /** + * Returns a newly created Component based Subscription Container. + * * @internal - * Registers SubscriptionContainer and decides weather integrationInstance is a callback or component based Subscription - * @param integrationInstance - Callback Function or Component - * @param subs - Initial Subscriptions - * @param config - Config + * @param componentInstance - UI-Component to be represented by the Subscription Container + * and mutated via the Integration's 'updateMethod()' method to trigger re-renders on it. + * @param subs - Observers to be initial subscribed to the Subscription Container. + * @param config - Configuration object. */ - public registerSubscription( - integrationInstance: any, - subs: Array = [], - config: RegisterSubscriptionConfigInterface = {} - ): SubscriptionContainer { - config = defineConfig(config, { - waitForMount: this.agileInstance().config.waitForMount, - }); - if (isFunction(integrationInstance)) - return this.registerCallbackSubscription( - integrationInstance, - subs, - config - ); - return this.registerComponentSubscription( - integrationInstance, - subs, - config - ); - } - - //========================================================================================================= - // Register Component Subscription - //========================================================================================================= - /** - * @internal - * Registers Component based Subscription and applies SubscriptionContainer to Component. - * If an instance called 'subscriptionContainers' exists in Component it will push the new SubscriptionContainer to this Array, - * otherwise it creates a new Instance called 'subscriptionContainer' which holds the new SubscriptionContainer - * @param componentInstance - Component that got subscribed by Observer/s - * @param subs - Initial Subscriptions - * @param config - Config - */ - public registerComponentSubscription( + public createComponentSubscriptionContainer( componentInstance: any, - subs: Array = [], + subs: { [key: string]: Observer } | Array, config: RegisterSubscriptionConfigInterface = {} ): ComponentSubscriptionContainer { const componentSubscriptionContainer = new ComponentSubscriptionContainer( @@ -234,22 +221,25 @@ export class SubController { ); this.componentSubs.add(componentSubscriptionContainer); - // Set to ready if not waiting for component to mount + // Define ready state of Subscription Container if (config.waitForMount) { if (this.mountedComponents.has(componentInstance)) componentSubscriptionContainer.ready = true; } else componentSubscriptionContainer.ready = true; - // Add subscriptionContainer to Component, to have an instance of it there (necessary to unsubscribe SubscriptionContainer later) + // Add Subscription Container to the UI-Component it represents. + // (For example, useful to unsubscribe the Subscription Container via the Component Instance) if ( - componentInstance.componentSubscriptionContainers && + componentInstance['componentSubscriptionContainers'] != null && Array.isArray(componentInstance.componentSubscriptionContainers) ) componentInstance.componentSubscriptionContainers.push( componentSubscriptionContainer ); else - componentInstance.componentSubscriptionContainer = componentSubscriptionContainer; + componentInstance['componentSubscriptionContainers'] = [ + componentSubscriptionContainer, + ]; Agile.logger.if .tag(['runtime', 'subscription']) @@ -258,19 +248,18 @@ export class SubController { return componentSubscriptionContainer; } - //========================================================================================================= - // Register Callback Subscription - //========================================================================================================= /** + * Returns a newly created Callback based Subscription Container. + * * @internal - * Registers Callback based Subscription - * @param callbackFunction - Callback Function that causes rerender on Component which got subscribed by Observer/s - * @param subs - Initial Subscriptions - * @param config - Config + * @param callbackFunction - Callback function to cause a rerender on the Component + * to be represented by the Subscription Container. + * @param subs - Observers to be initial subscribed to the Subscription Container. + * @param config - Configuration object */ - public registerCallbackSubscription( + public createCallbackSubscriptionContainer( callbackFunction: () => void, - subs: Array = [], + subs: { [key: string]: Observer } | Array, config: RegisterSubscriptionConfigInterface = {} ): CallbackSubscriptionContainer { const callbackSubscriptionContainer = new CallbackSubscriptionContainer( @@ -288,42 +277,51 @@ export class SubController { return callbackSubscriptionContainer; } - //========================================================================================================= - // Mount - //========================================================================================================= /** - * @internal - * Mounts Component based SubscriptionContainer - * @param componentInstance - SubscriptionContainer(Component) that gets mounted + * Notifies the Subscription Containers representing the specified UI-Component (`componentInstance`) + * that the UI-Component they represent has been mounted. + * + * @public + * @param componentInstance - Component Instance containing Subscription Containers to be mounted. */ public mount(componentInstance: any) { - if (componentInstance.componentSubscriptionContainer) - componentInstance.componentSubscriptionContainer.ready = true; + if ( + componentInstance['componentSubscriptionContainers'] != null && + Array.isArray(componentInstance.componentSubscriptionContainers) + ) + componentInstance.componentSubscriptionContainers.map( + (c) => (c.ready = true) + ); this.mountedComponents.add(componentInstance); } - //========================================================================================================= - // Unmount - //========================================================================================================= /** - * @internal - * Unmounts Component based SubscriptionContainer - * @param componentInstance - SubscriptionContainer(Component) that gets unmounted + * Notifies the Subscription Containers representing the specified UI-Component (`componentInstance`) + * that the UI-Component they represent has been unmounted. + * + * @public + * @param componentInstance - Component Instance containing Subscription Containers to be unmounted */ public unmount(componentInstance: any) { - if (componentInstance.componentSubscriptionContainer) - componentInstance.componentSubscriptionContainer.ready = false; + if ( + componentInstance['componentSubscriptionContainers'] != null && + Array.isArray(componentInstance.componentSubscriptionContainers) + ) + componentInstance.componentSubscriptionContainers.map( + (c) => (c.ready = false) + ); this.mountedComponents.delete(componentInstance); } } -/** - * @param waitForMount - Whether the subscriptionContainer should only become ready - * when the Component has been mounted. (default = agileInstance.config.waitForMount) - */ interface RegisterSubscriptionConfigInterface extends SubscriptionContainerConfigInterface { + /** + * Whether the Subscription Container should not be ready + * until the UI-Component it represents has been mounted. + * @default agileInstance.config.waitForMount + */ waitForMount?: boolean; } diff --git a/packages/core/src/state/index.ts b/packages/core/src/state/index.ts index 0fbe3bb3..1e70c201 100644 --- a/packages/core/src/state/index.ts +++ b/packages/core/src/state/index.ts @@ -74,7 +74,7 @@ export class State { * * @public * @param agileInstance - Instance of Agile the State belongs to. - * @param initialValue - Initial value of State. + * @param initialValue - Initial value of the State. * @param config - Configuration object */ constructor( diff --git a/packages/core/src/state/state.observer.ts b/packages/core/src/state/state.observer.ts index fd0182dc..a8c986dd 100644 --- a/packages/core/src/state/state.observer.ts +++ b/packages/core/src/state/state.observer.ts @@ -14,6 +14,7 @@ import { SideEffectInterface, createArrayFromObject, CreateStateRuntimeJobConfigInterface, + generateId, } from '../internal'; export class StateObserver extends Observer { @@ -100,7 +101,9 @@ export class StateObserver extends Observer { force: config.force, background: config.background, overwrite: config.overwrite, - key: config.key || this._key, + key: + config.key ?? + `${this._key != null ? this._key + '_' : ''}${generateId()}`, }); this.agileInstance().runtime.ingest(job, { diff --git a/packages/core/src/state/state.persistent.ts b/packages/core/src/state/state.persistent.ts index da15578c..89a7063a 100644 --- a/packages/core/src/state/state.persistent.ts +++ b/packages/core/src/state/state.persistent.ts @@ -107,7 +107,10 @@ export class StatePersistent extends Persistent { if (loadedValue == null) return false; // Assign loaded Value to State - this.state().set(loadedValue, { storage: false, overwrite: true }); + this.state().set(loadedValue, { + storage: false, + overwrite: true, + }); // Setup Side Effects to keep the Storage value in sync with the State value this.setupSideEffects(storageItemKey); diff --git a/packages/core/tests/unit/computed/computed.test.ts b/packages/core/tests/unit/computed/computed.test.ts index 9f645220..2dd8a4d3 100644 --- a/packages/core/tests/unit/computed/computed.test.ts +++ b/packages/core/tests/unit/computed/computed.test.ts @@ -232,9 +232,9 @@ describe('Computed Tests', () => { computed.hardCodedDeps = [dummyObserver3]; computed.deps = [dummyObserver3]; // normally the hardCodedDeps get automatically added to the deps.. but this time we set the hardCodedProperty after the instantiation - dummyObserver1.depend = jest.fn(); - dummyObserver2.depend = jest.fn(); - dummyObserver3.depend = jest.fn(); + dummyObserver1.addDependent = jest.fn(); + dummyObserver2.addDependent = jest.fn(); + dummyObserver3.addDependent = jest.fn(); jest.spyOn(ComputedTracker, 'track').mockClear(); // mockClear because otherwise the static mock doesn't get reset after each 'it' test jest.spyOn(ComputedTracker, 'getTrackedObservers').mockClear(); }); @@ -257,9 +257,15 @@ describe('Computed Tests', () => { dummyObserver1, dummyObserver2, ]); - expect(dummyObserver1.depend).toHaveBeenCalledWith(computed.observer); - expect(dummyObserver2.depend).toHaveBeenCalledWith(computed.observer); - expect(dummyObserver3.depend).toHaveBeenCalledWith(computed.observer); + expect(dummyObserver1.addDependent).toHaveBeenCalledWith( + computed.observer + ); + expect(dummyObserver2.addDependent).toHaveBeenCalledWith( + computed.observer + ); + expect(dummyObserver3.addDependent).toHaveBeenCalledWith( + computed.observer + ); }); it("should call computeFunction and shouldn't track dependencies the computeFunction depends on (autodetect false)", () => { @@ -273,9 +279,9 @@ describe('Computed Tests', () => { expect(ComputedTracker.getTrackedObservers).not.toHaveBeenCalled(); expect(computed.hardCodedDeps).toStrictEqual([dummyObserver3]); expect(computed.deps).toStrictEqual([dummyObserver3]); - expect(dummyObserver1.depend).not.toHaveBeenCalled(); - expect(dummyObserver2.depend).not.toHaveBeenCalled(); - expect(dummyObserver3.depend).not.toHaveBeenCalled(); + expect(dummyObserver1.addDependent).not.toHaveBeenCalled(); + expect(dummyObserver2.addDependent).not.toHaveBeenCalled(); + expect(dummyObserver3.addDependent).not.toHaveBeenCalled(); }); }); diff --git a/packages/core/tests/unit/runtime/observer.test.ts b/packages/core/tests/unit/runtime/observer.test.ts index b833ac97..7d246abd 100644 --- a/packages/core/tests/unit/runtime/observer.test.ts +++ b/packages/core/tests/unit/runtime/observer.test.ts @@ -4,6 +4,7 @@ import { SubscriptionContainer, RuntimeJob, } from '../../../src'; +import * as Utils from '@agile-ts/utils'; import { LogMock } from '../../helper/logMock'; describe('Observer Tests', () => { @@ -20,20 +21,22 @@ describe('Observer Tests', () => { dummyAgile = new Agile(); dummyObserver1 = new Observer(dummyAgile, { key: 'dummyObserver1' }); dummyObserver2 = new Observer(dummyAgile, { key: 'dummyObserver2' }); - dummySubscription1 = new SubscriptionContainer(); - dummySubscription2 = new SubscriptionContainer(); + dummySubscription1 = new SubscriptionContainer([]); + dummySubscription2 = new SubscriptionContainer([]); - jest.spyOn(Observer.prototype, 'subscribe'); + jest.spyOn(dummySubscription1, 'addSubscription'); + jest.spyOn(dummySubscription2, 'addSubscription'); }); it('should create Observer (default config)', () => { const observer = new Observer(dummyAgile); + expect(observer.agileInstance()).toBe(dummyAgile); expect(observer._key).toBeUndefined(); + expect(Array.from(observer.dependents)).toStrictEqual([]); + expect(Array.from(observer.subscribedTo)).toStrictEqual([]); expect(observer.value).toBeUndefined(); expect(observer.previousValue).toBeUndefined(); - expect(observer.dependents.size).toBe(0); - expect(observer.subscribedTo.size).toBe(0); }); it('should create Observer (specific config)', () => { @@ -44,18 +47,21 @@ describe('Observer Tests', () => { value: 'coolValue', }); + expect(observer.agileInstance()).toBe(dummyAgile); expect(observer._key).toBe('testKey'); + expect(Array.from(observer.dependents)).toStrictEqual([ + dummyObserver1, + dummyObserver2, + ]); + expect(Array.from(observer.subscribedTo)).toStrictEqual([ + dummySubscription1, + dummySubscription2, + ]); expect(observer.value).toBe('coolValue'); expect(observer.previousValue).toBe('coolValue'); - expect(observer.dependents.size).toBe(2); - expect(observer.dependents.has(dummyObserver2)).toBeTruthy(); - expect(observer.dependents.has(dummyObserver1)).toBeTruthy(); - expect(observer.subscribedTo.size).toBe(2); - expect(observer.subscribedTo.has(dummySubscription1)).toBeTruthy(); - expect(observer.subscribedTo.has(dummySubscription2)).toBeTruthy(); - - expect(observer.subscribe).toHaveBeenCalledWith(dummySubscription1); - expect(observer.subscribe).toHaveBeenCalledWith(dummySubscription2); + + expect(dummySubscription1.addSubscription).toHaveBeenCalledWith(observer); + expect(dummySubscription2.addSubscription).toHaveBeenCalledWith(observer); }); describe('Observer Function Tests', () => { @@ -82,9 +88,11 @@ describe('Observer Tests', () => { }); describe('ingest function tests', () => { - it('should create RuntimeJob and ingest Observer into the Runtime (default config)', () => { + it('should create RuntimeJob containing the Observer and ingest it into the Runtime (default config)', () => { + jest.spyOn(Utils, 'generateId').mockReturnValueOnce('generatedKey'); + dummyAgile.runtime.ingest = jest.fn((job: RuntimeJob) => { - expect(job._key).toBe(observer._key); + expect(job._key).toBe(`${observer._key}_generatedKey`); expect(job.observer).toBe(observer); expect(job.config).toStrictEqual({ background: false, @@ -93,7 +101,7 @@ describe('Observer Tests', () => { exclude: [], }, force: false, - numberOfTriesToUpdate: 3, + maxTriesToUpdate: 3, }); }); @@ -107,7 +115,7 @@ describe('Observer Tests', () => { ); }); - it('should create RuntimeJob and ingest Observer into the Runtime (specific config)', () => { + it('should create RuntimeJob containing the Observer and ingest it into the Runtime (specific config)', () => { dummyAgile.runtime.ingest = jest.fn((job: RuntimeJob) => { expect(job._key).toBe('coolKey'); expect(job.observer).toBe(observer); @@ -118,7 +126,7 @@ describe('Observer Tests', () => { exclude: [], }, force: true, - numberOfTriesToUpdate: 3, + maxTriesToUpdate: 3, }); }); @@ -148,7 +156,7 @@ describe('Observer Tests', () => { }); }); - describe('depend function tests', () => { + describe('addDependent function tests', () => { let dummyObserver1: Observer; let dummyObserver2: Observer; @@ -157,77 +165,21 @@ describe('Observer Tests', () => { dummyObserver2 = new Observer(dummyAgile, { key: 'dummyObserver2' }); }); - it('should add passed Observer to deps', () => { - observer.depend(dummyObserver1); + it('should add specified Observer to the dependents array', () => { + observer.addDependent(dummyObserver1); expect(observer.dependents.size).toBe(1); expect(observer.dependents.has(dummyObserver2)); }); - it("shouldn't add the same Observer twice to deps", () => { - observer.depend(dummyObserver1); + it("shouldn't add specified Observer twice to the dependents array", () => { + observer.addDependent(dummyObserver1); - observer.depend(dummyObserver1); + observer.addDependent(dummyObserver1); expect(observer.dependents.size).toBe(1); expect(observer.dependents.has(dummyObserver1)); }); }); - - describe('subscribe function tests', () => { - let dummySubscriptionContainer1: SubscriptionContainer; - - beforeEach(() => { - dummySubscriptionContainer1 = new SubscriptionContainer(); - }); - - it('should add subscriptionContainer to subs and this(Observer) to SubscriptionContainer subs', () => { - observer.subscribe(dummySubscriptionContainer1); - - expect(observer.subscribedTo.size).toBe(1); - expect(observer.subscribedTo.has(dummySubscriptionContainer1)); - expect(dummySubscriptionContainer1.subscribers.size).toBe(1); - expect( - dummySubscriptionContainer1.subscribers.has(observer) - ).toBeTruthy(); - }); - - it("shouldn't add same subscriptionContainer twice to subs", () => { - observer.subscribe(dummySubscriptionContainer1); - - observer.subscribe(dummySubscriptionContainer1); - - expect(observer.subscribedTo.size).toBe(1); - expect(observer.subscribedTo.has(dummySubscriptionContainer1)); - expect(dummySubscriptionContainer1.subscribers.size).toBe(1); - expect( - dummySubscriptionContainer1.subscribers.has(observer) - ).toBeTruthy(); - }); - }); - - describe('unsubscribe function tests', () => { - let dummySubscriptionContainer1: SubscriptionContainer; - let dummySubscriptionContainer2: SubscriptionContainer; - - beforeEach(() => { - dummySubscriptionContainer1 = new SubscriptionContainer(); - dummySubscriptionContainer2 = new SubscriptionContainer(); - observer.subscribe(dummySubscriptionContainer1); - observer.subscribe(dummySubscriptionContainer2); - }); - - it('should remove subscriptionContainer from subs and this(Observer) from SubscriptionContainer subs', () => { - observer.unsubscribe(dummySubscriptionContainer1); - - expect(observer.subscribedTo.size).toBe(1); - expect(observer.subscribedTo.has(dummySubscriptionContainer1)); - expect(dummySubscriptionContainer1.subscribers.size).toBe(0); - expect(dummySubscriptionContainer2.subscribers.size).toBe(1); - expect( - dummySubscriptionContainer2.subscribers.has(observer) - ).toBeTruthy(); - }); - }); }); }); diff --git a/packages/core/tests/unit/runtime/runtime.job.test.ts b/packages/core/tests/unit/runtime/runtime.job.test.ts index e9fd39df..e8989d14 100644 --- a/packages/core/tests/unit/runtime/runtime.job.test.ts +++ b/packages/core/tests/unit/runtime/runtime.job.test.ts @@ -17,7 +17,7 @@ describe('RuntimeJob Tests', () => { dummyObserver = new Observer(dummyAgile); }); - it('should create RuntimeJob with Agile that has integrations (default config)', () => { + it('should create RuntimeJob with a specified Agile Instance that has a registered Integration (default config)', () => { dummyAgile.integrate(dummyIntegration); const job = new RuntimeJob(dummyObserver); @@ -31,24 +31,25 @@ describe('RuntimeJob Tests', () => { exclude: [], }, force: false, - numberOfTriesToUpdate: 3, + maxTriesToUpdate: 3, }); expect(job.rerender).toBeTruthy(); expect(job.performed).toBeFalsy(); - expect(job.subscriptionContainersToUpdate.size).toBe(0); - expect(job.triesToUpdate).toBe(0); + expect(Array.from(job.subscriptionContainersToUpdate)).toStrictEqual([]); + expect(job.triedToUpdateCount).toBe(0); }); - it('should create RuntimeJob with Agile that has integrations (specific config)', () => { + it('should create RuntimeJob with a specified Agile Instance that has a registered Integration (specific config)', () => { dummyAgile.integrate(dummyIntegration); const job = new RuntimeJob(dummyObserver, { key: 'dummyJob', sideEffects: { enabled: false, + exclude: ['jeff'], }, force: true, - numberOfTriesToUpdate: 10, + maxTriesToUpdate: 10, }); expect(job._key).toBe('dummyJob'); @@ -57,16 +58,17 @@ describe('RuntimeJob Tests', () => { background: false, sideEffects: { enabled: false, + exclude: ['jeff'], }, force: true, - numberOfTriesToUpdate: 10, + maxTriesToUpdate: 10, }); expect(job.rerender).toBeTruthy(); expect(job.performed).toBeFalsy(); - expect(job.subscriptionContainersToUpdate.size).toBe(0); + expect(Array.from(job.subscriptionContainersToUpdate)).toStrictEqual([]); }); - it('should create RuntimeJob with Agile that has no integrations (default config)', () => { + it('should create RuntimeJob with a specified Agile Instance that has no registered Integration (default config)', () => { const job = new RuntimeJob(dummyObserver); expect(job._key).toBeUndefined(); @@ -78,14 +80,14 @@ describe('RuntimeJob Tests', () => { exclude: [], }, force: false, - numberOfTriesToUpdate: 3, + maxTriesToUpdate: 3, }); expect(job.rerender).toBeFalsy(); expect(job.performed).toBeFalsy(); - expect(job.subscriptionContainersToUpdate.size).toBe(0); + expect(Array.from(job.subscriptionContainersToUpdate)).toStrictEqual([]); }); - it('should create RuntimeJob and Agile that has integrations (config.background = true)', () => { + it('should create RuntimeJob with a specified Agile Instance that has a registered Integrations (config.background = true)', () => { dummyAgile.integrate(dummyIntegration); const job = new RuntimeJob(dummyObserver, { background: true }); @@ -99,11 +101,11 @@ describe('RuntimeJob Tests', () => { exclude: [], }, force: false, - numberOfTriesToUpdate: 3, + maxTriesToUpdate: 3, }); expect(job.rerender).toBeFalsy(); expect(job.performed).toBeFalsy(); - expect(job.subscriptionContainersToUpdate.size).toBe(0); + expect(Array.from(job.subscriptionContainersToUpdate)).toStrictEqual([]); }); describe('RuntimeJob Function Tests', () => { diff --git a/packages/core/tests/unit/runtime/runtime.test.ts b/packages/core/tests/unit/runtime/runtime.test.ts index 95781f72..b0814322 100644 --- a/packages/core/tests/unit/runtime/runtime.test.ts +++ b/packages/core/tests/unit/runtime/runtime.test.ts @@ -8,7 +8,6 @@ import { SubscriptionContainer, } from '../../../src'; import * as Utils from '@agile-ts/utils'; -import testIntegration from '../../helper/test.integration'; import { LogMock } from '../../helper/logMock'; describe('Runtime Tests', () => { @@ -24,10 +23,12 @@ describe('Runtime Tests', () => { it('should create Runtime', () => { const runtime = new Runtime(dummyAgile); + expect(runtime.agileInstance()).toBe(dummyAgile); expect(runtime.currentJob).toBeNull(); expect(runtime.jobQueue).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(0); expect(runtime.jobsToRerender).toStrictEqual([]); + expect(Array.from(runtime.notReadyJobsToRerender)).toStrictEqual([]); + expect(runtime.isPerformingJobs).toBeFalsy(); }); describe('Runtime Function Tests', () => { @@ -52,18 +53,37 @@ describe('Runtime Tests', () => { runtime.perform = jest.fn(); }); - it('should perform passed Job (default config)', () => { + it("should perform specified Job immediately if jobQueue isn't being processed (default config)", () => { + runtime.isPerformingJobs = false; + runtime.ingest(dummyJob); - expect(runtime.jobQueue.length).toBe(0); + expect(runtime.jobQueue).toStrictEqual([]); expect(runtime.perform).toHaveBeenCalledWith(dummyJob); }); - it("shouldn't perform passed Job (config.perform = false)", () => { + it("shouldn't perform specified Job immediately if jobQueue is being processed (default config)", () => { + runtime.isPerformingJobs = true; + + runtime.ingest(dummyJob); + + expect(runtime.jobQueue).toStrictEqual([dummyJob]); + expect(runtime.perform).not.toHaveBeenCalled(); + }); + + it('should perform specified Job immediately (config.perform = true)', () => { + runtime.isPerformingJobs = true; + runtime.ingest(dummyJob, { perform: true }); + + expect(runtime.jobQueue).toStrictEqual([]); + expect(runtime.perform).toHaveBeenCalledWith(dummyJob); + }); + + it("shouldn't perform specified Job immediately (config.perform = false)", () => { + runtime.isPerformingJobs = false; runtime.ingest(dummyJob, { perform: false }); - expect(runtime.jobQueue.length).toBe(1); - expect(runtime.jobQueue[0]).toBe(dummyJob); + expect(runtime.jobQueue).toStrictEqual([dummyJob]); expect(runtime.perform).not.toHaveBeenCalled(); }); }); @@ -88,32 +108,34 @@ describe('Runtime Tests', () => { dummyObserver2.ingest = jest.fn(); }); - it('should perform passed and all in jobQueue remaining Jobs and call updateSubscribers', async () => { - runtime.jobQueue.push(dummyJob2); - runtime.jobQueue.push(dummyJob3); + it( + "should perform specified Job and all remaining Jobs in the 'jobQueue' " + + "and call 'updateSubscribers' if at least one performed Job needs to rerender", + async () => { + runtime.jobQueue.push(dummyJob2); + runtime.jobQueue.push(dummyJob3); - runtime.perform(dummyJob1); + runtime.perform(dummyJob1); - expect(dummyObserver1.perform).toHaveBeenCalledWith(dummyJob1); - expect(dummyJob1.performed).toBeTruthy(); - expect(dummyObserver2.perform).toHaveBeenCalledWith(dummyJob2); - expect(dummyJob2.performed).toBeTruthy(); - expect(dummyObserver1.perform).toHaveBeenCalledWith(dummyJob3); - expect(dummyJob3.performed).toBeTruthy(); + expect(dummyObserver1.perform).toHaveBeenCalledWith(dummyJob1); + expect(dummyJob1.performed).toBeTruthy(); + expect(dummyObserver2.perform).toHaveBeenCalledWith(dummyJob2); + expect(dummyJob2.performed).toBeTruthy(); + expect(dummyObserver1.perform).toHaveBeenCalledWith(dummyJob3); + expect(dummyJob3.performed).toBeTruthy(); - expect(runtime.jobQueue.length).toBe(0); - expect(runtime.jobsToRerender.length).toBe(2); - expect(runtime.jobsToRerender.includes(dummyJob1)).toBeTruthy(); - expect(runtime.jobsToRerender.includes(dummyJob2)).toBeTruthy(); - expect(runtime.jobsToRerender.includes(dummyJob3)).toBeFalsy(); + expect(runtime.isPerformingJobs).toBeFalsy(); // because Jobs were performed + expect(runtime.jobQueue).toStrictEqual([]); + expect(runtime.jobsToRerender).toStrictEqual([dummyJob1, dummyJob2]); - // Sleep 5ms because updateSubscribers get called in Timeout - await new Promise((resolve) => setTimeout(resolve, 5)); + // Sleep 5ms because updateSubscribers is called in a timeout + await new Promise((resolve) => setTimeout(resolve, 5)); - expect(runtime.updateSubscribers).toHaveBeenCalledTimes(1); - }); + expect(runtime.updateSubscribers).toHaveBeenCalledTimes(1); + } + ); - it('should perform passed Job and update it dependents', async () => { + it('should perform specified Job and ingest its dependents into the runtime', async () => { dummyJob1.observer.dependents.add(dummyObserver2); dummyJob1.observer.dependents.add(dummyObserver1); @@ -122,139 +144,90 @@ describe('Runtime Tests', () => { expect(dummyObserver1.perform).toHaveBeenCalledWith(dummyJob1); expect(dummyJob1.performed).toBeTruthy(); + expect(dummyObserver1.ingest).toHaveBeenCalledTimes(1); expect(dummyObserver1.ingest).toHaveBeenCalledWith({ perform: false, }); - expect(dummyObserver1.ingest).toHaveBeenCalledTimes(1); + expect(dummyObserver2.ingest).toHaveBeenCalledTimes(1); expect(dummyObserver2.ingest).toHaveBeenCalledWith({ perform: false, }); - expect(dummyObserver2.ingest).toHaveBeenCalledTimes(1); }); - it("should perform passed and all in jobQueue remaining Jobs and shouldn't call updateSubscribes if no job needs to rerender", async () => { - dummyJob1.rerender = false; - runtime.jobQueue.push(dummyJob3); + it( + "should perform specified Job and all remaining Jobs in the 'jobQueue' " + + "and shouldn't call 'updateSubscribes' if no performed Job needs to rerender", + async () => { + dummyJob1.rerender = false; + runtime.jobQueue.push(dummyJob3); - runtime.perform(dummyJob1); + runtime.perform(dummyJob1); - expect(dummyObserver1.perform).toHaveBeenCalledWith(dummyJob1); - expect(dummyJob1.performed).toBeTruthy(); - expect(dummyObserver1.perform).toHaveBeenCalledWith(dummyJob3); - expect(dummyJob3.performed).toBeTruthy(); + expect(dummyObserver1.perform).toHaveBeenCalledWith(dummyJob1); + expect(dummyJob1.performed).toBeTruthy(); + expect(dummyObserver1.perform).toHaveBeenCalledWith(dummyJob3); + expect(dummyJob3.performed).toBeTruthy(); - expect(runtime.jobQueue.length).toBe(0); - expect(runtime.jobsToRerender.length).toBe(0); + expect(runtime.isPerformingJobs).toBeFalsy(); // because Jobs were performed + expect(runtime.jobQueue).toStrictEqual([]); + expect(runtime.jobsToRerender).toStrictEqual([]); - // Sleep 5ms because updateSubscribers get called in Timeout - await new Promise((resolve) => setTimeout(resolve, 5)); + // Sleep 5ms because updateSubscribers is called in a timeout + await new Promise((resolve) => setTimeout(resolve, 5)); - expect(runtime.updateSubscribers).not.toHaveBeenCalled(); - }); + expect(runtime.updateSubscribers).not.toHaveBeenCalled(); + } + ); }); describe('updateSubscribers function tests', () => { - let dummyObserver4: Observer; - let rCallbackSubJob: RuntimeJob; - let nrArCallbackSubJob: RuntimeJob; - let rComponentSubJob: RuntimeJob; - let nrArComponentSubJob: RuntimeJob; - let rCallbackSubContainer: CallbackSubscriptionContainer; - const rCallbackSubContainerCallbackFunction = () => { - /* empty function */ - }; - let nrCallbackSubContainer: CallbackSubscriptionContainer; - const nrCallbackSubContainerCallbackFunction = () => { + let dummyJob1: RuntimeJob; + let dummyJob2: RuntimeJob; + let dummyJob3: RuntimeJob; + const dummySubscriptionContainer1IntegrationInstance = () => { /* empty function */ }; - let rComponentSubContainer: ComponentSubscriptionContainer; - const rComponentSubContainerComponent = { + let dummySubscriptionContainer1: SubscriptionContainer; + const dummySubscriptionContainer2IntegrationInstance = { my: 'cool component', }; - let nrComponentSubContainer: ComponentSubscriptionContainer; - const nrComponentSubContainerComponent = { - my: 'second cool component', - }; - const dummyProxyKeyMap = { myState: { paths: [['a', 'b']] } }; + let dummySubscriptionContainer2: SubscriptionContainer; beforeEach(() => { - dummyAgile.integrate(testIntegration); - dummyObserver4 = new Observer(dummyAgile, { key: 'dummyObserver4' }); - - dummyObserver1.value = 'dummyObserverValue1'; - dummyObserver2.value = 'dummyObserverValue2'; - dummyObserver3.value = 'dummyObserverValue3'; - dummyObserver4.value = 'dummyObserverValue4'; - - // Create Ready Callback Subscription - rCallbackSubContainer = dummyAgile.subController.subscribeWithSubsArray( - rCallbackSubContainerCallbackFunction, - [dummyObserver1, dummyObserver2] - ) as CallbackSubscriptionContainer; - rCallbackSubContainer.callback = jest.fn(); - rCallbackSubContainer.ready = true; - rCallbackSubContainer.key = 'rCallbackSubContainerKey'; + dummySubscriptionContainer1 = dummyAgile.subController.subscribe( + dummySubscriptionContainer1IntegrationInstance, + [dummyObserver1] + ); + dummySubscriptionContainer2 = dummyAgile.subController.subscribe( + dummySubscriptionContainer2IntegrationInstance, + [dummyObserver2, dummyObserver3] + ); - // Create Not Ready Callback Subscription - nrCallbackSubContainer = dummyAgile.subController.subscribeWithSubsArray( - nrCallbackSubContainerCallbackFunction, - [dummyObserver2] - ) as CallbackSubscriptionContainer; - nrCallbackSubContainer.callback = jest.fn(); - nrCallbackSubContainer.ready = false; - nrCallbackSubContainer.key = 'nrCallbackSubContainerKey'; + dummyJob1 = new RuntimeJob(dummyObserver1); + dummyJob2 = new RuntimeJob(dummyObserver2); + dummyJob3 = new RuntimeJob(dummyObserver3); - // Create Ready Component Subscription - rComponentSubContainer = dummyAgile.subController.subscribeWithSubsObject( - rComponentSubContainerComponent, - { - observer3: dummyObserver3, - observer4: dummyObserver4, - } - ).subscriptionContainer as ComponentSubscriptionContainer; - rComponentSubContainer.ready = true; - rComponentSubContainer.key = 'rComponentSubContainerKey'; - - // Create Not Ready Component Subscription - nrComponentSubContainer = dummyAgile.subController.subscribeWithSubsObject( - nrComponentSubContainerComponent, - { - observer4: dummyObserver4, - } - ).subscriptionContainer as ComponentSubscriptionContainer; - nrComponentSubContainer.ready = false; - nrComponentSubContainer.key = 'nrComponentSubContainerKey'; - - rComponentSubJob = new RuntimeJob(dummyObserver3, { key: 'dummyJob3' }); // Job with ready Component Subscription - rCallbackSubJob = new RuntimeJob(dummyObserver1, { key: 'dummyJob1' }); // Job with ready CallbackSubscription - nrArComponentSubJob = new RuntimeJob(dummyObserver4, { - key: 'dummyJob4', - }); // Job with not ready and ready Component Subscription - nrArCallbackSubJob = new RuntimeJob(dummyObserver2, { - key: 'dummyJob2', - }); // Job with not ready and ready Callback Subscription - - jest.spyOn(dummyAgile.integrations, 'update'); - jest.spyOn(runtime, 'handleObjectBasedSubscription'); - jest.spyOn(runtime, 'handleProxyBasedSubscription'); + runtime.updateSubscriptionContainer = jest.fn(); + jest.spyOn(runtime, 'extractToUpdateSubscriptionContainer'); }); - it('should return false if agile has no integration', () => { + it('should return false if Agile has no registered Integration', () => { dummyAgile.hasIntegration = jest.fn(() => false); - runtime.jobsToRerender.push(rCallbackSubJob); - runtime.jobsToRerender.push(nrArCallbackSubJob); + runtime.jobsToRerender = [dummyJob1, dummyJob2]; const response = runtime.updateSubscribers(); expect(response).toBeFalsy(); + expect(runtime.jobsToRerender).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(0); - expect(dummyAgile.integrations.update).not.toHaveBeenCalled(); - expect(rCallbackSubContainer.callback).not.toHaveBeenCalled(); - expect(nrCallbackSubContainer.callback).not.toHaveBeenCalled(); + expect(Array.from(runtime.notReadyJobsToRerender)).toStrictEqual([]); + expect( + runtime.extractToUpdateSubscriptionContainer + ).not.toHaveBeenCalled(); + expect(runtime.updateSubscriptionContainer).not.toHaveBeenCalled(); }); - it('should return false if no Jobs in jobsToRerender and notReadyJobsToRerender left', () => { + it('should return false if jobsToRerender and notReadyJobsToRerender queue are both empty', () => { dummyAgile.hasIntegration = jest.fn(() => true); runtime.jobsToRerender = []; runtime.notReadyJobsToRerender = new Set(); @@ -262,358 +235,425 @@ describe('Runtime Tests', () => { const response = runtime.updateSubscribers(); expect(response).toBeFalsy(); - }); - - it('should update ready component based SubscriptionContainer', () => { - dummyAgile.hasIntegration = jest.fn(() => true); - runtime.jobsToRerender.push(rComponentSubJob); - - const response = runtime.updateSubscribers(); - - expect(runtime.jobsToRerender).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(0); - expect(runtime.handleProxyBasedSubscription).not.toHaveBeenCalled(); - - expect(dummyAgile.integrations.update).toHaveBeenCalledTimes(1); - expect(dummyAgile.integrations.update).toHaveBeenCalledWith( - rComponentSubContainerComponent, - { - observer3: 'dummyObserverValue3', - } - ); - expect(runtime.handleObjectBasedSubscription).toHaveBeenCalledWith( - rComponentSubContainer, - rComponentSubJob - ); - expect(rComponentSubJob.subscriptionContainersToUpdate.size).toBe(0); - expect(dummyObserver3.subscribedTo.size).toBe(1); - - expect(response).toBeTruthy(); - }); - - it('should update ready callback based SubscriptionContainer', () => { - dummyAgile.hasIntegration = jest.fn(() => true); - runtime.jobsToRerender.push(rCallbackSubJob); - - const response = runtime.updateSubscribers(); + expect(response).toBeFalsy(); expect(runtime.jobsToRerender).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(0); - expect(runtime.handleProxyBasedSubscription).not.toHaveBeenCalled(); - - expect(rCallbackSubContainer.callback).toHaveBeenCalledTimes(1); - expect(rCallbackSubJob.subscriptionContainersToUpdate.size).toBe(0); - expect(dummyObserver1.subscribedTo.size).toBe(1); - - expect(response).toBeTruthy(); + expect(Array.from(runtime.notReadyJobsToRerender)).toStrictEqual([]); + expect( + runtime.extractToUpdateSubscriptionContainer + ).not.toHaveBeenCalled(); + expect(runtime.updateSubscriptionContainer).not.toHaveBeenCalled(); }); - it('should update ready proxy, callback based SubscriptionContainer if handleProxyBasedSubscriptions() returns true', () => { - jest - .spyOn(runtime, 'handleProxyBasedSubscription') - .mockReturnValueOnce(true); + it('should return false if no Subscription Container of the Jobs to rerender queue needs to update', () => { dummyAgile.hasIntegration = jest.fn(() => true); - rCallbackSubContainer.proxyBased = true; - rCallbackSubContainer.proxyKeyMap = dummyProxyKeyMap; - runtime.jobsToRerender.push(rCallbackSubJob); - - const response = runtime.updateSubscribers(); - - expect(runtime.jobsToRerender).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(0); - expect(runtime.handleProxyBasedSubscription).toHaveBeenCalledWith( - rCallbackSubContainer, - rCallbackSubJob - ); - - expect(rCallbackSubContainer.callback).toHaveBeenCalledTimes(1); - expect(rCallbackSubJob.subscriptionContainersToUpdate.size).toBe(0); - expect(dummyObserver1.subscribedTo.size).toBe(1); - - expect(response).toBeTruthy(); - }); - - it("shouldn't update ready proxy, callback based SubscriptionContainer if handleProxyBasedSubscriptions() returns false", () => { jest - .spyOn(runtime, 'handleProxyBasedSubscription') - .mockReturnValueOnce(false); - dummyAgile.hasIntegration = jest.fn(() => true); - rCallbackSubContainer.proxyBased = true; - rCallbackSubContainer.proxyKeyMap = dummyProxyKeyMap; - runtime.jobsToRerender.push(rCallbackSubJob); + .spyOn(runtime, 'extractToUpdateSubscriptionContainer') + .mockReturnValueOnce([]); + runtime.jobsToRerender = [dummyJob1, dummyJob2]; + runtime.notReadyJobsToRerender = new Set([dummyJob3]); const response = runtime.updateSubscribers(); - expect(runtime.jobsToRerender).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(0); - expect(runtime.handleProxyBasedSubscription).toHaveBeenCalledWith( - rCallbackSubContainer, - rCallbackSubJob - ); - - expect(rCallbackSubContainer.callback).not.toHaveBeenCalled(); - expect(rCallbackSubJob.subscriptionContainersToUpdate.size).toBe(0); - expect(dummyObserver1.subscribedTo.size).toBe(1); - expect(response).toBeFalsy(); + expect(runtime.jobsToRerender).toStrictEqual([]); + expect(Array.from(runtime.notReadyJobsToRerender)).toStrictEqual([]); + expect( + runtime.extractToUpdateSubscriptionContainer + ).toHaveBeenCalledWith([dummyJob1, dummyJob2, dummyJob3]); + expect(runtime.updateSubscriptionContainer).not.toHaveBeenCalled(); }); - it("shouldn't update not ready SubscriptionContainers but it should update ready SubscriptionContainers", () => { + it('should return true if at least one Subscription Container of the Jobs to rerender queue needs to update', () => { dummyAgile.hasIntegration = jest.fn(() => true); - runtime.jobsToRerender.push(nrArCallbackSubJob); - runtime.jobsToRerender.push(nrArComponentSubJob); + jest + .spyOn(runtime, 'extractToUpdateSubscriptionContainer') + .mockReturnValueOnce([ + dummySubscriptionContainer1, + dummySubscriptionContainer2, + ]); + runtime.jobsToRerender = [dummyJob1, dummyJob2]; + runtime.notReadyJobsToRerender = new Set([dummyJob3]); const response = runtime.updateSubscribers(); + expect(response).toBeTruthy(); expect(runtime.jobsToRerender).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(2); - expect( - runtime.notReadyJobsToRerender.has(nrArCallbackSubJob) - ).toBeTruthy(); + expect(Array.from(runtime.notReadyJobsToRerender)).toStrictEqual([]); expect( - runtime.notReadyJobsToRerender.has(nrArComponentSubJob) - ).toBeTruthy(); - - expect(nrArCallbackSubJob.subscriptionContainersToUpdate.size).toBe(1); - expect( - nrArCallbackSubJob.subscriptionContainersToUpdate.has( - nrCallbackSubContainer - ) - ).toBeTruthy(); - expect(nrArComponentSubJob.subscriptionContainersToUpdate.size).toBe(1); - expect( - nrArComponentSubJob.subscriptionContainersToUpdate.has( - nrComponentSubContainer - ) - ).toBeTruthy(); - - expect(rCallbackSubContainer.callback).toHaveBeenCalledTimes(1); - expect(nrCallbackSubContainer.callback).not.toHaveBeenCalled(); - - expect(dummyAgile.integrations.update).toHaveBeenCalledTimes(1); - expect(dummyAgile.integrations.update).toHaveBeenCalledWith( - rComponentSubContainerComponent, - { - observer4: 'dummyObserverValue4', - } - ); - expect(dummyAgile.integrations.update).not.toHaveBeenCalledWith( - nrComponentSubContainerComponent, - { - observer4: 'dummyObserverValue4', - } - ); - - expect(dummyObserver2.subscribedTo.size).toBe(2); - expect(dummyObserver4.subscribedTo.size).toBe(2); - - expect(runtime.handleObjectBasedSubscription).toHaveBeenCalledWith( - rComponentSubContainer, - nrArComponentSubJob - ); - expect(runtime.handleObjectBasedSubscription).not.toHaveBeenCalledWith( - nrComponentSubContainer, - nrArComponentSubJob - ); + runtime.extractToUpdateSubscriptionContainer + ).toHaveBeenCalledWith([dummyJob1, dummyJob2, dummyJob3]); + expect(runtime.updateSubscriptionContainer).toHaveBeenCalledWith([ + dummySubscriptionContainer1, + dummySubscriptionContainer2, + ]); + }); + }); - expect(nrArComponentSubJob.triesToUpdate).toBe(1); - expect(nrArCallbackSubJob.triesToUpdate).toBe(1); + describe('extractToUpdateSubscriptionContainer function tests', () => { + let dummyJob1: RuntimeJob; + let dummyJob2: RuntimeJob; + const dummySubscriptionContainer1IntegrationInstance = () => { + /* empty function */ + }; + let dummySubscriptionContainer1: SubscriptionContainer; + const dummySubscriptionContainer2IntegrationInstance = { + my: 'cool component', + }; + let dummySubscriptionContainer2: SubscriptionContainer; - LogMock.hasLoggedCode( - '16:02:00', - [nrCallbackSubContainer.key], - nrCallbackSubContainer + beforeEach(() => { + dummySubscriptionContainer1 = dummyAgile.subController.subscribe( + dummySubscriptionContainer1IntegrationInstance, + [dummyObserver1] ); - LogMock.hasLoggedCode( - '16:02:00', - [nrComponentSubContainer.key], - nrComponentSubContainer + dummySubscriptionContainer2 = dummyAgile.subController.subscribe( + dummySubscriptionContainer2IntegrationInstance, + [dummyObserver2] ); - expect(response).toBeTruthy(); // because 2 SubscriptionContainer were ready - }); - - it('should try to update in the past not ready SubscriptionContainers from the notReadyJobsToUpdate queue', () => { - dummyAgile.hasIntegration = jest.fn(() => true); - runtime.notReadyJobsToRerender.add(rCallbackSubJob); - - const response = runtime.updateSubscribers(); - - expect(runtime.jobsToRerender).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(0); - - expect(rCallbackSubContainer.callback).toHaveBeenCalled(); - expect(rCallbackSubJob.subscriptionContainersToUpdate.size).toBe(0); - expect(dummyObserver1.subscribedTo.size).toBe(1); + dummyJob1 = new RuntimeJob(dummyObserver1); + dummyJob2 = new RuntimeJob(dummyObserver2); - expect(response).toBeTruthy(); + jest.spyOn(runtime, 'handleSelectors'); }); it( - "shouldn't update not ready SubscriptionContainers from the notReadyJobsToUpdate queue " + - 'and completely remove them from the runtime when it exceeded numberOfTriesToUpdate', + "shouldn't extract not ready Subscription Container from the specified Jobs, " + + "should add it to the 'notReadyJobsToRerender' queue and print a warning", () => { - dummyAgile.hasIntegration = jest.fn(() => true); - rCallbackSubJob.config.numberOfTriesToUpdate = 2; - rCallbackSubJob.triesToUpdate = 2; - rCallbackSubContainer.ready = false; - runtime.notReadyJobsToRerender.add(rCallbackSubJob); + jest + .spyOn(runtime, 'handleSelectors') + .mockReturnValueOnce(true) + .mockReturnValueOnce(true); + dummySubscriptionContainer1.ready = true; + dummySubscriptionContainer2.ready = false; + + const response = runtime.extractToUpdateSubscriptionContainer([ + dummyJob1, + dummyJob2, + ]); + + expect(response).toStrictEqual([dummySubscriptionContainer1]); + + // Called with Job that ran through + expect(runtime.handleSelectors).toHaveBeenCalledTimes(1); + expect(runtime.handleSelectors).toHaveBeenCalledWith( + dummySubscriptionContainer1, + dummyJob1 + ); - const response = runtime.updateSubscribers(); + expect(Array.from(runtime.notReadyJobsToRerender)).toStrictEqual([ + dummyJob2, + ]); - expect(runtime.jobsToRerender).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(0); + // Job that ran through + expect( + Array.from(dummyJob1.subscriptionContainersToUpdate) + ).toStrictEqual([]); + expect(dummyJob1.triedToUpdateCount).toBe(0); + expect( + Array.from(dummySubscriptionContainer1.updatedSubscribers) + ).toStrictEqual([dummyObserver1]); - expect(rCallbackSubContainer.callback).not.toHaveBeenCalled(); - expect(rCallbackSubJob.subscriptionContainersToUpdate.size).toBe(1); + // Job that didn't ran through + expect( + Array.from(dummyJob2.subscriptionContainersToUpdate) + ).toStrictEqual([dummySubscriptionContainer2]); + expect(dummyJob2.triedToUpdateCount).toBe(1); expect( - rCallbackSubJob.subscriptionContainersToUpdate.has( - rCallbackSubContainer - ) - ).toBeTruthy(); - expect(dummyObserver1.subscribedTo.size).toBe(1); - expect(rCallbackSubJob.triesToUpdate).toBe(2); + Array.from(dummySubscriptionContainer2.updatedSubscribers) + ).toStrictEqual([]); + // Called with Job that didn't ran through + expect(console.warn).toHaveBeenCalledTimes(1); LogMock.hasLoggedCode( - '16:02:01', - [rCallbackSubJob.config.numberOfTriesToUpdate], - rCallbackSubContainer + '16:02:00', + [dummySubscriptionContainer2.key], + dummySubscriptionContainer2 ); - - expect(response).toBeFalsy(); } ); it( - "shouldn't update not ready SubscriptionContainer from the notReadyJobsToUpdate queue " + - 'and add it again to the notReadyJobsToUpdate queue if numberOfTriesToUpdate is null', + "shouldn't extract not ready Subscription Container from the specified Jobs, " + + "should remove the Job when it exceeded the max 'maxTriesToUpdate' " + + 'and print a warning', () => { - dummyAgile.hasIntegration = jest.fn(() => true); - rCallbackSubJob.config.numberOfTriesToUpdate = null; - rCallbackSubJob.triesToUpdate = 2; - rCallbackSubContainer.ready = false; - runtime.notReadyJobsToRerender.add(rCallbackSubJob); + jest + .spyOn(runtime, 'handleSelectors') + .mockReturnValueOnce(true) + .mockReturnValueOnce(true); + dummySubscriptionContainer1.ready = true; + dummySubscriptionContainer2.ready = false; + const numberOfTries = (dummyJob2.config.maxTriesToUpdate ?? 0) + 1; + dummyJob2.triedToUpdateCount = numberOfTries; + + const response = runtime.extractToUpdateSubscriptionContainer([ + dummyJob1, + dummyJob2, + ]); + + expect(response).toStrictEqual([dummySubscriptionContainer1]); + + // Called with Job that ran through + expect(runtime.handleSelectors).toHaveBeenCalledTimes(1); + expect(runtime.handleSelectors).toHaveBeenCalledWith( + dummySubscriptionContainer1, + dummyJob1 + ); - const response = runtime.updateSubscribers(); + expect(Array.from(runtime.notReadyJobsToRerender)).toStrictEqual([]); // Because exceeded Job was removed - expect(runtime.jobsToRerender).toStrictEqual([]); - expect(runtime.notReadyJobsToRerender.size).toBe(1); + // Job that ran through expect( - runtime.notReadyJobsToRerender.has(rCallbackSubJob) - ).toBeTruthy(); + Array.from(dummyJob1.subscriptionContainersToUpdate) + ).toStrictEqual([]); + expect(dummyJob1.triedToUpdateCount).toBe(0); + expect( + Array.from(dummySubscriptionContainer1.updatedSubscribers) + ).toStrictEqual([dummyObserver1]); - expect(rCallbackSubContainer.callback).not.toHaveBeenCalled(); - expect(rCallbackSubJob.subscriptionContainersToUpdate.size).toBe(1); + // Job that didn't ran through + expect( + Array.from(dummyJob2.subscriptionContainersToUpdate) + ).toStrictEqual([dummySubscriptionContainer2]); + expect(dummyJob2.triedToUpdateCount).toBe(numberOfTries); expect( - rCallbackSubJob.subscriptionContainersToUpdate.has( - rCallbackSubContainer - ) - ).toBeTruthy(); - expect(dummyObserver1.subscribedTo.size).toBe(1); - expect(rCallbackSubJob.triesToUpdate).toBe(3); + Array.from(dummySubscriptionContainer2.updatedSubscribers) + ).toStrictEqual([]); + // Called with Job that didn't ran through + expect(console.warn).toHaveBeenCalledTimes(1); LogMock.hasLoggedCode( - '16:02:00', - [rCallbackSubContainer.key], - rCallbackSubContainer + '16:02:01', + [dummyJob2.config.maxTriesToUpdate], + dummySubscriptionContainer2 ); - - expect(response).toBeFalsy(); } ); - }); - describe('handleObjectBasedSubscription function tests', () => { - let arraySubscriptionContainer: SubscriptionContainer; - const dummyComponent = { - my: 'cool component', - }; - let objectSubscriptionContainer: SubscriptionContainer; - const dummyComponent2 = { - my: 'second cool component', - }; - let arrayJob: RuntimeJob; - let objectJob1: RuntimeJob; - let objectJob2: RuntimeJob; + it("shouldn't extract Subscription Container if the selected property hasn't changed", () => { + jest + .spyOn(runtime, 'handleSelectors') + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + dummySubscriptionContainer1.ready = true; + dummySubscriptionContainer2.ready = true; - beforeEach(() => { - arraySubscriptionContainer = dummyAgile.subController.subscribeWithSubsArray( - dummyComponent, - [dummyObserver1, dummyObserver2, dummyObserver3] + const response = runtime.extractToUpdateSubscriptionContainer([ + dummyJob1, + dummyJob2, + ]); + + expect(response).toStrictEqual([dummySubscriptionContainer2]); + + expect(runtime.handleSelectors).toHaveBeenCalledTimes(2); + expect(runtime.handleSelectors).toHaveBeenCalledWith( + dummySubscriptionContainer1, + dummyJob1 + ); + expect(runtime.handleSelectors).toHaveBeenCalledWith( + dummySubscriptionContainer2, + dummyJob2 ); - arrayJob = new RuntimeJob(dummyObserver1, { key: 'dummyArrayJob' }); - objectSubscriptionContainer = dummyAgile.subController.subscribeWithSubsObject( - dummyComponent2, - { - observer1: dummyObserver1, - observer2: dummyObserver2, - observer3: dummyObserver3, - } - ).subscriptionContainer; - objectJob1 = new RuntimeJob(dummyObserver1, { key: 'dummyObjectJob1' }); - objectJob2 = new RuntimeJob(dummyObserver3, { key: 'dummyObjectJob2' }); + // Since the Job is ready but the Observer value simply hasn't changed + // -> no point in trying to update it again + expect(Array.from(runtime.notReadyJobsToRerender)).toStrictEqual([]); + + // Job that didn't ran through + expect( + Array.from(dummyJob1.subscriptionContainersToUpdate) + ).toStrictEqual([]); + expect(dummyJob1.triedToUpdateCount).toBe(0); + expect( + Array.from(dummySubscriptionContainer1.updatedSubscribers) + ).toStrictEqual([]); + + // Job that ran through + expect( + Array.from(dummyJob2.subscriptionContainersToUpdate) + ).toStrictEqual([]); + expect(dummyJob2.triedToUpdateCount).toBe(0); + expect( + Array.from(dummySubscriptionContainer2.updatedSubscribers) + ).toStrictEqual([dummyObserver2]); + + expect(console.warn).toHaveBeenCalledTimes(0); }); - it('should ignore not object based SubscriptionContainer', () => { - runtime.handleObjectBasedSubscription( - arraySubscriptionContainer, - arrayJob - ); + it('should extract ready and to update Subscription Containers', () => { + jest + .spyOn(runtime, 'handleSelectors') + .mockReturnValueOnce(true) + .mockReturnValueOnce(true); + dummySubscriptionContainer1.ready = true; + dummySubscriptionContainer2.ready = true; + + const response = runtime.extractToUpdateSubscriptionContainer([ + dummyJob1, + dummyJob2, + ]); - expect(arraySubscriptionContainer.observerKeysToUpdate).toStrictEqual( - [] + expect(response).toStrictEqual([ + dummySubscriptionContainer1, + dummySubscriptionContainer2, + ]); + + expect(runtime.handleSelectors).toHaveBeenCalledTimes(2); + expect(runtime.handleSelectors).toHaveBeenCalledWith( + dummySubscriptionContainer1, + dummyJob1 ); + expect(runtime.handleSelectors).toHaveBeenCalledWith( + dummySubscriptionContainer2, + dummyJob2 + ); + + expect(Array.from(runtime.notReadyJobsToRerender)).toStrictEqual([]); + + // Job that ran through + expect( + Array.from(dummyJob1.subscriptionContainersToUpdate) + ).toStrictEqual([]); + expect(dummyJob1.triedToUpdateCount).toBe(0); + expect( + Array.from(dummySubscriptionContainer1.updatedSubscribers) + ).toStrictEqual([dummyObserver1]); + + // Job that ran through + expect( + Array.from(dummyJob2.subscriptionContainersToUpdate) + ).toStrictEqual([]); + expect(dummyJob2.triedToUpdateCount).toBe(0); + expect( + Array.from(dummySubscriptionContainer2.updatedSubscribers) + ).toStrictEqual([dummyObserver2]); + + expect(console.warn).not.toHaveBeenCalled(); }); + }); - it('should add Job Observer to changedObjectKeys in SubscriptionContainer', () => { - runtime.handleObjectBasedSubscription( - objectSubscriptionContainer, - objectJob1 - ); + describe('updateSubscriptionContainer function tests', () => { + const dummyIntegration1 = { dummy: 'component' }; + let componentSubscriptionContainer1: ComponentSubscriptionContainer; + const dummyIntegration2 = jest.fn(); + let callbackSubscriptionContainer2: CallbackSubscriptionContainer; + const dummyIntegration3 = jest.fn(); + let callbackSubscriptionContainer3: CallbackSubscriptionContainer; + + beforeEach(() => { + componentSubscriptionContainer1 = dummyAgile.subController.subscribe( + dummyIntegration1, + [dummyObserver1] + ) as ComponentSubscriptionContainer; + componentSubscriptionContainer1.updatedSubscribers = new Set([ + dummyObserver1, + ]); + callbackSubscriptionContainer2 = dummyAgile.subController.subscribe( + dummyIntegration2, + [dummyObserver2] + ) as CallbackSubscriptionContainer; + callbackSubscriptionContainer2.updatedSubscribers = new Set([ + dummyObserver2, + ]); + callbackSubscriptionContainer3 = dummyAgile.subController.subscribe( + dummyIntegration3, + [dummyObserver3] + ) as CallbackSubscriptionContainer; + callbackSubscriptionContainer3.updatedSubscribers = new Set([ + dummyObserver3, + ]); - expect(objectSubscriptionContainer.observerKeysToUpdate).toStrictEqual([ - 'observer1', + dummyAgile.integrations.update = jest.fn(); + }); + + it('should update the specified Subscription Container', () => { + jest + .spyOn(runtime, 'getUpdatedObserverValues') + .mockReturnValueOnce('propsBasedOnUpdatedObservers' as any); + + runtime.updateSubscriptionContainer([ + componentSubscriptionContainer1, + callbackSubscriptionContainer2, + callbackSubscriptionContainer3, ]); + + // Component Subscription Container 1 + expect(dummyAgile.integrations.update).toHaveBeenCalledTimes(1); + expect(dummyAgile.integrations.update).toHaveBeenCalledWith( + dummyIntegration1, + 'propsBasedOnUpdatedObservers' + ); + expect( + Array.from(componentSubscriptionContainer1.updatedSubscribers) + ).toStrictEqual([]); + + // Callback Subscription Container 2 + expect(callbackSubscriptionContainer2.callback).toHaveBeenCalledTimes( + 1 + ); + expect( + Array.from(callbackSubscriptionContainer2.updatedSubscribers) + ).toStrictEqual([]); + + // Callback Subscription Container 3 + expect(callbackSubscriptionContainer3.callback).toHaveBeenCalledTimes( + 1 + ); + expect( + Array.from(callbackSubscriptionContainer2.updatedSubscribers) + ).toStrictEqual([]); }); }); - describe('getObjectBasedProps function tests', () => { + describe('getUpdatedObserverValues function tests', () => { let subscriptionContainer: SubscriptionContainer; const dummyFunction = () => { /* empty function */ }; beforeEach(() => { - subscriptionContainer = dummyAgile.subController.subscribeWithSubsObject( + subscriptionContainer = dummyAgile.subController.subscribe( dummyFunction, - { - observer1: dummyObserver1, - observer2: dummyObserver2, - observer3: dummyObserver3, - } - ).subscriptionContainer; + [dummyObserver1, dummyObserver2, dummyObserver3] + ); dummyObserver1.value = 'dummyObserverValue1'; dummyObserver3.value = 'dummyObserverValue3'; + + dummyObserver1._key = 'dummyObserver1KeyInObserver'; + dummyObserver2._key = undefined; + subscriptionContainer.subscriberKeysWeakMap.set( + dummyObserver2, + 'dummyObserver2KeyInWeakMap' + ); + dummyObserver3._key = 'dummyObserver3KeyInObserver'; + subscriptionContainer.subscriberKeysWeakMap.set( + dummyObserver3, + 'dummyObserver3KeyInWeakMap' + ); }); - it('should build Observer Value Object out of observerKeysToUpdate and Value of Observer', () => { - subscriptionContainer.observerKeysToUpdate.push('observer1'); - subscriptionContainer.observerKeysToUpdate.push('observer2'); - subscriptionContainer.observerKeysToUpdate.push('observer3'); + it('should map the values of the updated Observers into an object and return it', () => { + subscriptionContainer.updatedSubscribers.add(dummyObserver1); + subscriptionContainer.updatedSubscribers.add(dummyObserver2); + subscriptionContainer.updatedSubscribers.add(dummyObserver3); - const props = runtime.getObjectBasedProps(subscriptionContainer); + const props = runtime.getUpdatedObserverValues(subscriptionContainer); expect(props).toStrictEqual({ - observer1: 'dummyObserverValue1', - observer2: undefined, - observer3: 'dummyObserverValue3', + dummyObserver1KeyInObserver: 'dummyObserverValue1', + dummyObserver2KeyInWeakMap: undefined, + dummyObserver3KeyInWeakMap: 'dummyObserverValue3', }); - expect(subscriptionContainer.observerKeysToUpdate).toStrictEqual([]); + expect( + Array.from(subscriptionContainer.updatedSubscribers) + ).toStrictEqual([dummyObserver1, dummyObserver2, dummyObserver3]); }); }); - describe('handleProxyBasedSubscription function tests', () => { + describe('handleSelector function tests', () => { let objectSubscriptionContainer: SubscriptionContainer; const dummyFunction = () => { /* empty function */ @@ -627,96 +667,69 @@ describe('Runtime Tests', () => { let arrayJob: RuntimeJob; beforeEach(() => { - // Create Job with Object value - objectSubscriptionContainer = dummyAgile.subController.subscribeWithSubsObject( + // Create Job with object based value + objectSubscriptionContainer = dummyAgile.subController.subscribe( dummyFunction, - { observer1: dummyObserver1 } - ).subscriptionContainer; + [dummyObserver1] + ); dummyObserver1.value = { - key: 'dummyObserverValue1', data: { name: 'jeff' }, }; dummyObserver1.previousValue = { - key: 'dummyObserverValue1', data: { name: 'jeff' }, }; - objectSubscriptionContainer.proxyBased = true; - objectSubscriptionContainer.proxyKeyMap = { - [dummyObserver1._key || 'unknown']: { paths: [['data', 'name']] }, - }; + objectSubscriptionContainer.selectorsWeakMap.set(dummyObserver1, { + methods: [(value) => value?.data?.name], + }); objectJob = new RuntimeJob(dummyObserver1, { key: 'dummyObjectJob1' }); - // Create Job with Array value - arraySubscriptionContainer = dummyAgile.subController.subscribeWithSubsObject( + // Create Job with array based value + arraySubscriptionContainer = dummyAgile.subController.subscribe( dummyFunction2, - { observer2: dummyObserver2 } + { dummyObserver2: dummyObserver2 } ).subscriptionContainer; dummyObserver2.value = [ { - key: 'dummyObserver2Value1', data: { name: 'jeff' }, }, { - key: 'dummyObserver2Value2', data: { name: 'hans' }, }, + { + data: { name: 'frank' }, + }, ]; dummyObserver2.previousValue = [ { - key: 'dummyObserver2Value1', data: { name: 'jeff' }, }, { - key: 'dummyObserver2Value2', data: { name: 'hans' }, }, - ]; - arraySubscriptionContainer.proxyBased = true; - arraySubscriptionContainer.proxyKeyMap = { - [dummyObserver2._key || 'unknown']: { - paths: [['0', 'data', 'name']], + { + data: { name: 'frank' }, }, - }; + ]; + arraySubscriptionContainer.selectorsWeakMap.set(dummyObserver2, { + methods: [ + (value) => value[0]?.data?.name, + (value) => value[2]?.data?.name, + ], + }); arrayJob = new RuntimeJob(dummyObserver2, { key: 'dummyObjectJob2' }); jest.spyOn(Utils, 'notEqual'); - // Because not equals is called once during the creation of the subscriptionContainer + // Because not equals is called once during the creation of the Subscription Containers jest.clearAllMocks(); }); - it("should return true if subscriptionContainer isn't proxy based", () => { - objectSubscriptionContainer.proxyBased = false; - - const response = runtime.handleProxyBasedSubscription( - objectSubscriptionContainer, - objectJob - ); - - expect(response).toBeTruthy(); - expect(Utils.notEqual).not.toHaveBeenCalled(); - }); - - it('should return true if observer the job represents has no key', () => { - objectJob.observer._key = undefined; - - const response = runtime.handleProxyBasedSubscription( - objectSubscriptionContainer, - objectJob - ); - - expect(response).toBeTruthy(); - expect(Utils.notEqual).not.toHaveBeenCalled(); - }); - - it("should return true if the observer key isn't represented in the proxyKeyMap", () => { - objectSubscriptionContainer.proxyKeyMap = { - unknownKey: { paths: [['a', 'b']] }, - }; + it('should return true if Subscription Container has no selector methods', () => { + objectSubscriptionContainer.selectorsWeakMap.delete(dummyObserver1); - const response = runtime.handleProxyBasedSubscription( + const response = runtime.handleSelectors( objectSubscriptionContainer, objectJob ); @@ -725,90 +738,119 @@ describe('Runtime Tests', () => { expect(Utils.notEqual).not.toHaveBeenCalled(); }); - it('should return true if used property has changed (object value)', () => { + it('should return true if selected property has changed (object value)', () => { dummyObserver1.value = { - key: 'dummyObserverValue1', - data: { name: 'hans' }, + data: { name: 'changedName' }, }; - const response = runtime.handleProxyBasedSubscription( + const response = runtime.handleSelectors( objectSubscriptionContainer, objectJob ); expect(response).toBeTruthy(); + + expect(Utils.notEqual).toHaveBeenCalledTimes(1); expect(Utils.notEqual).toHaveBeenCalledWith( dummyObserver1.value.data.name, dummyObserver1.previousValue.data.name ); }); - it("should return false if used property hasn't changed (object value)", () => { - const response = runtime.handleProxyBasedSubscription( + it("should return false if selected property hasn't changed (object value)", () => { + const response = runtime.handleSelectors( objectSubscriptionContainer, objectJob ); expect(response).toBeFalsy(); + + expect(Utils.notEqual).toHaveBeenCalledTimes(1); expect(Utils.notEqual).toHaveBeenCalledWith( dummyObserver1.value.data.name, dummyObserver1.previousValue.data.name ); }); - it('should return true if used property has changed in the deepness (object value)', () => { - dummyObserver1.value = { - key: 'dummyObserverValue1', - }; - dummyObserver1.previousValue = { - key: 'dummyObserverValue1', - data: { name: undefined }, - }; - - const response = runtime.handleProxyBasedSubscription( - objectSubscriptionContainer, - objectJob - ); - - expect(response).toBeTruthy(); - expect(Utils.notEqual).toHaveBeenCalledWith(undefined, undefined); - }); - - it('should return true if used property has changed (array value)', () => { + // TODO the deepness check isn't possible with the current way of handling selector methods + // it('should return true if selected property has changed in the deepness (object value)', () => { + // dummyObserver1.value = { + // key: 'dummyObserverValue1', + // }; + // dummyObserver1.previousValue = { + // key: 'dummyObserverValue1', + // data: { name: undefined }, + // }; + // + // const response = runtime.handleSelectors( + // objectSubscriptionContainer, + // objectJob + // ); + // + // expect(response).toBeTruthy(); + // expect(Utils.notEqual).toHaveBeenCalledWith(undefined, undefined); + // }); + + it('should return true if a selected property has changed (array value)', () => { dummyObserver2.value = [ { - key: 'dummyObserver2Value1', - data: { name: 'frank' }, + data: { name: 'jeff' }, }, { - key: 'dummyObserver2Value2', data: { name: 'hans' }, }, + { + data: { name: 'changedName' }, + }, ]; - const response = runtime.handleProxyBasedSubscription( + const response = runtime.handleSelectors( arraySubscriptionContainer, arrayJob ); expect(response).toBeTruthy(); + + expect(Utils.notEqual).toHaveBeenCalledTimes(2); expect(Utils.notEqual).toHaveBeenCalledWith( dummyObserver2.value['0'].data.name, dummyObserver2.previousValue['0'].data.name ); + expect(Utils.notEqual).toHaveBeenCalledWith( + dummyObserver2.value['2'].data.name, + dummyObserver2.previousValue['2'].data.name + ); }); it("should return false if used property hasn't changed (array value)", () => { - const response = runtime.handleProxyBasedSubscription( + dummyObserver2.value = [ + { + data: { name: 'jeff' }, + }, + { + data: { name: 'changedName (but not selected)' }, + }, + { + data: { name: 'frank' }, + }, + ]; + + const response = runtime.handleSelectors( arraySubscriptionContainer, arrayJob ); expect(response).toBeFalsy(); + + expect(Utils.notEqual).toHaveBeenCalledTimes(2); expect(Utils.notEqual).toHaveBeenCalledWith( dummyObserver2.value['0'].data.name, dummyObserver2.previousValue['0'].data.name ); + expect(Utils.notEqual).toHaveBeenCalledWith( + dummyObserver2.value['2'].data.name, + dummyObserver2.previousValue['2'].data.name + ); }); }); }); diff --git a/packages/core/tests/unit/runtime/subscription/container/CallbackSubscriptionContainer.test.ts b/packages/core/tests/unit/runtime/subscription/container/CallbackSubscriptionContainer.test.ts index 080c0439..e8bea19e 100644 --- a/packages/core/tests/unit/runtime/subscription/container/CallbackSubscriptionContainer.test.ts +++ b/packages/core/tests/unit/runtime/subscription/container/CallbackSubscriptionContainer.test.ts @@ -2,6 +2,8 @@ import { Agile, CallbackSubscriptionContainer, Observer, + ProxyWeakMapType, + SelectorWeakMapType, } from '../../../../../src'; import { LogMock } from '../../../../helper/logMock'; @@ -9,6 +11,8 @@ describe('CallbackSubscriptionContainer Tests', () => { let dummyAgile: Agile; let dummyObserver1: Observer; let dummyObserver2: Observer; + let dummySelectorWeakMap: SelectorWeakMapType; + let dummyProxyWeakMap: ProxyWeakMapType; beforeEach(() => { jest.clearAllMocks(); @@ -17,6 +21,8 @@ describe('CallbackSubscriptionContainer Tests', () => { dummyAgile = new Agile(); dummyObserver1 = new Observer(dummyAgile, { key: 'dummyObserver1' }); dummyObserver2 = new Observer(dummyAgile, { key: 'dummyObserver2' }); + dummySelectorWeakMap = new WeakMap(); + dummyProxyWeakMap = new WeakMap(); }); it('should create CallbackSubscriptionContainer', () => { @@ -27,22 +33,36 @@ describe('CallbackSubscriptionContainer Tests', () => { const subscriptionContainer = new CallbackSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2], - { key: 'dummyKey', proxyKeyMap: { myState: { paths: [['hi']] } } } + { + key: 'dummyKey', + proxyWeakMap: dummyProxyWeakMap, + selectorWeakMap: dummySelectorWeakMap, + componentId: 'testID', + } ); expect(subscriptionContainer.callback).toBe(dummyIntegration); + // Check if SubscriptionContainer was called with correct parameters expect(subscriptionContainer.key).toBe('dummyKey'); expect(subscriptionContainer.ready).toBeFalsy(); - expect(subscriptionContainer.subscribers.size).toBe(2); - expect(subscriptionContainer.subscribers.has(dummyObserver1)).toBeTruthy(); - expect(subscriptionContainer.subscribers.has(dummyObserver2)).toBeTruthy(); + expect(subscriptionContainer.componentId).toBe('testID'); + expect(Array.from(subscriptionContainer.subscribers)).toStrictEqual([ + dummyObserver1, + dummyObserver2, + ]); + expect(Array.from(subscriptionContainer.updatedSubscribers)).toStrictEqual( + [] + ); expect(subscriptionContainer.isObjectBased).toBeFalsy(); - expect(subscriptionContainer.observerKeysToUpdate).toStrictEqual([]); - expect(subscriptionContainer.subsObject).toBeUndefined(); - expect(subscriptionContainer.proxyKeyMap).toStrictEqual({ - myState: { paths: [['hi']] }, - }); - expect(subscriptionContainer.proxyBased).toBeTruthy(); + expect(subscriptionContainer.subscriberKeysWeakMap).toStrictEqual( + expect.any(WeakMap) + ); + expect(subscriptionContainer.selectorsWeakMap).toStrictEqual( + expect.any(WeakMap) + ); + expect(subscriptionContainer.selectorsWeakMap).not.toBe( + dummySelectorWeakMap + ); }); }); diff --git a/packages/core/tests/unit/runtime/subscription/container/ComponentSubscriptionContainer.test.ts b/packages/core/tests/unit/runtime/subscription/container/ComponentSubscriptionContainer.test.ts index 04362b53..4401cf88 100644 --- a/packages/core/tests/unit/runtime/subscription/container/ComponentSubscriptionContainer.test.ts +++ b/packages/core/tests/unit/runtime/subscription/container/ComponentSubscriptionContainer.test.ts @@ -2,6 +2,8 @@ import { Agile, ComponentSubscriptionContainer, Observer, + ProxyWeakMapType, + SelectorWeakMapType, } from '../../../../../src'; import { LogMock } from '../../../../helper/logMock'; @@ -9,6 +11,8 @@ describe('ComponentSubscriptionContainer Tests', () => { let dummyAgile: Agile; let dummyObserver1: Observer; let dummyObserver2: Observer; + let dummySelectorWeakMap: SelectorWeakMapType; + let dummyProxyWeakMap: ProxyWeakMapType; beforeEach(() => { jest.clearAllMocks(); @@ -17,6 +21,8 @@ describe('ComponentSubscriptionContainer Tests', () => { dummyAgile = new Agile(); dummyObserver1 = new Observer(dummyAgile, { key: 'dummyObserver1' }); dummyObserver2 = new Observer(dummyAgile, { key: 'dummyObserver2' }); + dummySelectorWeakMap = new WeakMap(); + dummyProxyWeakMap = new WeakMap(); }); it('should create ComponentSubscriptionContainer', () => { @@ -25,22 +31,36 @@ describe('ComponentSubscriptionContainer Tests', () => { const subscriptionContainer = new ComponentSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2], - { key: 'dummyKey', proxyKeyMap: { myState: { paths: [['hi']] } } } + { + key: 'dummyKey', + proxyWeakMap: dummyProxyWeakMap, + selectorWeakMap: dummySelectorWeakMap, + componentId: 'testID', + } ); expect(subscriptionContainer.component).toStrictEqual(dummyIntegration); + // Check if SubscriptionContainer was called with correct parameters expect(subscriptionContainer.key).toBe('dummyKey'); expect(subscriptionContainer.ready).toBeFalsy(); - expect(subscriptionContainer.subscribers.size).toBe(2); - expect(subscriptionContainer.subscribers.has(dummyObserver1)).toBeTruthy(); - expect(subscriptionContainer.subscribers.has(dummyObserver2)).toBeTruthy(); + expect(subscriptionContainer.componentId).toBe('testID'); + expect(Array.from(subscriptionContainer.subscribers)).toStrictEqual([ + dummyObserver1, + dummyObserver2, + ]); + expect(Array.from(subscriptionContainer.updatedSubscribers)).toStrictEqual( + [] + ); expect(subscriptionContainer.isObjectBased).toBeFalsy(); - expect(subscriptionContainer.observerKeysToUpdate).toStrictEqual([]); - expect(subscriptionContainer.subsObject).toBeUndefined(); - expect(subscriptionContainer.proxyKeyMap).toStrictEqual({ - myState: { paths: [['hi']] }, - }); - expect(subscriptionContainer.proxyBased).toBeTruthy(); + expect(subscriptionContainer.subscriberKeysWeakMap).toStrictEqual( + expect.any(WeakMap) + ); + expect(subscriptionContainer.selectorsWeakMap).toStrictEqual( + expect.any(WeakMap) + ); + expect(subscriptionContainer.selectorsWeakMap).not.toBe( + dummySelectorWeakMap + ); }); }); diff --git a/packages/core/tests/unit/runtime/subscription/container/SubscriptionContainer.test.ts b/packages/core/tests/unit/runtime/subscription/container/SubscriptionContainer.test.ts index 3c9c566a..c3aa012d 100644 --- a/packages/core/tests/unit/runtime/subscription/container/SubscriptionContainer.test.ts +++ b/packages/core/tests/unit/runtime/subscription/container/SubscriptionContainer.test.ts @@ -1,4 +1,10 @@ -import { Agile, Observer, SubscriptionContainer } from '../../../../../src'; +import { + Agile, + Observer, + ProxyWeakMapType, + SelectorWeakMapType, + SubscriptionContainer, +} from '../../../../../src'; import * as Utils from '@agile-ts/utils'; import { LogMock } from '../../../../helper/logMock'; @@ -6,6 +12,8 @@ describe('SubscriptionContainer Tests', () => { let dummyAgile: Agile; let dummyObserver1: Observer; let dummyObserver2: Observer; + let dummySelectorWeakMap: SelectorWeakMapType; + let dummyProxyWeakMap: ProxyWeakMapType; beforeEach(() => { jest.clearAllMocks(); @@ -14,40 +22,310 @@ describe('SubscriptionContainer Tests', () => { dummyAgile = new Agile(); dummyObserver1 = new Observer(dummyAgile, { key: 'dummyObserver1' }); dummyObserver2 = new Observer(dummyAgile, { key: 'dummyObserver2' }); + dummySelectorWeakMap = new WeakMap(); + dummyProxyWeakMap = new WeakMap(); }); - it('should create SubscriptionContainer (default config)', () => { - jest.spyOn(Utils, 'generateId').mockReturnValue('generatedId'); + it('should create SubscriptionContainer with passed subs array (default config)', () => { + jest.spyOn(Utils, 'generateId').mockReturnValueOnce('generatedId'); + jest + .spyOn(SubscriptionContainer.prototype, 'addSubscription') + .mockReturnValueOnce() + .mockReturnValueOnce(); - const subscriptionContainer = new SubscriptionContainer(); + const subscriptionContainer = new SubscriptionContainer([ + dummyObserver1, + dummyObserver2, + ]); + + expect(subscriptionContainer.addSubscription).toHaveBeenCalledTimes(2); + expect(subscriptionContainer.addSubscription).toHaveBeenCalledWith( + dummyObserver1, + { + proxyPaths: undefined, + selectorMethods: undefined, + key: undefined, + } + ); + expect(subscriptionContainer.addSubscription).toHaveBeenCalledWith( + dummyObserver2, + { + proxyPaths: undefined, + selectorMethods: undefined, + key: undefined, + } + ); expect(subscriptionContainer.key).toBe('generatedId'); expect(subscriptionContainer.ready).toBeFalsy(); - expect(subscriptionContainer.subscribers.size).toBe(0); + expect(subscriptionContainer.componentId).toBeUndefined(); + expect(Array.from(subscriptionContainer.subscribers)).toStrictEqual([]); // because of mocking addSubscription + expect(Array.from(subscriptionContainer.updatedSubscribers)).toStrictEqual( + [] + ); expect(subscriptionContainer.isObjectBased).toBeFalsy(); - expect(subscriptionContainer.observerKeysToUpdate).toStrictEqual([]); - expect(subscriptionContainer.subsObject).toBeUndefined(); - expect(subscriptionContainer.proxyKeyMap).toStrictEqual({}); - expect(subscriptionContainer.proxyBased).toBeFalsy(); + expect(subscriptionContainer.subscriberKeysWeakMap).toStrictEqual( + expect.any(WeakMap) + ); + expect(subscriptionContainer.selectorsWeakMap).toStrictEqual( + expect.any(WeakMap) + ); + }); + + it('should create SubscriptionContainer with passed subs object (default config)', () => { + jest.spyOn(Utils, 'generateId').mockReturnValueOnce('generatedId'); + jest + .spyOn(SubscriptionContainer.prototype, 'addSubscription') + .mockReturnValueOnce() + .mockReturnValueOnce(); + + const subscriptionContainer = new SubscriptionContainer({ + dummyObserver1: dummyObserver1, + dummyObserver2: dummyObserver2, + }); + + expect(subscriptionContainer.addSubscription).toHaveBeenCalledTimes(2); + expect(subscriptionContainer.addSubscription).toHaveBeenCalledWith( + dummyObserver1, + { + proxyPaths: undefined, + selectorMethods: undefined, + key: 'dummyObserver1', + } + ); + expect(subscriptionContainer.addSubscription).toHaveBeenCalledWith( + dummyObserver2, + { + proxyPaths: undefined, + selectorMethods: undefined, + key: 'dummyObserver2', + } + ); + + expect(subscriptionContainer.key).toBe('generatedId'); + expect(subscriptionContainer.ready).toBeFalsy(); + expect(subscriptionContainer.componentId).toBeUndefined(); + expect(Array.from(subscriptionContainer.subscribers)).toStrictEqual([]); // because of mocking addSubscription + expect(Array.from(subscriptionContainer.updatedSubscribers)).toStrictEqual( + [] + ); + expect(subscriptionContainer.isObjectBased).toBeTruthy(); + expect(subscriptionContainer.subscriberKeysWeakMap).toStrictEqual( + expect.any(WeakMap) + ); + expect(subscriptionContainer.selectorsWeakMap).toStrictEqual( + expect.any(WeakMap) + ); }); - it('should create SubscriptionContainer (specific config)', () => { + it('should create SubscriptionContainer with passed subs array (specific config)', () => { + jest + .spyOn(SubscriptionContainer.prototype, 'addSubscription') + .mockReturnValueOnce() + .mockReturnValueOnce(); + + dummyProxyWeakMap.set(dummyObserver1, { + paths: 'dummyObserver1_paths' as any, + }); + dummyProxyWeakMap.set(dummyObserver2, { + paths: 'dummyObserver2_paths' as any, + }); + dummySelectorWeakMap.set(dummyObserver2, { + methods: 'dummyObserver2_selectors' as any, + }); + const subscriptionContainer = new SubscriptionContainer( [dummyObserver1, dummyObserver2], - { key: 'dummyKey', proxyKeyMap: { myState: { paths: [['a', 'b']] } } } + { + key: 'dummyKey', + proxyWeakMap: dummyProxyWeakMap, + selectorWeakMap: dummySelectorWeakMap, + componentId: 'testID', + } + ); + + expect(subscriptionContainer.addSubscription).toHaveBeenCalledTimes(2); + expect(subscriptionContainer.addSubscription).toHaveBeenCalledWith( + dummyObserver1, + { + proxyPaths: 'dummyObserver1_paths', + selectorMethods: undefined, + key: undefined, + } + ); + expect(subscriptionContainer.addSubscription).toHaveBeenCalledWith( + dummyObserver2, + { + proxyPaths: 'dummyObserver2_paths', + selectorMethods: 'dummyObserver2_selectors', + key: undefined, + } ); expect(subscriptionContainer.key).toBe('dummyKey'); expect(subscriptionContainer.ready).toBeFalsy(); - expect(subscriptionContainer.subscribers.size).toBe(2); - expect(subscriptionContainer.subscribers.has(dummyObserver1)).toBeTruthy(); - expect(subscriptionContainer.subscribers.has(dummyObserver2)).toBeTruthy(); + expect(subscriptionContainer.componentId).toBe('testID'); + expect(Array.from(subscriptionContainer.subscribers)).toStrictEqual([]); // because of mocking addSubscription + expect(Array.from(subscriptionContainer.updatedSubscribers)).toStrictEqual( + [] + ); expect(subscriptionContainer.isObjectBased).toBeFalsy(); - expect(subscriptionContainer.observerKeysToUpdate).toStrictEqual([]); - expect(subscriptionContainer.subsObject).toBeUndefined(); - expect(subscriptionContainer.proxyKeyMap).toStrictEqual({ - myState: { paths: [['a', 'b']] }, + expect(subscriptionContainer.subscriberKeysWeakMap).toStrictEqual( + expect.any(WeakMap) + ); + expect(subscriptionContainer.selectorsWeakMap).toStrictEqual( + expect.any(WeakMap) + ); + expect(subscriptionContainer.selectorsWeakMap).not.toBe( + dummySelectorWeakMap + ); + }); + + describe('Subscription Container Function Tests', () => { + let subscriptionContainer: SubscriptionContainer; + + beforeEach(() => { + subscriptionContainer = new SubscriptionContainer([]); + }); + + describe('addSubscription function tests', () => { + it( + 'should create selector methods based on the specified proxy paths, ' + + "assign newly created and provided selector methods to the 'selectorsWeakMap' " + + 'and subscribe the specified Observer to the Subscription Container', + () => { + dummyObserver1.value = { + das: { haus: { vom: 'nikolaus' } }, + alle: { meine: 'entchien' }, + test1: 'test1Value', + test2: 'test2Value', + test3: 'test3Value', + }; + subscriptionContainer.selectorsWeakMap.set(dummyObserver1, { + methods: [(value) => value.test3], + }); + subscriptionContainer.selectorsWeakMap.set(dummyObserver2, { + methods: [(value) => 'doesNotMatter'], + }); + subscriptionContainer.subscriberKeysWeakMap.set( + dummyObserver2, + 'dummyObserver2' + ); + + subscriptionContainer.addSubscription(dummyObserver1, { + key: 'dummyObserver1', + proxyPaths: [['das', 'haus', 'vom'], ['test1']], + selectorMethods: [ + (value) => value.alle.meine, + (value) => value.test2, + ], + }); + + expect(Array.from(subscriptionContainer.subscribers)).toStrictEqual([ + dummyObserver1, + ]); + expect(Array.from(dummyObserver1.subscribedTo)).toStrictEqual([ + subscriptionContainer, + ]); + + // should assign specified selectors/(and selectors created from proxy paths) to the 'selectorsWeakMap' + const observer1Selector = subscriptionContainer.selectorsWeakMap.get( + dummyObserver1 + ) as any; + expect(observer1Selector.methods.length).toBe(5); + expect(observer1Selector.methods[0](dummyObserver1.value)).toBe( + 'test3Value' + ); + expect(observer1Selector.methods[1](dummyObserver1.value)).toBe( + 'entchien' + ); + expect(observer1Selector.methods[2](dummyObserver1.value)).toBe( + 'test2Value' + ); + expect(observer1Selector.methods[3](dummyObserver1.value)).toBe( + 'nikolaus' + ); + expect(observer1Selector.methods[4](dummyObserver1.value)).toBe( + 'test1Value' + ); + + // shouldn't overwrite already set values in 'selectorsWeakMap' (Observer2) + const observer2Selector = subscriptionContainer.selectorsWeakMap.get( + dummyObserver2 + ) as any; + expect(observer2Selector.methods.length).toBe(1); + expect(observer2Selector.methods[0](null)).toBe('doesNotMatter'); + + // should assign specified key to the 'subscriberKeysWeakMap' + const observer1Key = subscriptionContainer.subscriberKeysWeakMap.get( + dummyObserver1 + ); + expect(observer1Key).toBe('dummyObserver1'); + + // shouldn't overwrite already set values in 'subscriberKeysWeakMap' (Observer2) + const observer2Key = subscriptionContainer.subscriberKeysWeakMap.get( + dummyObserver2 + ); + expect(observer2Key).toBe('dummyObserver2'); + } + ); + }); + + describe('removeSubscription function tests', () => { + let subscriptionContainer: SubscriptionContainer; + + beforeEach(() => { + subscriptionContainer = new SubscriptionContainer([]); + + subscriptionContainer.subscribers = new Set([ + dummyObserver1, + dummyObserver2, + ]); + dummyObserver1.subscribedTo = new Set([subscriptionContainer]); + dummyObserver2.subscribedTo = new Set([subscriptionContainer]); + + subscriptionContainer.selectorsWeakMap.set(dummyObserver1, { + methods: [], + }); + subscriptionContainer.selectorsWeakMap.set(dummyObserver2, { + methods: [], + }); + subscriptionContainer.subscriberKeysWeakMap.set( + dummyObserver1, + 'dummyObserver1' + ); + subscriptionContainer.subscriberKeysWeakMap.set( + dummyObserver2, + 'dummyObserver2' + ); + }); + + it('should remove subscribed Observer from Subscription Container', () => { + subscriptionContainer.removeSubscription(dummyObserver1); + + expect(Array.from(subscriptionContainer.subscribers)).toStrictEqual([ + dummyObserver2, + ]); + + expect( + subscriptionContainer.selectorsWeakMap.get(dummyObserver1) + ).toBeUndefined(); + expect( + subscriptionContainer.selectorsWeakMap.get(dummyObserver2) + ).not.toBeUndefined(); + + expect( + subscriptionContainer.subscriberKeysWeakMap.get(dummyObserver1) + ).toBeUndefined(); + expect( + subscriptionContainer.subscriberKeysWeakMap.get(dummyObserver2) + ).toBe('dummyObserver2'); + + expect(Array.from(dummyObserver1.subscribedTo)).toStrictEqual([]); + expect(Array.from(dummyObserver2.subscribedTo)).toStrictEqual([ + subscriptionContainer, + ]); + }); }); - expect(subscriptionContainer.proxyBased).toBeTruthy(); }); }); diff --git a/packages/core/tests/unit/runtime/subscription/sub.controller.test.ts b/packages/core/tests/unit/runtime/subscription/sub.controller.test.ts index 0dee1506..e52e81a1 100644 --- a/packages/core/tests/unit/runtime/subscription/sub.controller.test.ts +++ b/packages/core/tests/unit/runtime/subscription/sub.controller.test.ts @@ -4,7 +4,6 @@ import { ComponentSubscriptionContainer, Observer, SubController, - SubscriptionContainer, } from '../../../../src'; import * as Utils from '@agile-ts/utils'; import { LogMock } from '../../../helper/logMock'; @@ -22,8 +21,9 @@ describe('SubController Tests', () => { it('should create SubController', () => { const subController = new SubController(dummyAgile); - expect(subController.callbackSubs.size).toBe(0); - expect(subController.callbackSubs.size).toBe(0); + expect(subController.agileInstance()).toBe(dummyAgile); + expect(Array.from(subController.callbackSubs)).toStrictEqual([]); + expect(Array.from(subController.componentSubs)).toStrictEqual([]); }); describe('SubController Function Tests', () => { @@ -32,430 +32,394 @@ describe('SubController Tests', () => { let dummyObserver2: Observer; beforeEach(() => { - dummyObserver1 = new Observer(dummyAgile, { key: 'dummyObserver1' }); - dummyObserver2 = new Observer(dummyAgile, { key: 'dummyObserver2' }); - subController = new SubController(dummyAgile); - }); - - describe('subscribeWithSubsObject function tests', () => { - const dummyIntegration = 'myDummyIntegration'; - let dummySubscriptionContainer: SubscriptionContainer; - - beforeEach(() => { - dummySubscriptionContainer = new SubscriptionContainer(); - dummyObserver1.value = 'myCoolValue'; - - subController.registerSubscription = jest.fn( - () => dummySubscriptionContainer - ); - jest.spyOn(dummyObserver1, 'subscribe'); - jest.spyOn(dummyObserver2, 'subscribe'); + dummyObserver1 = new Observer(dummyAgile, { + key: 'dummyObserver1', + value: 'dummyObserver1Value', }); - - it('should create subscriptionContainer and add in Object shape passed Observers to it', () => { - const subscribeWithSubsResponse = subController.subscribeWithSubsObject( - dummyIntegration, - { - dummyObserver1: dummyObserver1, - dummyObserver2: dummyObserver2, - }, - { - key: 'subscribeWithSubsObjectKey', - proxyKeyMap: {}, - waitForMount: false, - } - ); - - expect(subscribeWithSubsResponse).toStrictEqual({ - props: { - dummyObserver1: 'myCoolValue', - }, - subscriptionContainer: dummySubscriptionContainer, - }); - - expect(subController.registerSubscription).toHaveBeenCalledWith( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { - key: 'subscribeWithSubsObjectKey', - proxyKeyMap: {}, - waitForMount: false, - } - ); - - expect(dummySubscriptionContainer.isObjectBased).toBeTruthy(); - expect(dummySubscriptionContainer.subsObject).toStrictEqual({ - dummyObserver1: dummyObserver1, - dummyObserver2: dummyObserver2, - }); - - expect(dummySubscriptionContainer.subscribers.size).toBe(2); - expect( - dummySubscriptionContainer.subscribers.has(dummyObserver1) - ).toBeTruthy(); - expect( - dummySubscriptionContainer.subscribers.has(dummyObserver2) - ).toBeTruthy(); - - expect(dummyObserver1.subscribe).toHaveBeenCalledWith( - dummySubscriptionContainer - ); - expect(dummyObserver2.subscribe).toHaveBeenCalledWith( - dummySubscriptionContainer - ); + dummyObserver2 = new Observer(dummyAgile, { + key: 'dummyObserver2', + value: 'dummyObserver2Value', }); + subController = new SubController(dummyAgile); }); - describe('subscribeWithSubsArray function tests', () => { - const dummyIntegration = 'myDummyIntegration'; - let dummySubscriptionContainer: SubscriptionContainer; - + describe('subscribe function tests', () => { beforeEach(() => { - dummySubscriptionContainer = new SubscriptionContainer(); - - subController.registerSubscription = jest.fn( - () => dummySubscriptionContainer - ); - jest.spyOn(dummyObserver1, 'subscribe'); - jest.spyOn(dummyObserver2, 'subscribe'); + jest.spyOn(subController, 'createCallbackSubscriptionContainer'); + jest.spyOn(subController, 'createComponentSubscriptionContainer'); }); - it('should create subscriptionContainer and add in Array Shape passed Observers to it', () => { - const subscribeWithSubsArrayResponse = subController.subscribeWithSubsArray( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { - key: 'subscribeWithSubsArrayKey', - proxyKeyMap: {}, - waitForMount: false, - } - ); - - expect(subscribeWithSubsArrayResponse).toBe(dummySubscriptionContainer); - - expect(subController.registerSubscription).toHaveBeenCalledWith( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { - key: 'subscribeWithSubsArrayKey', - proxyKeyMap: {}, - waitForMount: false, - } - ); - - expect(dummySubscriptionContainer.isObjectBased).toBeFalsy(); - expect(dummySubscriptionContainer.subsObject).toBeUndefined(); - - expect(dummySubscriptionContainer.subscribers.size).toBe(2); - expect( - dummySubscriptionContainer.subscribers.has(dummyObserver1) - ).toBeTruthy(); - expect( - dummySubscriptionContainer.subscribers.has(dummyObserver2) - ).toBeTruthy(); - - expect(dummyObserver1.subscribe).toHaveBeenCalledWith( - dummySubscriptionContainer - ); - expect(dummyObserver2.subscribe).toHaveBeenCalledWith( - dummySubscriptionContainer - ); - }); + it( + 'should create a Component based Subscription Container with specified Component Instance ' + + 'and assign the in an object specified Observers to it', + () => { + dummyAgile.config.waitForMount = 'aFakeBoolean' as any; + const dummyIntegration: any = { + dummy: 'integration', + }; + + const returnValue = subController.subscribe( + dummyIntegration, + { observer1: dummyObserver1, observer2: dummyObserver2 }, + { + key: 'subscriptionContainerKey', + componentId: 'testID', + waitForMount: true, + } + ); + + expect(returnValue.subscriptionContainer).toBeInstanceOf( + ComponentSubscriptionContainer + ); + expect(returnValue.props).toStrictEqual({ + observer1: dummyObserver1.value, + observer2: dummyObserver2.value, + }); + + expect( + subController.createComponentSubscriptionContainer + ).toHaveBeenCalledWith( + dummyIntegration, + { observer1: dummyObserver1, observer2: dummyObserver2 }, + { + key: 'subscriptionContainerKey', + componentId: 'testID', + waitForMount: true, + } + ); + expect( + subController.createCallbackSubscriptionContainer + ).not.toHaveBeenCalled(); + } + ); + + it( + 'should create a Component based Subscription Container with specified Component Instance ' + + 'and assign the in an array specified Observers to it', + () => { + dummyAgile.config.waitForMount = 'aFakeBoolean' as any; + const dummyIntegration: any = { + dummy: 'integration', + }; + + const returnValue = subController.subscribe( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { key: 'subscriptionContainerKey', componentId: 'testID' } + ); + + expect(returnValue).toBeInstanceOf(ComponentSubscriptionContainer); + + expect( + subController.createComponentSubscriptionContainer + ).toHaveBeenCalledWith( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { + key: 'subscriptionContainerKey', + componentId: 'testID', + waitForMount: 'aFakeBoolean', + } + ); + expect( + subController.createCallbackSubscriptionContainer + ).not.toHaveBeenCalled(); + } + ); + + it( + 'should create a Callback based Subscription Container with specified callback function ' + + 'and assign the in an object specified Observers to it', + () => { + dummyAgile.config.waitForMount = 'aFakeBoolean' as any; + const dummyIntegration = () => { + /* empty function */ + }; + + const returnValue = subController.subscribe( + dummyIntegration, + { observer1: dummyObserver1, observer2: dummyObserver2 }, + { + key: 'subscriptionContainerKey', + componentId: 'testID', + } + ); + + expect(returnValue.subscriptionContainer).toBeInstanceOf( + CallbackSubscriptionContainer + ); + expect(returnValue.props).toStrictEqual({ + observer1: dummyObserver1.value, + observer2: dummyObserver2.value, + }); + + expect( + subController.createCallbackSubscriptionContainer + ).toHaveBeenCalledWith( + dummyIntegration, + { observer1: dummyObserver1, observer2: dummyObserver2 }, + { + key: 'subscriptionContainerKey', + componentId: 'testID', + waitForMount: 'aFakeBoolean', + } + ); + expect( + subController.createComponentSubscriptionContainer + ).not.toHaveBeenCalled(); + } + ); + + it( + 'should create a Callback based Subscription Container with specified callback function ' + + 'and assign the in an array specified Observers to it', + () => { + dummyAgile.config.waitForMount = 'aFakeBoolean' as any; + const dummyIntegration = () => { + /* empty function */ + }; + + const returnValue = subController.subscribe( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { + key: 'subscriptionContainerKey', + componentId: 'testID', + waitForMount: false, + } + ); + + expect(returnValue).toBeInstanceOf(CallbackSubscriptionContainer); + + expect( + subController.createCallbackSubscriptionContainer + ).toHaveBeenCalledWith( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { + key: 'subscriptionContainerKey', + componentId: 'testID', + waitForMount: false, + } + ); + expect( + subController.createComponentSubscriptionContainer + ).not.toHaveBeenCalled(); + } + ); }); describe('unsubscribe function tests', () => { - beforeEach(() => { - jest.spyOn(dummyObserver1, 'unsubscribe'); - jest.spyOn(dummyObserver2, 'unsubscribe'); - }); - - it('should unsubscribe callbackSubscriptionContainer', () => { + it('should unsubscribe Callback based Subscription Container', () => { const dummyIntegration = () => { /* empty function */ }; - const callbackSubscriptionContainer = subController.registerCallbackSubscription( + const callbackSubscriptionContainer = subController.createCallbackSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2] ); + callbackSubscriptionContainer.removeSubscription = jest.fn(); subController.unsubscribe(callbackSubscriptionContainer); - expect(subController.callbackSubs.size).toBe(0); + expect(Array.from(subController.callbackSubs)).toStrictEqual([]); expect(callbackSubscriptionContainer.ready).toBeFalsy(); - expect(dummyObserver1.unsubscribe).toHaveBeenCalledWith( - callbackSubscriptionContainer - ); - expect(dummyObserver2.unsubscribe).toHaveBeenCalledWith( - callbackSubscriptionContainer - ); + expect( + callbackSubscriptionContainer.removeSubscription + ).toHaveBeenCalledTimes(2); + expect( + callbackSubscriptionContainer.removeSubscription + ).toHaveBeenCalledWith(dummyObserver1); + expect( + callbackSubscriptionContainer.removeSubscription + ).toHaveBeenCalledWith(dummyObserver2); }); - it('should unsubscribe componentSubscriptionContainer', () => { + it('should unsubscribe Component Subscription Container', () => { const dummyIntegration: any = { dummy: 'integration', }; - const componentSubscriptionContainer = subController.registerComponentSubscription( + const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2] ); + componentSubscriptionContainer.removeSubscription = jest.fn(); subController.unsubscribe(componentSubscriptionContainer); - expect(subController.componentSubs.size).toBe(0); - expect(componentSubscriptionContainer.ready).toBeFalsy(); - expect(dummyObserver1.unsubscribe).toHaveBeenCalledWith( - componentSubscriptionContainer - ); - expect(dummyObserver2.unsubscribe).toHaveBeenCalledWith( - componentSubscriptionContainer - ); - }); - - it('should unsubscribe componentSubscriptionContainer from passed Object that hold an instance of componentSubscriptionContainer', () => { - const dummyIntegration: any = { - dummy: 'integration', - }; - const componentSubscriptionContainer = subController.registerComponentSubscription( - dummyIntegration, - [dummyObserver1, dummyObserver2] - ); - - subController.unsubscribe(dummyIntegration); - - expect(subController.componentSubs.size).toBe(0); - expect(componentSubscriptionContainer.ready).toBeFalsy(); - expect(dummyObserver1.unsubscribe).toHaveBeenCalledWith( - componentSubscriptionContainer - ); - expect(dummyObserver2.unsubscribe).toHaveBeenCalledWith( - componentSubscriptionContainer - ); - }); - - it('should unsubscribe componentSubscriptionContainers from passed Object that hold an Array of componentSubscriptionContainers', () => { - const dummyIntegration: any = { - dummy: 'integration', - componentSubscriptionContainers: [], - }; - const componentSubscriptionContainer = subController.registerComponentSubscription( - dummyIntegration, - [dummyObserver1, dummyObserver2] - ); - const componentSubscriptionContainer2 = subController.registerComponentSubscription( - dummyIntegration, - [dummyObserver1, dummyObserver2] - ); - - subController.unsubscribe(dummyIntegration); - - expect(subController.componentSubs.size).toBe(0); - + expect(Array.from(subController.componentSubs)).toStrictEqual([]); expect(componentSubscriptionContainer.ready).toBeFalsy(); - expect(dummyObserver1.unsubscribe).toHaveBeenCalledWith( - componentSubscriptionContainer - ); - expect(dummyObserver2.unsubscribe).toHaveBeenCalledWith( - componentSubscriptionContainer - ); - - expect(componentSubscriptionContainer2.ready).toBeFalsy(); - expect(dummyObserver1.unsubscribe).toHaveBeenCalledWith( - componentSubscriptionContainer2 - ); - expect(dummyObserver2.unsubscribe).toHaveBeenCalledWith( - componentSubscriptionContainer2 - ); - }); - }); - - describe('registerSubscription function tests', () => { - let dummySubscriptionContainer: SubscriptionContainer; - - beforeEach(() => { - dummySubscriptionContainer = new SubscriptionContainer(); - dummyAgile.config.waitForMount = 'dummyWaitForMount' as any; - - subController.registerCallbackSubscription = jest.fn( - () => dummySubscriptionContainer as CallbackSubscriptionContainer - ); - subController.registerComponentSubscription = jest.fn( - () => dummySubscriptionContainer as ComponentSubscriptionContainer - ); - }); - - it('should call registerCallbackSubscription if passed integrationInstance is a Function (default config)', () => { - const dummyIntegration = () => { - /* empty function */ - }; - - const subscriptionContainer = subController.registerSubscription( - dummyIntegration, - [dummyObserver1, dummyObserver2] - ); - - expect(subscriptionContainer).toBe(dummySubscriptionContainer); expect( - subController.registerCallbackSubscription - ).toHaveBeenCalledWith( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { waitForMount: dummyAgile.config.waitForMount } - ); - expect( - subController.registerComponentSubscription - ).not.toHaveBeenCalled(); - }); - - it('should call registerCallbackSubscription if passed integrationInstance is a Function (specific config)', () => { - const dummyIntegration = () => { - /* empty function */ - }; - - const subscriptionContainer = subController.registerSubscription( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { key: 'niceKey', proxyKeyMap: {}, waitForMount: false } - ); - - expect(subscriptionContainer).toBe(dummySubscriptionContainer); + componentSubscriptionContainer.removeSubscription + ).toHaveBeenCalledTimes(2); expect( - subController.registerCallbackSubscription - ).toHaveBeenCalledWith( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { key: 'niceKey', proxyKeyMap: {}, waitForMount: false } - ); + componentSubscriptionContainer.removeSubscription + ).toHaveBeenCalledWith(dummyObserver1); expect( - subController.registerComponentSubscription - ).not.toHaveBeenCalled(); + componentSubscriptionContainer.removeSubscription + ).toHaveBeenCalledWith(dummyObserver2); }); - it('should call registerComponentSubscription if passed integrationInstance is not a Function (default config)', () => { - const dummyIntegration = { dummy: 'integration' }; - - const subscriptionContainer = subController.registerSubscription( - dummyIntegration, - [dummyObserver1, dummyObserver2] - ); - - expect(subscriptionContainer).toBe(dummySubscriptionContainer); - expect( - subController.registerComponentSubscription - ).toHaveBeenCalledWith( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { waitForMount: dummyAgile.config.waitForMount } - ); - expect( - subController.registerCallbackSubscription - ).not.toHaveBeenCalled(); - }); - - it('should call registerComponentSubscription if passed integrationInstance is not a Function (specific config)', () => { - const dummyIntegration = { dummy: 'integration' }; - - const subscriptionContainer = subController.registerSubscription( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { key: 'niceKey', proxyKeyMap: {}, waitForMount: false } - ); - - expect(subscriptionContainer).toBe(dummySubscriptionContainer); - expect( - subController.registerComponentSubscription - ).toHaveBeenCalledWith( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { key: 'niceKey', proxyKeyMap: {}, waitForMount: false } - ); - expect( - subController.registerCallbackSubscription - ).not.toHaveBeenCalled(); - }); + it( + 'should unsubscribe Component based Subscription Container ' + + 'from specified object (UI-Component) that contains an instance of the Component Subscription Container', + () => { + const dummyIntegration: any = { + dummy: 'integration', + componentSubscriptionContainers: [], + }; + const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( + dummyIntegration, + [dummyObserver1, dummyObserver2] + ); + componentSubscriptionContainer.removeSubscription = jest.fn(); + const componentSubscriptionContainer2 = subController.createComponentSubscriptionContainer( + dummyIntegration, + [dummyObserver1, dummyObserver2] + ); + componentSubscriptionContainer2.removeSubscription = jest.fn(); + + subController.unsubscribe(dummyIntegration); + + expect(Array.from(subController.componentSubs)).toStrictEqual([]); + + expect(componentSubscriptionContainer.ready).toBeFalsy(); + expect( + componentSubscriptionContainer.removeSubscription + ).toHaveBeenCalledTimes(2); + expect( + componentSubscriptionContainer.removeSubscription + ).toHaveBeenCalledWith(dummyObserver1); + expect( + componentSubscriptionContainer.removeSubscription + ).toHaveBeenCalledWith(dummyObserver2); + + expect(componentSubscriptionContainer2.ready).toBeFalsy(); + expect( + componentSubscriptionContainer2.removeSubscription + ).toHaveBeenCalledTimes(2); + expect( + componentSubscriptionContainer2.removeSubscription + ).toHaveBeenCalledWith(dummyObserver1); + expect( + componentSubscriptionContainer2.removeSubscription + ).toHaveBeenCalledWith(dummyObserver2); + } + ); }); - describe('registerComponentSubscription function tests', () => { - it('should return ready componentSubscriptionContainer and add it to dummyIntegration at componentSubscriptionContainer (config.waitForMount = false)', () => { - const dummyIntegration: any = { dummy: 'integration' }; - - const componentSubscriptionContainer = subController.registerComponentSubscription( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { waitForMount: false } - ); - - expect(componentSubscriptionContainer).toBeInstanceOf( - ComponentSubscriptionContainer - ); - expect(componentSubscriptionContainer.component).toStrictEqual( - dummyIntegration - ); - expect(componentSubscriptionContainer.ready).toBeTruthy(); - - expect(componentSubscriptionContainer.subscribers.size).toBe(2); - expect( - componentSubscriptionContainer.subscribers.has(dummyObserver1) - ).toBeTruthy(); - expect( - componentSubscriptionContainer.subscribers.has(dummyObserver2) - ).toBeTruthy(); - - expect(subController.componentSubs.size).toBe(1); - expect( - subController.componentSubs.has(componentSubscriptionContainer) - ).toBeTruthy(); - - expect(dummyIntegration.componentSubscriptionContainer).toBe( - componentSubscriptionContainer - ); - }); - - it('should return ready componentSubscriptionContainer and add it to componentSubscriptions in dummyIntegration (config.waitForMount = false)', () => { - const dummyIntegration: any = { - dummy: 'integration', - componentSubscriptionContainers: [], - }; - - const componentSubscriptionContainer = subController.registerComponentSubscription( - dummyIntegration, - [dummyObserver1, dummyObserver2], - { waitForMount: false } - ); - - expect(componentSubscriptionContainer).toBeInstanceOf( - ComponentSubscriptionContainer - ); - expect(componentSubscriptionContainer.component).toStrictEqual( - dummyIntegration - ); - expect(componentSubscriptionContainer.ready).toBeTruthy(); - - expect(componentSubscriptionContainer.subscribers.size).toBe(2); - expect( - componentSubscriptionContainer.subscribers.has(dummyObserver1) - ).toBeTruthy(); - expect( - componentSubscriptionContainer.subscribers.has(dummyObserver2) - ).toBeTruthy(); - - expect(subController.componentSubs.size).toBe(1); - expect( - subController.componentSubs.has(componentSubscriptionContainer) - ).toBeTruthy(); - - expect(dummyIntegration.componentSubscriptionContainers.length).toBe(1); - expect(dummyIntegration.componentSubscriptionContainers[0]).toBe( - componentSubscriptionContainer - ); - expect(dummyIntegration.componentSubscriptionContainer).toBeUndefined(); - }); - - it("should return not ready componentSubscriptionContainer if componentInstance isn't mounted (waitForMount = true)", () => { + describe('createComponentSubscriptionContainer function tests', () => { + it( + 'should return ready Component based Subscription Container ' + + "and add an instance of it to the not existing 'componentSubscriptions' property " + + 'in the dummyIntegration (default config)', + () => { + jest.spyOn(Utils, 'generateId').mockReturnValueOnce('generatedKey'); + const dummyIntegration: any = { + dummy: 'integration', + }; + + const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { waitForMount: false } + ); + + expect(componentSubscriptionContainer).toBeInstanceOf( + ComponentSubscriptionContainer + ); + expect(componentSubscriptionContainer.component).toStrictEqual( + dummyIntegration + ); + expect(componentSubscriptionContainer.ready).toBeTruthy(); + + expect(Array.from(subController.componentSubs)).toStrictEqual([ + componentSubscriptionContainer, + ]); + + expect( + dummyIntegration.componentSubscriptionContainers + ).toStrictEqual([componentSubscriptionContainer]); + + // Check if ComponentSubscriptionContainer was called with correct parameters + expect(componentSubscriptionContainer.key).toBe('generatedKey'); + expect(componentSubscriptionContainer.componentId).toBeUndefined(); + expect( + Array.from(componentSubscriptionContainer.subscribers) + ).toStrictEqual([dummyObserver1, dummyObserver2]); + } + ); + + it( + 'should return ready Component based Subscription Container ' + + "and add an instance of it to the existing 'componentSubscriptions' property " + + 'in the dummyIntegration (default config)', + () => { + jest.spyOn(Utils, 'generateId').mockReturnValueOnce('generatedKey'); + const dummyIntegration: any = { + dummy: 'integration', + componentSubscriptionContainers: [], + }; + + const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { waitForMount: false } + ); + + expect( + dummyIntegration.componentSubscriptionContainers + ).toStrictEqual([componentSubscriptionContainer]); + } + ); + + it( + 'should return ready Component based Subscription Container ' + + "and add an instance of it to the not existing 'componentSubscriptions' property " + + 'in the dummyIntegration (specific config)', + () => { + const dummyIntegration: any = { + dummy: 'integration', + }; + + const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( + dummyIntegration, + [dummyObserver1, dummyObserver2], + { waitForMount: false, componentId: 'testID', key: 'dummyKey' } + ); + + expect(componentSubscriptionContainer).toBeInstanceOf( + ComponentSubscriptionContainer + ); + expect(componentSubscriptionContainer.component).toStrictEqual( + dummyIntegration + ); + expect(componentSubscriptionContainer.ready).toBeTruthy(); + + expect(Array.from(subController.componentSubs)).toStrictEqual([ + componentSubscriptionContainer, + ]); + + expect( + dummyIntegration.componentSubscriptionContainers + ).toStrictEqual([componentSubscriptionContainer]); + + // Check if ComponentSubscriptionContainer was called with correct parameters + expect(componentSubscriptionContainer.key).toBe('dummyKey'); + expect(componentSubscriptionContainer.componentId).toBe('testID'); + expect( + Array.from(componentSubscriptionContainer.subscribers) + ).toStrictEqual([dummyObserver1, dummyObserver2]); + } + ); + + it("should return not ready Component based Subscription Container if componentInstance isn't mounted (config.waitForMount = true)", () => { + jest.spyOn(Utils, 'generateId').mockReturnValueOnce('generatedKey'); const dummyIntegration: any = { dummy: 'integration', }; - const componentSubscriptionContainer = subController.registerComponentSubscription( + const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2], { waitForMount: true } @@ -469,27 +433,26 @@ describe('SubController Tests', () => { ); expect(componentSubscriptionContainer.ready).toBeFalsy(); - expect(componentSubscriptionContainer.subscribers.size).toBe(2); - expect( - componentSubscriptionContainer.subscribers.has(dummyObserver1) - ).toBeTruthy(); - expect( - componentSubscriptionContainer.subscribers.has(dummyObserver2) - ).toBeTruthy(); + expect(Array.from(subController.componentSubs)).toStrictEqual([ + componentSubscriptionContainer, + ]); - expect(subController.componentSubs.size).toBe(1); + // Check if ComponentSubscriptionContainer was called with correct parameters + expect(componentSubscriptionContainer.key).toBe('generatedKey'); + expect(componentSubscriptionContainer.componentId).toBeUndefined(); expect( - subController.componentSubs.has(componentSubscriptionContainer) - ).toBeTruthy(); + Array.from(componentSubscriptionContainer.subscribers) + ).toStrictEqual([dummyObserver1, dummyObserver2]); }); - it('should return ready componentSubscriptionContainer if componentInstance is mounted (config.waitForMount = true)', () => { + it('should return ready Component based Subscription Container if componentInstance is mounted (config.waitForMount = true)', () => { + jest.spyOn(Utils, 'generateId').mockReturnValueOnce('generatedKey'); const dummyIntegration: any = { dummy: 'integration', }; subController.mount(dummyIntegration); - const componentSubscriptionContainer = subController.registerComponentSubscription( + const componentSubscriptionContainer = subController.createComponentSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2], { waitForMount: true } @@ -503,29 +466,27 @@ describe('SubController Tests', () => { ); expect(componentSubscriptionContainer.ready).toBeTruthy(); - expect(componentSubscriptionContainer.subscribers.size).toBe(2); - expect( - componentSubscriptionContainer.subscribers.has(dummyObserver1) - ).toBeTruthy(); - expect( - componentSubscriptionContainer.subscribers.has(dummyObserver2) - ).toBeTruthy(); + expect(Array.from(subController.componentSubs)).toStrictEqual([ + componentSubscriptionContainer, + ]); - expect(subController.componentSubs.size).toBe(1); + // Check if ComponentSubscriptionContainer was called with correct parameters + expect(componentSubscriptionContainer.key).toBe('generatedKey'); + expect(componentSubscriptionContainer.componentId).toBeUndefined(); expect( - subController.componentSubs.has(componentSubscriptionContainer) - ).toBeTruthy(); + Array.from(componentSubscriptionContainer.subscribers) + ).toStrictEqual([dummyObserver1, dummyObserver2]); }); }); describe('registerCallbackSubscription function tests', () => { - it('should return callbackSubscriptionContainer (default config)', () => { - jest.spyOn(Utils, 'generateId').mockReturnValueOnce('randomKey'); + it('should return Callback based Subscription Container (default config)', () => { + jest.spyOn(Utils, 'generateId').mockReturnValueOnce('generatedKey'); const dummyIntegration = () => { /* empty function */ }; - const callbackSubscriptionContainer = subController.registerCallbackSubscription( + const callbackSubscriptionContainer = subController.createCallbackSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2] ); @@ -536,42 +497,35 @@ describe('SubController Tests', () => { expect(callbackSubscriptionContainer.callback).toBe(dummyIntegration); expect(callbackSubscriptionContainer.ready).toBeTruthy(); - // TODO find a way to spy on a class constructor without overwriting it - // https://stackoverflow.com/questions/48219267/how-to-spy-on-a-class-constructor-jest/48486214 - // Because the below tests are not really related to this test, - // they are checking if the CallbackSubscriptionContainer got called with the right parameters - // by checking if CallbackSubscriptionContainer has set its properties correctly - // Note:This 'issue' happens in multiple parts of the AgileTs test - expect(callbackSubscriptionContainer.key).toBe('randomKey'); - expect(callbackSubscriptionContainer.proxyKeyMap).toStrictEqual({}); - expect(callbackSubscriptionContainer.proxyBased).toBeFalsy(); - - expect(callbackSubscriptionContainer.subscribers.size).toBe(2); - expect( - callbackSubscriptionContainer.subscribers.has(dummyObserver1) - ).toBeTruthy(); - expect( - callbackSubscriptionContainer.subscribers.has(dummyObserver2) - ).toBeTruthy(); + expect(Array.from(subController.callbackSubs)).toStrictEqual([ + callbackSubscriptionContainer, + ]); - expect(subController.callbackSubs.size).toBe(1); + // TODO find a way to spy on a class constructor without overwriting it. + // https://stackoverflow.com/questions/48219267/how-to-spy-on-a-class-constructor-jest/48486214 + // Because the below tests are not really related to this test. + // They are checking if the CallbackSubscriptionContainer was called with the correct parameters + // by checking if the CallbackSubscriptionContainer has correctly set properties. + // Note: This 'issue' happens in multiple parts of the AgileTs test! + expect(callbackSubscriptionContainer.key).toBe('generatedKey'); + expect(callbackSubscriptionContainer.componentId).toBeUndefined(); expect( - subController.callbackSubs.has(callbackSubscriptionContainer) - ).toBeTruthy(); + Array.from(callbackSubscriptionContainer.subscribers) + ).toStrictEqual([dummyObserver1, dummyObserver2]); }); - it('should return callbackSubscriptionContainer (specific config)', () => { + it('should return Callback based Subscription Container (specific config)', () => { const dummyIntegration = () => { /* empty function */ }; - const callbackSubscriptionContainer = subController.registerCallbackSubscription( + const callbackSubscriptionContainer = subController.createCallbackSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2], { waitForMount: false, - proxyKeyMap: { jeff: { paths: [[]] } }, - key: 'jeff', + componentId: 'testID', + key: 'dummyKey', } ); @@ -580,24 +534,17 @@ describe('SubController Tests', () => { ); expect(callbackSubscriptionContainer.callback).toBe(dummyIntegration); expect(callbackSubscriptionContainer.ready).toBeTruthy(); - expect(callbackSubscriptionContainer.key).toBe('jeff'); - expect(callbackSubscriptionContainer.proxyKeyMap).toStrictEqual({ - jeff: { paths: [[]] }, - }); - expect(callbackSubscriptionContainer.proxyBased).toBeTruthy(); - expect(callbackSubscriptionContainer.subscribers.size).toBe(2); - expect( - callbackSubscriptionContainer.subscribers.has(dummyObserver1) - ).toBeTruthy(); - expect( - callbackSubscriptionContainer.subscribers.has(dummyObserver2) - ).toBeTruthy(); + expect(Array.from(subController.callbackSubs)).toStrictEqual([ + callbackSubscriptionContainer, + ]); - expect(subController.callbackSubs.size).toBe(1); + // Check if CallbackSubscriptionContainer was called with correct parameters + expect(callbackSubscriptionContainer.key).toBe('dummyKey'); + expect(callbackSubscriptionContainer.componentId).toBe('testID'); expect( - subController.callbackSubs.has(callbackSubscriptionContainer) - ).toBeTruthy(); + Array.from(callbackSubscriptionContainer.subscribers) + ).toStrictEqual([dummyObserver1, dummyObserver2]); }); }); @@ -609,21 +556,24 @@ describe('SubController Tests', () => { beforeEach(() => { dummyAgile.config.waitForMount = true; - componentSubscriptionContainer = subController.registerComponentSubscription( + componentSubscriptionContainer = subController.createComponentSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2] ); }); - it('should add componentInstance to mountedComponents and set its subscriptionContainer to ready', () => { - subController.mount(dummyIntegration); - - expect(componentSubscriptionContainer.ready).toBeTruthy(); - expect(subController.mountedComponents.size).toBe(1); - expect( - subController.mountedComponents.has(dummyIntegration) - ).toBeTruthy(); - }); + it( + "should add specified 'componentInstance' to the 'mountedComponents' " + + 'and set the Subscription Container representing the mounted Component to ready', + () => { + subController.mount(dummyIntegration); + + expect(componentSubscriptionContainer.ready).toBeTruthy(); + expect(Array.from(subController.mountedComponents)).toStrictEqual([ + dummyIntegration, + ]); + } + ); }); describe('unmount function tests', () => { @@ -634,19 +584,23 @@ describe('SubController Tests', () => { beforeEach(() => { dummyAgile.config.waitForMount = true; - componentSubscriptionContainer = subController.registerComponentSubscription( + componentSubscriptionContainer = subController.createComponentSubscriptionContainer( dummyIntegration, [dummyObserver1, dummyObserver2] ); subController.mount(dummyIntegration); }); - it('should remove componentInstance from mountedComponents and set its subscriptionContainer to not ready', () => { - subController.unmount(dummyIntegration); + it( + "should remove specified 'componentInstance' to the 'mountedComponents' " + + 'and set the Subscription Container representing the mounted Component to not ready', + () => { + subController.unmount(dummyIntegration); - expect(componentSubscriptionContainer.ready).toBeFalsy(); - expect(subController.mountedComponents.size).toBe(0); - }); + expect(componentSubscriptionContainer.ready).toBeFalsy(); + expect(Array.from(subController.mountedComponents)).toStrictEqual([]); + } + ); }); }); }); diff --git a/packages/event/src/hooks/useEvent.ts b/packages/event/src/hooks/useEvent.ts index 1bb4e371..905d6413 100644 --- a/packages/event/src/hooks/useEvent.ts +++ b/packages/event/src/hooks/useEvent.ts @@ -27,7 +27,7 @@ export function useEvent>( } // Create Callback based Subscription - const subscriptionContainer = agileInstance.subController.subscribeWithSubsArray( + const subscriptionContainer = agileInstance.subController.subscribe( () => { forceRender(); }, diff --git a/packages/react/src/hocs/AgileHOC.ts b/packages/react/src/hocs/AgileHOC.ts index 8249b15c..189271d9 100644 --- a/packages/react/src/hocs/AgileHOC.ts +++ b/packages/react/src/hocs/AgileHOC.ts @@ -109,16 +109,14 @@ const createHOC = ( UNSAFE_componentWillMount() { // Create Subscription with Observer that have no Indicator and can't be merged into 'this.state' (Rerender will be caused via force Update) if (depsWithoutIndicator.length > 0) { - this.agileInstance.subController.subscribeWithSubsArray( - this, - depsWithoutIndicator, - { waitForMount: this.waitForMount } - ); + this.agileInstance.subController.subscribe(this, depsWithoutIndicator, { + waitForMount: this.waitForMount, + }); } // Create Subscription with Observer that have an Indicator (Rerender will be cause via mutating 'this.state') if (depsWithIndicator) { - const response = this.agileInstance.subController.subscribeWithSubsObject( + const response = this.agileInstance.subController.subscribe( this, depsWithIndicator, { waitForMount: this.waitForMount } diff --git a/packages/react/src/hooks/useAgile.ts b/packages/react/src/hooks/useAgile.ts index 7061ed61..bafcce1d 100644 --- a/packages/react/src/hooks/useAgile.ts +++ b/packages/react/src/hooks/useAgile.ts @@ -10,8 +10,9 @@ import { SubscriptionContainerKeyType, defineConfig, isValidObject, - ProxyKeyMapInterface, generateId, + ProxyWeakMapType, + ComponentIdType, } from '@agile-ts/core'; import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect'; import { ProxyTree } from '@agile-ts/proxytree'; @@ -47,37 +48,30 @@ export function useAgile< config: AgileHookConfigInterface = {} ): AgileHookArrayType | AgileHookType { const depsArray = extractObservers(deps); - const proxyTreeMap: ProxyTreeMapInterface = {}; + const proxyTreeWeakMap = new WeakMap(); config = defineConfig(config, { proxyBased: false, key: generateId(), agileInstance: null, }); - // Creates Return Value of Hook, depending if deps are in Array shape or not + // Creates Return Value of Hook, depending whether deps are in Array shape or not const getReturnValue = ( depsArray: (Observer | undefined)[] ): AgileHookArrayType | AgileHookType => { - const handleReturn = ( - dep: State | Observer | undefined - ): AgileHookType => { - const value = dep?.value; - const depKey = dep?.key; + const handleReturn = (dep: Observer | undefined): AgileHookType => { + if (dep == null) return undefined as any; + const value = dep.value; - // If proxyBased and value is object wrap Proxy around it to track used properties + // If proxyBased and value is of type object. + // Wrap a Proxy around the object to track the used properties if (config.proxyBased && isValidObject(value, true)) { - if (depKey) { - const proxyTree = new ProxyTree(value); - proxyTreeMap[depKey] = proxyTree; - return proxyTree.proxy; - } - Agile.logger.warn( - 'Keep in mind that without a key no Proxy can be wrapped around the dependency value!', - dep - ); + const proxyTree = new ProxyTree(value); + proxyTreeWeakMap.set(dep, proxyTree); + return proxyTree.proxy; } - return dep?.value; + return value; }; // Handle single dep @@ -85,7 +79,7 @@ export function useAgile< return handleReturn(depsArray[0]); } - // Handle dep array + // Handle deps array return depsArray.map((dep) => { return handleReturn(dep); }) as AgileHookArrayType; @@ -112,24 +106,35 @@ export function useAgile< (dep): dep is Observer => dep !== undefined ); - // Build Proxy Key Map - const proxyKeyMap: ProxyKeyMapInterface = {}; + // Build Proxy Path WeakMap Map based on the Proxy Tree WeakMap + // by extracting the routes of the Tree + // Building the Path WeakMap in the 'useIsomorphicLayoutEffect' + // because the 'useIsomorphicLayoutEffect' is called after the rerender + // -> In the Component used paths got successfully tracked + const proxyWeakMap: ProxyWeakMapType = new WeakMap(); if (config.proxyBased) { - for (const proxyTreeKey in proxyTreeMap) { - const proxyTree = proxyTreeMap[proxyTreeKey]; - proxyKeyMap[proxyTreeKey] = { - paths: proxyTree.getUsedRoutes() as any, - }; + for (const observer of observers) { + const proxyTree = proxyTreeWeakMap.get(observer); + if (proxyTree != null) { + proxyWeakMap.set(observer, { + paths: proxyTree.getUsedRoutes() as any, + }); + } } } // Create Callback based Subscription - const subscriptionContainer = agileInstance.subController.subscribeWithSubsArray( + const subscriptionContainer = agileInstance.subController.subscribe( () => { forceRender(); }, observers, - { key: config.key, proxyKeyMap, waitForMount: false } + { + key: config.key, + proxyWeakMap, + waitForMount: false, + componentId: config.componentId, + } ); // Unsubscribe Callback based Subscription on Unmount @@ -185,6 +190,7 @@ interface AgileHookConfigInterface { key?: SubscriptionContainerKeyType; agileInstance?: Agile; proxyBased?: boolean; + componentId?: ComponentIdType; } interface ProxyTreeMapInterface { diff --git a/packages/vue/src/bindAgileInstances.ts b/packages/vue/src/bindAgileInstances.ts index 0e89bdc9..451c759d 100644 --- a/packages/vue/src/bindAgileInstances.ts +++ b/packages/vue/src/bindAgileInstances.ts @@ -27,20 +27,16 @@ export function bindAgileInstances( // Create Subscription with Observer that have no Indicator and can't be merged into the 'sharedState' (Rerender will be caused via force Update) if (depsWithoutIndicator.length > 0) { - agile.subController.subscribeWithSubsArray( - vueComponent, - depsWithoutIndicator, - { waitForMount: false } - ); + agile.subController.subscribe(vueComponent, depsWithoutIndicator, { + waitForMount: false, + }); } // Create Subscription with Observer that have an Indicator (Rerender will be cause via mutating 'this.$data.sharedState') if (depsWithIndicator) { - return agile.subController.subscribeWithSubsObject( - vueComponent, - depsWithIndicator, - { waitForMount: false } - ).props; + return agile.subController.subscribe(vueComponent, depsWithIndicator, { + waitForMount: false, + }).props; } return {};