Browse Source

Merge pull request #44 from ryangjchandler/feature/magic-properties

feature(magic properties)
Ryan Chandler 4 years ago
parent
commit
5d5c93a8a1

+ 2 - 1
.gitignore

@@ -1 +1,2 @@
-/node_modules
+/node_modules
+yarn-error.log

+ 51 - 71
README.md

@@ -5,8 +5,10 @@
 A lightweight state management layer for Alpine.js
 
 ![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/ryangjchandler/spruce?label=version&style=flat-square)
+
 ![GitHub file size in bytes](https://img.shields.io/github/size/ryangjchandler/spruce/dist/spruce.js?label=min%20%28no%20gzip%29&style=flat-square)
-[![](https://data.jsdelivr.com/v1/package/gh/ryangjchandler/spruce/badge)](https://www.jsdelivr.com/package/gh/ryangjchandler/spruce)
+
+[![Monthly downloads via CDN](https://data.jsdelivr.com/v1/package/gh/ryangjchandler/spruce/badge)](https://www.jsdelivr.com/package/gh/ryangjchandler/spruce)
 
 ## About
 
@@ -18,7 +20,7 @@ Many large frameworks have their own state management solutions. One thing these
 
 Include the following `<script>` tag in the `<head>` of your document:
 
-```html
+``` html
 <script src="https://cdn.jsdelivr.net/gh/ryangjchandler/spruce@0.x.x/dist/spruce.umd.js"></script>
 ```
 
@@ -28,19 +30,19 @@ Include the following `<script>` tag in the `<head>` of your document:
 
 If you wish to include Spruce with your own bundle:
 
-```bash
+``` bash
 yarn add @ryangjchandler/spruce
 ```
 
 or:
 
-```bash
+``` bash
 npm install @ryangjchandler/spruce --save
 ```
 
 Then add the following to your script:
 
-```javascript
+``` javascript
 import Spruce from '@ryangjchandler/spruce'
 ```
 
@@ -48,34 +50,34 @@ import Spruce from '@ryangjchandler/spruce'
 
 To verify you have correctly installed Spruce, copy & paste the following code snippet into your project.
 
-```html
-<div x-data="{}" x-subscribe>
+``` html
+<div x-data>
     <div x-show="$store.modal.open === 'login'">
-    <p>
-      This "login" modal isn't built with a11y in mind, don't actually use it
-    </p>
+        <p>
+            This "login" modal isn't built with a11y in mind, don't actually use it
+        </p>
     </div>
 </div>
 
-<div x-data="{}" x-subscribe>
+<div x-data>
     <div x-show="$store.modal.open === 'register'">
-    <p>
-      This "register" modal isn't built with a11y in mind, don't actually use it
-    </p>
+        <p>
+            This "register" modal isn't built with a11y in mind, don't actually use it
+        </p>
     </div>
 </div>
 
-<div x-data="{}" x-subscribe>
-  <select x-model="$store.modal.open">
-    <option value="login" selected>login</option>
-    <option value="register">register</option>
-  </select>
+<div x-data>
+    <select x-model="$store.modal.open">
+        <option value="login" selected>login</option>
+        <option value="register">register</option>
+    </select>
 </div>
 
 <script>
-  Spruce.store('modal', {
-    open: 'login',
-  });
+    Spruce.store('modal', {
+        open: 'login',
+    });
 </script>
 ```
 
@@ -101,7 +103,7 @@ If you are importing Spruce into your own bundle, you can interact with it like
 
 **store.js**
 
-```javascript
+```js
 import Spruce from '@ryangjchandler/spruce'
 
 Spruce.store('modals', {
@@ -113,7 +115,7 @@ export default Spruce
 
 **app.js**
 
-```javascript
+```js
 import './store'
 import 'alpinejs'
 ```
@@ -122,21 +124,19 @@ import 'alpinejs'
 
 ### Subscribing your components
 
-To access the global state from your Alpine components, you can simply add the `x-subscribe` directive to your root component.
+Spruce hooks into Alpine using the "magic properties" API, meaning there are no extra steps needed. Start using the `$store` variable in your components right away.
 
 ```html
-<div x-data="{}" x-subscribe>
+<div x-data="{}">
     <span x-text="$store.application.name"></span>
 </div>
 ```
 
-This directive adds a new `$store` magic variable to your component. This can be used to "get" and "set" data in your global store.
-
 ### Defining global state
 
 To define a piece of global state, you can use the `Spruce.store()` method:
 
-```javascript
+```js
 Spruce.store('application', {
     name: 'Amazing Alpine Application'
 })
@@ -147,18 +147,18 @@ The first argument defines the top level property of the scope. The second argum
 To access the `name` property, you can do the following inside of your component:
 
 ```html
-<div x-data="{}" x-subscribe>
+<div x-data="{}">
     <span x-text="$store.application.name"></span>
 </div>
 ```
 
-The `<span>` will now have "Amazing Alpine Application" set as its `innerText`.
+The `<span>` will now have "Amazing Alpine Application" set as its `innerText` .
 
 ### Modifying state from outside of Alpine
 
 You can modify your global state from external scripts using the `Spruce.store()` method too:
 
-```javascript
+```js
 Spruce.store('application', {
     name: 'Amazing Alpine Application'
 })
@@ -172,7 +172,7 @@ This will trigger Alpine to re-evaluate your subscribed components and re-render
 
 A `Spruce.reset()` method is provided so that you can completely overwrite a global store:
 
-```javascript
+```js
 Spruce.store('application', {
     name: 'Amazing Alpine Application'
 })
@@ -188,17 +188,15 @@ Calling the `reset` method will make the new state reactive and cause subscribed
 
 You can register watchers in a similar fashion to Alpine. All you need is the full dot-notation representation of your piece of state and a callback.
 
-```html
-<script>
-    Spruce.store('form', {
-        name: 'Ryan',
-        email: 'support@ryangjchandler.co.uk'
-    })
+```js
+Spruce.store('form', {
+    name: 'Ryan',
+    email: 'support@ryangjchandler.co.uk'
+})
 
-    Spruce.watch('form.email', (old, next) => {
-        // do something with the values here
-    })
-<script>
+Spruce.watch('form.email', (old, next) => {
+    // do something with the values here
+})
 ```
 
 In the above snippet, when we change the value of `form.email` either from a component or externally in a separate JavaScript file, our callback will be invoked and will receive the old value, as well as the new value. This can be useful for running automatic inline validation when a property changes, or triggering an action elsewhere in another component without the need for dispatching events.
@@ -209,7 +207,7 @@ In the above snippet, when we change the value of `form.email` either from a com
 
 Spruce ships with a basic event bus. It exposes two methods:
 
-* `Spruce.on(eventName, callback)` - this can be used to register an event listener. This will react to any internal events, such as `init`. Your callback will receive a single `detail` property which can any information from the event, as well as the global store.
+* `Spruce.on(eventName, callback)` - this can be used to register an event listener. This will react to any internal events, such as `init` . Your callback will receive a single `detail` property which contains any data sent from the event, as well as the global store.
 
 ```js
 Spruce.on('init', ({ store }) => {
@@ -219,7 +217,7 @@ Spruce.on('init', ({ store }) => {
 
 * `Spruce.once(eventName, callback)` - this can be used to register an event listener that is only run **a single time**. This is useful for one time events, such as fetching HTML from the server when hovering over a button or similar.
 
-```js
+``` js
 Spruce.once('event', () => {
     // do something once...
 })
@@ -227,17 +225,15 @@ Spruce.once('event', () => {
 
 * `Spruce.off(eventName, callback)` - this can be used to unhook or de-register an event listener.
 
-```js
+``` js
 var callback = () => {}
 
 Spruce.off('init', callback)
 ```
 
-> **Note**: When calling `Spruce.off()` directly, you **must** pass a named callback.
+You can also unhook a listener using the function returned by `Spruce.on()` . This is especially useful for anonymous function callbacks.
 
-You can also unhook a listener using the function returned by `Spruce.on()`. This is especially useful for anonymous function callbacks.
-
-```js
+``` js
 var off = Spruce.on('event', () => {})
 
 off()
@@ -245,35 +241,19 @@ off()
 
 * `Spruce.emit(eventName, data = {})` - this can be used to emit an event. The first argument should be the name of the event, the second should be an object containing data. This will be merged in with the core data, which consists of a `store` property. When emitting an event, a browser event will also be dispatched with a `spruce:` prefix.
 
-```js
-Spruce.emit('event-name', { foo: 'bar' })
+``` js
+Spruce.emit('event-name', {
+    foo: 'bar'
+})
 ```
 
 In the example above, a `spruce:event-name` event will be fired on the `window` level, so you could register an event listener inside of your Alpine component:
 
-```html
+``` html
 <div x-data @spruce:event-name.window="foo = $event.detail.store.foo">
 </div>
 ```
 
-### Removing the need for `x-subscribe`
-
-Alpine offers a Config API. Using this API, you can enable an experimental global `$store` variable that is declared on the `window` object. This means your components do not need to manually "subscribe" to state changes:
-
-```html
-<script>
-    Spruce.config({
-        globalStoreVariable: true
-    })
-</script>
-
-<div x-data>
-    <span x-text="$store.foo.bar"></span>
-</div>
-```
-
-> **Important**: This feature is **highly unoptimized** at the moment and will actually cause all of your Alpine components on the page to re-render. This is due to the limited API that Alpine exposes to third party libraries and the `$store` variable has no simple way of knowing which element is currently retrieving data from the global store.
-
 ## Versioning
 
 This projects follow the [Semantic Versioning](https://semver.org/) guidelines. This means that there *could* be breaking changes on minor version changes, up until v1.x is reached.

File diff suppressed because it is too large
+ 0 - 0
dist/spruce.js


File diff suppressed because it is too large
+ 0 - 0
dist/spruce.js.map


File diff suppressed because it is too large
+ 0 - 0
dist/spruce.module.js


File diff suppressed because it is too large
+ 0 - 0
dist/spruce.module.js.map


File diff suppressed because it is too large
+ 0 - 0
dist/spruce.umd.js


File diff suppressed because it is too large
+ 0 - 0
dist/spruce.umd.js.map


BIN
docs/example.png


+ 1 - 1
examples/index.html

@@ -4,7 +4,7 @@
         <script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.min.js"></script>
     </head>
     <body>
-        <div x-data x-subscribe>
+        <div x-data>
             <span x-text="$store.application.name"></span>
             <button @click="$store.application.name = 'Example'">Click</button>
         </div>

+ 4 - 2
package.json

@@ -15,13 +15,15 @@
     "scripts": {
         "build": "microbundle",
         "watch": "microbundle watch",
-        "test": "npx jest"
+        "test": "jest"
+    },
+    "dependencies": {
+        "alpinejs": "^2.5"
     },
     "devDependencies": {
         "@babel/preset-env": "^7.9.5",
         "@testing-library/dom": "^7.2.2",
         "@testing-library/jest-dom": "^5.5.0",
-        "alpinejs": "^2.3.1",
         "jest": "^25.4.0",
         "jsdom-simulant": "^1.1.2",
         "microbundle": "^0.11.0",

+ 3 - 3
src/bus.js

@@ -50,11 +50,11 @@ export default {
         this.watchers[dotNotation].push(callback)
     },
 
-    runWatchers(stores, target, key, oldValue) {
+    runWatchers(stores, target, key, value) {
         const self = this
 
         if (self.watchers[key]) {
-            return self.watchers[key].forEach(callback => callback(oldValue, target[key]))
+            return self.watchers[key].forEach(callback => callback(value))
         }
 
         Object.keys(self.watchers)
@@ -66,7 +66,7 @@ export default {
 
                 dotNotationParts.reduce((comparison, part) => {
                     if (comparison[key] === target[key] || Object.is(target, comparison)) {
-                        self.watchers[fullDotNotationKey].forEach(callback => callback(oldValue, target[key]))
+                        self.watchers[fullDotNotationKey].forEach(callback => callback(value))
                     }
 
                     return comparison[part]

+ 21 - 32
src/index.js

@@ -1,19 +1,15 @@
-import { domReady, buildInitExpression, isNullOrUndefined } from './utils'
+import { domReady } from './utils'
 import { createObservable } from './observable'
 import EventBus from './bus'
 
 const Spruce = {
-    options: {
-        globalStoreVariable: false,
-    },
-
     events: EventBus,
 
     stores: {},
 
     subscribers: [],
 
-    start: async function () {
+    async start() {
         await domReady()
 
         this.emit('init')
@@ -23,32 +19,29 @@ const Spruce = {
         document.addEventListener('turbolinks:render', this.attach)
 
         this.stores = createObservable(this.stores, {
-            set: (target, key, value, oldValue) => {
-                this.events.runWatchers(this.stores, target, key, oldValue)
+            set: (target, key, value) => {
+                this.events.runWatchers(this.stores, target, key, value)
 
                 this.updateSubscribers()
             }
         })
-
-        if (this.options.globalStoreVariable) {
-            document.querySelectorAll('[x-data]').forEach(el => {
-                if (! this.subscribers.includes(el)) {
-                    this.subscribers.push(el)
-                }
-            })
-            
-            window.$store = this.stores
-        }
     },
 
     attach() {
-        document.querySelectorAll('[x-subscribe]').forEach(el => {
-            el.setAttribute('x-init', buildInitExpression(el))
-            el.removeAttribute('x-subscribe')
+        if (! window.Alpine) {
+            throw new Error('[Spruce] You must be using Alpine >= 2.5.0 to use Spruce.')
+        }
+
+        const self = this
+
+        window.Alpine.addMagicProperty('store', el => {
+            self.subscribe(el)
+
+            return self.stores
         })
     },
 
-    store: function (name, state) {
+    store(name, state) {
         if (! this.stores[name]) {
             this.stores[name] = state
         }
@@ -56,28 +49,24 @@ const Spruce = {
         return this.stores[name]
     },
 
-    reset: function (name, state) {
+    reset(name, state) {
         this.stores[name] = state
     },
 
     subscribe(el) {
-        this.subscribers.push(el)
+        if (! this.subscribers.includes(el)) {
+            this.subscribers.push(el)
+        }
 
         return this.stores
     },
 
     updateSubscribers() {
-        this.subscribers.forEach(el => {
-            if (el.__x !== undefined) {
-                el.__x.updateElements(el)
-            }
+        this.subscribers.filter(el => !! el.__x).forEach(el => {
+            el.__x.updateElements(el)
         })
     },
 
-    config(options = {}) {
-        this.options = Object.assign(this.options, options)
-    },
-
     on(name, callback) {
         return this.events.on(name, callback)
     },

+ 4 - 13
src/observable.js

@@ -1,28 +1,19 @@
-import { isNullOrUndefined } from './utils'
+import { isNullOrUndefined, isObject } from './utils'
 
 export const createObservable = (target, callbacks) => {
     Object.keys(target).forEach(key => {
-        if (! isNullOrUndefined(target[key]) && Object.getPrototypeOf(target[key]) === Object.prototype) {
+        if (! isNullOrUndefined(target[key]) && isObject(target[key])) {
             target[key] = createObservable(target[key], callbacks)
         }
     })
 
     return new Proxy(target, {
-        get(target, key) {
-            if (callbacks.hasOwnProperty('get')) {
-                callbacks.get(key)
-            }
-            
-            return target[key]
-        },
         set(target, key, value) {
-            const old = target[key]
-
-            if (! isNullOrUndefined(value) && typeof value === 'object') {
+            if (! isNullOrUndefined(value) && isObject(value)) {
                 value = createObservable(value, callbacks)
             }
 
-            callbacks.set(target, key, target[key] = value, old)
+            callbacks.set(target, key, target[key] = value)
 
             return true
         }

+ 4 - 0
src/utils.js

@@ -20,4 +20,8 @@ export const buildInitExpression = el => {
 
 export const isNullOrUndefined = value => {
     return value === null || value === undefined
+}
+
+export const isObject = _ => {
+    return Object.getPrototypeOf(_) === Object.prototype
 }

+ 2 - 4
tests/bus.spec.js

@@ -8,6 +8,7 @@ beforeEach(() => {
 
 beforeAll(() => {
     window.Spruce = Spruce
+    window.Alpine = Alpine
 })
 
 /* Spruce.on() */
@@ -94,14 +95,12 @@ test('.emit() > will dispatch browser event to window with spruce: prefix', asyn
 
 test('.watch() > can listen for changes to property', async () => {
     let fixture = undefined
-    let oldFixture = undefined
     
     Spruce.store('example', {
         cool: 'stuff'
     })
 
-    Spruce.watch('example.cool', (previous, value) => {
-        oldFixture = previous
+    Spruce.watch('example.cool', (value) => {
         fixture = value
     })
 
@@ -112,7 +111,6 @@ test('.watch() > can listen for changes to property', async () => {
     Spruce.stores.example.cool = 'amazing'
 
     expect(fixture).toEqual('amazing')
-    expect(oldFixture).toEqual('stuff')
 })
 
 test('.off() > can unregister listener', () => {

+ 1 - 0
tests/get.spec.js

@@ -8,6 +8,7 @@ beforeEach(() => {
 
 beforeAll(() => {
     window.Spruce = Spruce
+    window.Alpine = Alpine
 })
 
 test('$store > data can be retrieved from store inside component', async () => {

+ 1 - 10
tests/global-store.spec.js

@@ -8,16 +8,7 @@ beforeEach(() => {
 
 beforeAll(() => {
     window.Spruce = Spruce
-})
-
-test('$store > is available as global object', async () => {
-    Spruce.config({ globalStoreVariable: true })
-
-    expect(Spruce.options.globalStoreVariable).toBeTruthy()
-
-    await Spruce.start()
-
-    expect(window.$store).toEqual(Spruce.stores)
+    window.Alpine = Alpine
 })
 
 test('$store > can be used inside of component without subscribing', async () => {

+ 1 - 0
tests/reset.spec.js

@@ -8,6 +8,7 @@ beforeEach(() => {
 
 beforeAll(() => {
     window.Spruce = Spruce
+    window.Alpine = Alpine
 })
 
 test('.reset() > will overwrite existing properties', () => {

+ 1 - 0
tests/set.spec.js

@@ -8,6 +8,7 @@ beforeEach(() => {
 
 beforeAll(() => {
     window.Spruce = Spruce
+    window.Alpine = Alpine
 })
 
 test('$store > data can be set inside component', async () => {

+ 0 - 26
tests/subscribe.spec.js

@@ -1,26 +0,0 @@
-import Spruce from '../dist/spruce'
-import { waitFor } from '@testing-library/dom'
-
-test('x-subscribe > correctly updates x-init directive', async () => {
-    document.body.innerHTML = `
-        <div x-subscribe></div>
-    `
-
-    Spruce.start()
-
-    await waitFor(() => {
-        expect(document.querySelector('div').getAttribute('x-init')).toEqual('$store = Spruce.subscribe($el)')
-    })
-})
-
-test('x-subscribe > correctly updates x-init when already defined', async () => {
-    document.body.innerHTML = `
-        <div x-subscribe x-init="testing = true"></div>
-    `
-
-    Spruce.start()
-
-    await waitFor(() => {
-        expect(document.querySelector('div').getAttribute('x-init')).toEqual('$store = Spruce.subscribe($el); testing = true')
-    })
-})

+ 4 - 4
yarn.lock

@@ -1207,10 +1207,10 @@ alphanum-sort@^1.0.0, alphanum-sort@^1.0.1, alphanum-sort@^1.0.2:
   resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3"
   integrity sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=
 
-alpinejs@^2.3.1:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/alpinejs/-/alpinejs-2.3.1.tgz#350b79b226fff35f026b70a631dde63a56142ea1"
-  integrity sha512-jkASU7vSv4rwTWLsH5kS+NxRw3D2dMhLigfXk88xPifkYWaKyGFMHzQtyCHUh3lDwTvPYCsLCvmPLZHtKgXFng==
+alpinejs@^2.5:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/alpinejs/-/alpinejs-2.6.0.tgz#0ff340e51e632fa4ed25f1c4ee0ce2515bd77491"
+  integrity sha512-4cW3zU17JIYzq6uS3asozwKIMVe5A7wrIfn3o0SNsH1YZ4D3cR2Cs+YThvf4dU3/O7EBzExBY4Dhd8LjtCnEqA==
 
 ansi-escapes@^4.2.1:
   version "4.3.1"

Some files were not shown because too many files changed in this diff