From 2d9cf78c5e04402ed9f727f4034bc6353fd2817e Mon Sep 17 00:00:00 2001 From: Jorge Ramirez Date: Thu, 19 Dec 2024 16:03:29 -0500 Subject: [PATCH 1/3] feat: Add PubSub utility --- package.json | 3 +- src/utilities/pub-sub/README.md | 76 ++++++++++++++ src/utilities/pub-sub/pub-sub.js | 33 ++++++ test/utilities/pub-sub/pub-sub.test.js | 135 +++++++++++++++++++++++++ 4 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 src/utilities/pub-sub/README.md create mode 100644 src/utilities/pub-sub/pub-sub.js create mode 100644 test/utilities/pub-sub/pub-sub.test.js diff --git a/package.json b/package.json index 9628486..192b56b 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "./components/view-toggle.js": "./src/components/view-toggle/view-toggle.js", "./controllers/computed-value.js": "./src/controllers/computed-values/computed-value.js", "./controllers/computed-values.js": "./src/controllers/computed-values/computed-values.js", - "./controllers/language-listener.js": "./src/controllers/language-listener/language-listener.js" + "./controllers/language-listener.js": "./src/controllers/language-listener/language-listener.js", + "./utilities/pub-sub.js": "./src/utilities/pub-sub/pub-sub.js" }, "scripts": { "langs:sync": "mfv add-missing && mfv remove-extraneous", diff --git a/src/utilities/pub-sub/README.md b/src/utilities/pub-sub/README.md new file mode 100644 index 0000000..ea7ca8a --- /dev/null +++ b/src/utilities/pub-sub/README.md @@ -0,0 +1,76 @@ +# PubSub + +A simple class implementation of the publish-subscribe model. + +## Simple Example + +```js +import PubSub from '@brightspace-ui/labs/utilites/pub-sub.js'; + +// Instantiate the PubSub class +const myPubSub = new PubSub(); + +// Register subscribers +const subscriber1 = (message) => console.log('Subscriber 1 received: ', message); +const subscriber2 = (message) => console.log('Subscriber 2 received: ', message); +myPubSub.subscribe(subscriber1); +myPubSub.subscribe(subscriber2); + +// Publish messages to subscribers +myPubSub.publish('Hello!'); +// Console: Subscriber 1 received: Hello! +// Console: Subscriber 2 received: Hello! + +// Unsubscribe +myPubSub.unsubscribe(subscriber1); +myPubSub.unsubscribe(subscriber2); +``` + +## Instance Methods + +### Constructor + +The constructor takes no arguments. + +### `clear()` + +The `clear` method unsubscribes all subscribed callback functions. Subscriber callback functions are not called at this time, just removed. + +This method accepts no arguments and returns no values. + +### `publish(...args)` + +The `publish` method is used to publish messages/data to all subscribed callbacks. All subscribed callback functions are called in subscription order with the same arguments passed to `publish`. + +| Parameter Name | Type | Description | Required | +|---|---|---|---| +| `...args` | Any | The arguments to be passed to subscriber callback functions when called. | No | + +`publish` returns no values. + +### `subscribe(callback, initialize = false)` + +The `subscribe` method is used to subscribe a callback function for future published messages. + +If the callback being subscribed is already subscribed, nothing will change and it will still only be called once when messages are published. + +By default, a subscribed callback function won't be called until a message is published, but if the `initialize` argument is set to `true` and the `PubSub` instance has published at least once, then callback function will be immediately called after subscription with the last published arguments. + +| Parameter Name | Type | Description | Required | Default Value | +|---|---|---|---|---| +| `callback` | Function | The callback function to be called when messages are published. | True | | +| `initialize` | Boolean | Whether or not to immedately call the callback function with the last published values. | False | `false` | + +`subscribe` returns no values. + +### `unsubscribe(callback)` + +The `unsubscribe` method is used to remove a callback function from the collection of subscribers so it no longer receives future published messages. + +If the callback function passed in does not match a currently subscribed function, nothing happens. + +| Parameter Name | Type | Description | Required | +|---|---|---|---| +| `callback` | Function | The callback function to remove from the subscribers. | True | + +`unsubscribe` returns no values. diff --git a/src/utilities/pub-sub/pub-sub.js b/src/utilities/pub-sub/pub-sub.js new file mode 100644 index 0000000..7500f4f --- /dev/null +++ b/src/utilities/pub-sub/pub-sub.js @@ -0,0 +1,33 @@ +/* + A simple pub-sub implementation. It allows for subscribing to the class and publishing to all subscribers. +*/ +export default class PubSub { + constructor() { + this._subscribers = new Map(); + this._hasTriggered = false; + this._previousArgs = []; + } + + clear() { + this._subscribers.clear(); + } + + publish(...args) { + this._subscribers.forEach(callback => callback(...args)); + this._hasTriggered = true; + this._previousArgs = args; + } + + // If initialize is true and publish has been called at least once, the callback will be called + // immediately with the last published arguments. + subscribe(callback, initialize = false) { + this._subscribers.set(callback, callback); + if (this._hasTriggered && initialize) { + callback(...this._previousArgs); + } + } + + unsubscribe(callback) { + this._subscribers.delete(callback); + } +} diff --git a/test/utilities/pub-sub/pub-sub.test.js b/test/utilities/pub-sub/pub-sub.test.js new file mode 100644 index 0000000..e31b086 --- /dev/null +++ b/test/utilities/pub-sub/pub-sub.test.js @@ -0,0 +1,135 @@ +import { expect } from '@brightspace-ui/testing'; +import PubSub from '../../../src/utilities/pub-sub/pub-sub'; + +class CallbackStub { + calls = []; + + constructor() { + this.callback = this.callback.bind(this); + } + + callback(...args) { + this.calls.push(args); + } +} + +describe('PubSub', () => { + let pubSub; + beforeEach(() => { + pubSub = new PubSub(); + }); + afterEach(() => { + pubSub.clear(); + }); + + describe('clear()', () => { + it('should remove all subscribers', () => { + const callbackStub1 = new CallbackStub(); + const callbackStub2 = new CallbackStub(); + + pubSub.subscribe(callbackStub1.callback); + pubSub.subscribe(callbackStub2.callback); + + pubSub.clear(); + pubSub.publish(); + + expect(callbackStub1.calls.length).to.equal(0); + expect(callbackStub2.calls.length).to.equal(0); + }); + }); + + describe('publish()', () => { + it('should call all subscribers with the provided arguments', () => { + const callbackStub1 = new CallbackStub(); + const callbackStub2 = new CallbackStub(); + + pubSub.subscribe(callbackStub1.callback); + pubSub.subscribe(callbackStub2.callback); + + pubSub.publish('arg1', 'arg2'); + + expect(callbackStub1.calls.length).to.equal(1); + expect(callbackStub1.calls[0]).to.deep.equal(['arg1', 'arg2']); + expect(callbackStub2.calls.length).to.equal(1); + expect(callbackStub2.calls[0]).to.deep.equal(['arg1', 'arg2']); + }); + + it('should remember the last arguments published', () => { + pubSub.publish('arg1', 'arg2'); + pubSub.publish('arg3', 'arg4'); + + const callbackStub = new CallbackStub(); + pubSub.subscribe(callbackStub.callback, true); + + expect(callbackStub.calls.length).to.equal(1); + expect(callbackStub.calls[0]).to.deep.equal(['arg3', 'arg4']); + }); + }); + + describe('subscribe()', () => { + it('should call the callback immediately with the last published arguments if initialize is true', () => { + pubSub.publish('arg1', 'arg2'); + + const callbackStub = new CallbackStub(); + pubSub.subscribe(callbackStub.callback, true); + + expect(callbackStub.calls.length).to.equal(1); + expect(callbackStub.calls[0]).to.deep.equal(['arg1', 'arg2']); + }); + + it('should not call the callback immediately with the last published arguments if initialize is false', () => { + pubSub.publish('arg1', 'arg2'); + + const callbackStub = new CallbackStub(); + pubSub.subscribe(callbackStub.callback, false); + + expect(callbackStub.calls.length).to.equal(0); + }); + + it('should do nothing if the callback is already subscribed', () => { + const callbackStub = new CallbackStub(); + + pubSub.subscribe(callbackStub.callback); + pubSub.subscribe(callbackStub.callback); + + pubSub.publish(); + + expect(callbackStub.calls.length).to.equal(1); + }); + }); + + describe('unsubscribe()', () => { + it('should remove the subscriber', () => { + const callbackStub1 = new CallbackStub(); + const callbackStub2 = new CallbackStub(); + const callbackStub3 = new CallbackStub(); + + pubSub.subscribe(callbackStub1.callback); + pubSub.unsubscribe(callbackStub1.callback); + + pubSub.subscribe(callbackStub2.callback); + pubSub.subscribe(callbackStub3.callback); + pubSub.publish(); + + pubSub.unsubscribe(callbackStub2.callback); + pubSub.publish(); + + expect(callbackStub1.calls.length).to.equal(0); + expect(callbackStub2.calls.length).to.equal(1); + expect(callbackStub3.calls.length).to.equal(2); + }); + + it('should do nothing if the subscriber is not found', () => { + const callbackStub1 = new CallbackStub(); + const callbackStub2 = new CallbackStub(); + + pubSub.subscribe(callbackStub1.callback); + pubSub.unsubscribe(callbackStub2); + + pubSub.publish(); + + expect(callbackStub1.calls.length).to.equal(1); + expect(callbackStub2.calls.length).to.equal(0); + }); + }); +}); From a2e8bcc4dc95a3eb84940dafda1a0fa065f64528 Mon Sep 17 00:00:00 2001 From: Jorge Ramirez Date: Fri, 27 Dec 2024 17:49:10 -0500 Subject: [PATCH 2/3] docs: Missing word in pub-sub docs --- src/utilities/pub-sub/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utilities/pub-sub/README.md b/src/utilities/pub-sub/README.md index ea7ca8a..c5d05ba 100644 --- a/src/utilities/pub-sub/README.md +++ b/src/utilities/pub-sub/README.md @@ -54,7 +54,7 @@ The `subscribe` method is used to subscribe a callback function for future publi If the callback being subscribed is already subscribed, nothing will change and it will still only be called once when messages are published. -By default, a subscribed callback function won't be called until a message is published, but if the `initialize` argument is set to `true` and the `PubSub` instance has published at least once, then callback function will be immediately called after subscription with the last published arguments. +By default, a subscribed callback function won't be called until a message is published, but if the `initialize` argument is set to `true` and the `PubSub` instance has published at least once, then the callback function will be immediately called after subscription with the last published arguments. | Parameter Name | Type | Description | Required | Default Value | |---|---|---|---|---| From 74625aad342374f528010a4f81f709b4ab32c769 Mon Sep 17 00:00:00 2001 From: Jorge Ramirez Date: Fri, 27 Dec 2024 17:50:14 -0500 Subject: [PATCH 3/3] feat: Add ReactiveStore utility --- package-lock.json | 9 + package.json | 4 +- src/utilities/reactive-store/README.md | 299 ++++++++++++++++++ .../reactive-store/context-controllers.js | 79 +++++ .../reactive-store/reactive-store.js | 57 ++++ .../reactive-store/store-consumer.js | 47 +++ 6 files changed, 494 insertions(+), 1 deletion(-) create mode 100644 src/utilities/reactive-store/README.md create mode 100644 src/utilities/reactive-store/context-controllers.js create mode 100644 src/utilities/reactive-store/reactive-store.js create mode 100644 src/utilities/reactive-store/store-consumer.js diff --git a/package-lock.json b/package-lock.json index 6cecad7..01dd80f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@brightspace-ui/core": "^3", + "@lit/context": "^1.1.3", "lit": "^3" }, "devDependencies": { @@ -1094,6 +1095,14 @@ "integrity": "sha512-wx4aBmgeGvFmOKucFKY+8VFJSYZxs9poN3SDNQFF6lT6NrQUnHiPB2PWz2sc4ieEcAaYYzN+1uWahEeTq2aRIQ==", "license": "BSD-3-Clause" }, + "node_modules/@lit/context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.3.tgz", + "integrity": "sha512-Auh37F4S0PZM93HTDfZWs97mmzaQ7M3vnTc9YvxAGyP3UItSK/8Fs0vTOGT+njuvOwbKio/l8Cx/zWL4vkutpQ==", + "dependencies": { + "@lit/reactive-element": "^1.6.2 || ^2.0.0" + } + }, "node_modules/@lit/reactive-element": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.0.4.tgz", diff --git a/package.json b/package.json index 192b56b..b89090c 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "./controllers/computed-value.js": "./src/controllers/computed-values/computed-value.js", "./controllers/computed-values.js": "./src/controllers/computed-values/computed-values.js", "./controllers/language-listener.js": "./src/controllers/language-listener/language-listener.js", - "./utilities/pub-sub.js": "./src/utilities/pub-sub/pub-sub.js" + "./utilities/pub-sub.js": "./src/utilities/pub-sub/pub-sub.js", + "./utilities/reactive-store.js": "./src/utilities/reactive-store/reactive-store.js" }, "scripts": { "langs:sync": "mfv add-missing && mfv remove-extraneous", @@ -53,6 +54,7 @@ }, "dependencies": { "@brightspace-ui/core": "^3", + "@lit/context": "^1.1.3", "lit": "^3" }, "version": "2.4.1" diff --git a/src/utilities/reactive-store/README.md b/src/utilities/reactive-store/README.md new file mode 100644 index 0000000..bcd0e3c --- /dev/null +++ b/src/utilities/reactive-store/README.md @@ -0,0 +1,299 @@ +# ReactiveStore + +A simple data store that will automatically notify subscribers when any of its properties changes. + +It's designed to work as a state store for Lit based apps and mimics Lit's component class API. + +## Usage Examples + +### Basic Usage + +First define and create your own Reactive Store: + +```js +// my-store.js + +import ReactiveStore from '@brightspace-ui/labs/utilites/reactive-store.js'; + +// Define your store with its reactive properties +class MyStore extends ReactiveStore { + static get properties() { + return { + foo: { type: Number }, + }; + } + + constructor() { + super(); + + this.foo = 0; + } +} + +// Create an instance of your store +const myStore = new MyStore(); + +// Create and export a consumer class +export const MyStoreConsumer = myStore.createConsumer(); +``` + +Then, connect to your store from any Lit component using the consumer: + +```js +// my-component.js + +import { MyStoreConsumer } from './my-store.js'; + +class MyComponent extends LitElement { + constructor() { + super(); + + // Connect to the store by instantiating the consumer. + // This will automatically notify your component of changes to the store properties. + this.myStoreConsumer = new MyStoreConsumer(this); + } + + render() { + // The consumer will have all the same properties defined in your store. + return html` +
Foo: ${this.myStoreConsumer.foo}
+ + `; + } + + _click() { + // Updating the values from the consumer will update the store, which will then + // notify all consumers of the changes and trigger component updates. + this.myStoreConsumer.foo += 1; + } +} + +customElements.define('my-component', MyComponent); +``` + +### Context Example + +If you want to create a store that's tied to a branch of your DOM tree instead of being shared globally, you can generate a pair of Context Provider/Consumer reactive controllers. + +Like the basic usage example, you'll first define and create your own Reactive Store, but instead of instantiating the store and creating a consumer for it, you'll create a Context Provider/Consumer pair for it: + +```js +// my-store.js + +import ReactiveStore from '@brightspace-ui/labs/utilites/reactive-store.js'; + +// Define your store with its reactive properties +class MyStore extends ReactiveStore { + static get properties() { + return { + foo: { type: Number }, + }; + } + + constructor() { + super(); + + this.foo = 0; + } +} + +// Generate and export the Context Provider/Consumer pair for your store +const { + Provider: MyStoreContextProvider, + Consumer: MyStoreContextConsumer +} = MyStore.createContextControllers(); +export { MyStoreContextProvider, MyStoreContextConsumer }; +``` + +Then, instantiate the provider within the component you want to provide the store from: + +```js +// my-component.js + +import { MyStoreContextProvider } from './my-store.js'; + +class MyComponent extends LitElement { + constructor() { + super(); + + // Instantiate the provider here to provide an instance of your store to all descendants of + // this component. + // Note: This creates a new instance of your store by default, but it's possible to pass a + // pre-existing instance to the constructor instead. + this.myStoreProvider = new MyStoreContextProvider(this); + } + + render() { + // The provider will have all the same properties defined in your store, so you can + // access your store data from the provider if you wish. + return html` +
Foo: ${this.myStoreProvider.foo}
+ + + `; + } + + _click() { + // Updating the values from the provider will update the store, which will then + // notify all consumers of the changes and trigger component updates. + this.myStoreProvider.foo += 1; + } +} + +customElements.define('my-component', MyComponent); +``` + +Finally, any component that is descended from the component with the store provider can connect to the store by using your store's Context Consumer: + +```js +// my-descendant-component.js + +import { MyStoreContextConsumer } from './my-store.js'; + +class MyDescendantComponent extends LitElement { + constructor() { + super(); + + // Connect to the store by instantiating the context consumer. + // This will automatically notify your component of changes to the store properties. + this.myStoreConsumer = new MyStoreContextConsumer(this); + } + + render() { + // The consumer will have all the same properties defined in your store. + return html` +
Foo: ${this.myStoreConsumer.foo}
+ + `; + } + + _click() { + // Updating the values from the consumer will update the store, which will then + // notify all consumers of the changes and trigger component updates for all consumers and + // the provider as well. + this.myStoreConsumer.foo += 1; + } +} + +customElements.define('my-descendant-component', MyDescendantComponent); +``` + +# API + +## The `ReactiveStore` class + +The `ReactiveStore` class is an abstract class that can be extended to define your own store. + +### Static Properties + +#### `properties` + +This static propery must be created by the extending class. This property must be an object where each key represents a reactive property to be added to the extending store. The value for each of the property keys must be an object representing the property options. + +The available property options are as follows: + +| Option | Type | Description | Required | Default Value | +|---|---|---|---|---| +| `hasChanged(oldValue, newValue)` | Function | This comparison function is called when the store property is set. It is called with both the previous value and the new value to be set. If the result is `true`, the store will notify all consumers of the value change. | False | `(oldValue, newValue) => oldValue !== newValue` | + +### Static Methods + +#### `createContextControllers()` + +This static method is used to generate a pair of Reactive Controllers that can be used to provide a store to Lit components through the use of [Context](https://lit.dev/docs/data/context/). + +This method should never be called on the `ReactiveStore` class directly, instead it should be called on the extending class. + +Calling this method on the extending class returns an object with the following properties: + +| Property | Type | Description | +|---|---|---| +| `Provider` | class | A Reactive Controller responsible for providing the store instance to any descendant components that instantiate the Consumer class. | +| `Consumer` | class | A Reactive Controller that connects the hosting component to the store provided by the ancestor component that instantiated the Provider class. | + +### Instance Properties + +#### The Reactive `properties` + +Each of the reactive properties defined in the static `properties` object will have a getter and setter generated at object construction time. + +Any time the property value is updated, the generated setter will check if the property has changed from its previous value. If the property has changed, it will update it and call all subscriber callback functions that have been registered with the store. + +Note that since this is a setter, the value of the property itself must be changed for subscribers to be notified of the change. If you change a nested value of the property, the store will not detect that change. If you wish to manually trigger an update notification after changing a nested property, you can call the `forceUpdate()` instance method. + +### Instance Methods + +#### `constructor()` + +The store constructor. Make sure to call `super()` when overriding. + +The reactive property accessors are dynamically generated in the constructor. + +#### `createConsumer()` + +This method can be used by any store instance (i.e. an instance of a class that extends the `ReactiveStore`). When called, it generates and returns a Reactive Controller consumer class that can be used to connect a Lit component to the store instance. + +#### `forceUpdate()` + +This method can be used by any store instance (i.e. an instance of a class that extends the `ReactiveStore`). When called, it notifies all subscribers that a value within the store has changed without specifying which one. + +This method should not be needed in most scenarios since changes to the reactive properties should be the primary way to send update notifications to subscribers. This can be used in cases where a deeply nested property of one the reactive properties has been changed and you wish to notify subscribers that the store has changed. + +#### `subscribe(callback, initialize = false)` + +This method can be used by any store instance (i.e. an instance of a class that extends the `ReactiveStore`). This method is used to subscribe a callback function for future store update notifications. + +If the callback being subscribed is already subscribed, nothing will change and it will still only be called once when the store updates. + +By default, a subscribed callback function won't be called until the store is updated, but if the `initialize` argument is set to `true` and the store instance has been updated at least once, then the callback function will be immediately called after subscription with the last update info. + +| Parameter Name | Type | Description | Required | Default Value | +|---|---|---|---|---| +| `callback` | Function | The callback function to be called when the store is updated. | True | | +| `initialize` | Boolean | Whether or not to immedately call the callback function with the last update info. | False | `false` | + +`subscribe` returns no values. + +#### `unsubscribe(callback)` + +This method can be used by any store instance (i.e. an instance of a class that extends the `ReactiveStore`). This method is used to remove a callback function from the collection of subscribers so it no longer receives future store update notifications. + +If the callback function passed in does not match a currently subscribed function, nothing happens. + +| Parameter Name | Type | Description | Required | +|---|---|---|---| +| `callback` | Function | The callback function to remove from the subscribers. | True | + +`unsubscribe` returns no values. + +## The store Consumer class + +This is the class that is returned by the `createConsumer()` instance method on an instance of the store. + +This class is a [Lit Reactive Controller](https://lit.dev/docs/composition/controllers/) that when instantiated can be used by a Lit component to connect to the originating store instance. + +Any Consumer class instances will have access to all the same properties that the originating store does and will automatically trigger the update cycle on the host component whenever a property of the store changes. + +### Instance Properties + +#### The Reactive `properties` + +Just like the store has a set of properties dynamically generated, the Consumer class will have the same property accessors generated at construction time. The Consumer's properties will be directly connected to the corresponding properties on the originating store instance, so they can be used as if connecting to the store directly. + +Setting any of these properties will call the corresponding setter on the originating store, so the store will notify all consumers of changes and in turn any Consumer instance will trigger the update cycle for its host component. + +#### `changedProperties` + +Each Consumer instance has a `changedProperties` Map that keeps track of which reactive properties of the store have changed since the end of the host component's last update cycle. + +Each key of the map represents a reactive property of the store that has changed since the last update cycle of the host Lit component and the corresponding value will be the previous value of that reactive property. + +This map serves a similar function to the `changedProperties` map that [Lit provides](https://lit.dev/docs/components/lifecycle/#changed-properties) as an argument to lifecycle methods like `shouldUpdate` and `willUpdate`. + +Note that `changedProperties` is unique for each instance of the Consumer class and is explicitly tied to the hosting component's update cycle. + +### Instance Methods + +#### `forceUpdate()` + +This method can be used to call the originating store's own `forceUpdate()` method. See the store's `forceUpdate()` definition for details. diff --git a/src/utilities/reactive-store/context-controllers.js b/src/utilities/reactive-store/context-controllers.js new file mode 100644 index 0000000..dd1511a --- /dev/null +++ b/src/utilities/reactive-store/context-controllers.js @@ -0,0 +1,79 @@ +import { + createContext, + ContextProvider as LitContextProvider, + ContextConsumer as LitContextConsumer +} from "@lit/context"; +import StoreConsumer from "./store-consumer"; + +export class ContextProvider { + constructor(host, StoreClass, store = new StoreClass()) { + const { properties } = StoreClass; + this._storeConsumer = new StoreConsumer(host, store, properties); + this._provider = new LitContextProvider(host, { + context: createContext(StoreClass), + initialValue: store, + }); + this._defineProperties(properties); + } + + get changedProperties() { + return this._storeConsumer.changedProperties; + } + + _defineProperties(properties) { + Object.keys(properties).forEach((property) => { + Object.defineProperty(this, property, { + get() { + return this._storeConsumer[property]; + }, + set(value) { + this._storeConsumer[property] = value; + } + }); + }); + } +} +export class ContextConsumer { + constructor(host, StoreClass) { + const { properties } = StoreClass; + this._contextConsumer = new LitContextConsumer(host, { + context: createContext(StoreClass), + callback: (store) => { + this._storeConsumer = new StoreConsumer(host, store, properties); + this._defineProperties(properties); + }, + }); + } + + get changedProperties() { + return this._storeConsumer?.changedProperties; + } + + _defineProperties(properties) { + Object.keys(properties).forEach((property) => { + Object.defineProperty(this, property, { + get() { + return this._storeConsumer[property]; + }, + set(value) { + this._storeConsumer[property] = value; + } + }); + }); + } +} + +export function createContextControllers(StoreClass) { + return { + Provider: class extends ContextProvider { + constructor(host, store = new StoreClass()) { + super(host, StoreClass, store); + } + }, + Consumer: class extends ContextConsumer { + constructor(host) { + super(host, StoreClass); + } + }, + }; +} diff --git a/src/utilities/reactive-store/reactive-store.js b/src/utilities/reactive-store/reactive-store.js new file mode 100644 index 0000000..3b63d1d --- /dev/null +++ b/src/utilities/reactive-store/reactive-store.js @@ -0,0 +1,57 @@ +import PubSub from '../pub-sub/pub-sub.js'; +import StoreConsumer from './store-consumer.js'; +import { createContextControllers } from './context-controllers.js'; + +const defaultHasChanged = (oldValue, newValue) => oldValue !== newValue; + +export default class ReactiveStore { + static createContextControllers() { + return createContextControllers(this); + } + + constructor() { + this._pubSub = new PubSub(); + this._state = {}; + this._defineProperties(this.constructor.properties); + } + + createConsumer() { + const store = this; + return class extends StoreConsumer { + constructor(host) { + super(host, store); + } + } + } + + forceUpdate() { + this._pubSub.publish({ forceUpdate: true }); + } + + subscribe(callback, initialize = false) { + this._pubSub.subscribe(callback, initialize); + } + + unsubscribe(callback) { + this._pubSub.unsubscribe(callback); + } + + _defineProperties(properties) { + Object.keys(properties).forEach((property) => { + Object.defineProperty(this, property, { + get() { + return this._state[property]; + }, + set(value) { + const { hasChanged = defaultHasChanged } = properties[property]; + if (!hasChanged(this._state[property], value)) return; + + const prevValue = this._state; + this._state[property] = value; + + this._pubSub.publish({ property, value, prevValue }); + } + }); + }); + } +} diff --git a/src/utilities/reactive-store/store-consumer.js b/src/utilities/reactive-store/store-consumer.js new file mode 100644 index 0000000..910b724 --- /dev/null +++ b/src/utilities/reactive-store/store-consumer.js @@ -0,0 +1,47 @@ +export default class StoreConsumer { + constructor(host, store, properties = store.constructor.properties) { + this._host = host; + this._host.addController(this); + this._store = store; + + this.changedProperties = new Map(); + + this._onPropertyChange = this._onPropertyChange.bind(this); + this._store.subscribe(this._onPropertyChange, true); + this._defineProperties(properties); + } + + forceUpdate() { + this._store.forceUpdate(); + } + + hostDisconnected() { + this._store.unsubscribe(this._onPropertyChange); + } + + _defineProperties(properties) { + Object.keys(properties).forEach((property) => { + Object.defineProperty(this, property, { + get() { + return this._store[property]; + }, + set(value) { + this._store[property] = value; + } + }); + }); + } + + _onPropertyChange({ + property, + prevValue, + forceUpdate = false, + }) { + if (!forceUpdate && !this.changedProperties.has(property)) this.changedProperties.set(property, prevValue); + + this._host.requestUpdate(); + this._host.updateComplete.then(() => { + this.changedProperties.clear(); + }); + } +}