Преглед изворни кода

Merge branch 'master' into x-spread-event-object

Caleb Porzio пре 5 година
родитељ
комит
1b41d94e49

+ 10 - 0
README.md

@@ -290,6 +290,11 @@ This will add or remove the `disabled` attribute when `myVar` is true or false r
 
 Boolean attributes are supported as per the [HTML specification](https://html.spec.whatwg.org/multipage/indices.html#attributes-3:boolean-attribute), for example `disabled`, `readonly`, `required`, `checked`, `hidden`, `selected`, `open`, etc.
 
+**`.camel` modifier**
+**Example:** `<svg x-bind:view-box.camel="viewBox">`
+
+The `camel` modifier will bind to the camel case equivalent of the attribute name. In the example above, the value of `viewBox` will be bound the `viewBox` attribute as opposed to the `view-box` attribute.
+
 ---
 
 ### `x-on`
@@ -374,6 +379,11 @@ If you wish to customize this, you can specifiy a custom wait time like so:
 <input x-on:input.debounce.750ms="fetchSomething()">
 ```
 
+**`.camel` modifier**
+**Example:** `<input x-on:event-name.camel="doSomething()">`
+
+The `camel` modifier will attach an event listener for the camel case equivalent event name. In the example above, the expression will be evaluated when the `eventName` event is fired on the element.
+
 ---
 
 ### `x-model`

+ 21 - 6
src/component.js

@@ -8,16 +8,17 @@ import { handleIfDirective } from './directives/if'
 import { registerModelListener } from './directives/model'
 import { registerListener } from './directives/on'
 import { unwrap, wrap } from './observable'
+import Alpine from './index'
 
 export default class Component {
-    constructor(el, seedDataForCloning = null) {
+    constructor(el, componentForClone = null) {
         this.$el = el
 
         const dataAttr = this.$el.getAttribute('x-data')
         const dataExpression = dataAttr === '' ? '{}' : dataAttr
         const initExpression = this.$el.getAttribute('x-init')
 
-        this.unobservedData = seedDataForCloning ? seedDataForCloning : saferEval(dataExpression, { $el: this.$el })
+        this.unobservedData = componentForClone ? componentForClone.getUnobservedData() : saferEval(dataExpression, { $el: this.$el })
 
         /* IE11-ONLY:START */
             // For IE11, add our magic properties to the original data for access.
@@ -26,6 +27,9 @@ export default class Component {
             this.unobservedData.$refs = null
             this.unobservedData.$nextTick = null
             this.unobservedData.$watch = null
+            Object.keys(Alpine.magicProperties).forEach(name => {
+                this.unobservedData[`$${name}`] = null
+            })
         /* IE11-ONLY:END */
 
         // Construct a Proxy-based observable. This will be used to handle reactivity.
@@ -50,12 +54,19 @@ export default class Component {
             this.watchers[property].push(callback)
         }
 
+        let canonicalComponentElementReference = componentForClone ? componentForClone.$el : this.$el
+
+        // Register custom magic properties.
+        Object.entries(Alpine.magicProperties).forEach(([name, callback]) => {
+            Object.defineProperty(this.unobservedData, `$${name}`, { get: function () { return callback(canonicalComponentElementReference) } });
+        })
+
         this.showDirectiveStack = []
         this.showDirectiveLastElement
 
         var initReturnedCallback
         // If x-init is present AND we aren't cloning (skip x-init on clone)
-        if (initExpression && ! seedDataForCloning) {
+        if (initExpression && ! componentForClone) {
             // We want to allow data manipulation, but not trigger DOM updates just yet.
             // We haven't even initialized the elements with their Alpine bindings. I mean c'mon.
             this.pauseReactivity = true
@@ -75,6 +86,10 @@ export default class Component {
             // Alpine's got it's grubby little paws all over everything.
             initReturnedCallback.call(this.$data)
         }
+
+        componentForClone || setTimeout(() => {
+            Alpine.onComponentInitializeds.forEach(callback => callback(this))
+        }, 0)
     }
 
     getUnobservedData() {
@@ -253,14 +268,14 @@ export default class Component {
         attrs.forEach(({ type, value, modifiers, expression }) => {
             switch (type) {
                 case 'model':
-                    handleAttributeBindingDirective(this, el, 'value', expression, extraVars, type)
+                    handleAttributeBindingDirective(this, el, 'value', expression, extraVars, type, modifiers)
                     break;
 
                 case 'bind':
                     // The :key binding on an x-for is special, ignore it.
                     if (el.tagName.toLowerCase() === 'template' && value === 'key') return
 
-                    handleAttributeBindingDirective(this, el, value, expression, extraVars, type)
+                    handleAttributeBindingDirective(this, el, value, expression, extraVars, type, modifiers)
                     break;
 
                 case 'text':
@@ -282,7 +297,7 @@ export default class Component {
                 case 'if':
                     // If this element also has x-for on it, don't process x-if.
                     // We will let the "x-for" directive handle the "if"ing.
-                    if (attrs.filter(i => i.type === 'for').length > 0) return
+                    if (attrs.some(i => i.type === 'for')) return
 
                     var output = this.evaluateReturnExpression(el, expression, extraVars)
 

+ 4 - 2
src/directives/bind.js

@@ -1,6 +1,6 @@
-import { arrayUnique, isBooleanAttr, convertClassStringToArray } from '../utils'
+import { arrayUnique, isBooleanAttr, convertClassStringToArray, camelCase } from '../utils'
 
-export function handleAttributeBindingDirective(component, el, attrName, expression, extraVars, attrType) {
+export function handleAttributeBindingDirective(component, el, attrName, expression, extraVars, attrType, modifiers) {
     var value = component.evaluateReturnExpression(el, expression, extraVars)
 
     if (attrName === 'value') {
@@ -63,6 +63,8 @@ export function handleAttributeBindingDirective(component, el, attrName, express
             el.setAttribute('class', arrayUnique(originalClasses.concat(newClasses)).join(' '))
         }
     } else {
+        attrName = modifiers.includes('camel') ? camelCase(attrName) : attrName
+
         // If an attribute's bound value is null, undefined or false, remove the attribute
         if ([null, undefined, false].includes(value)) {
             el.removeAttribute(attrName)

+ 1 - 1
src/directives/if.js

@@ -5,7 +5,7 @@ export function handleIfDirective(component, el, expressionResult, initialUpdate
 
     const elementHasAlreadyBeenAdded = el.nextElementSibling && el.nextElementSibling.__x_inserted_me === true
 
-    if (expressionResult && ! elementHasAlreadyBeenAdded) {
+    if (expressionResult && (! elementHasAlreadyBeenAdded || el.__x_transition)) {
         const clone = document.importNode(el.content, true);
 
         el.parentElement.insertBefore(clone, el.nextElementSibling)

+ 6 - 1
src/directives/on.js

@@ -1,9 +1,14 @@
-import { kebabCase, debounce, isNumeric } from '../utils'
+import { kebabCase, camelCase, debounce, isNumeric } from '../utils'
 
 export function registerListener(component, el, event, modifiers, expression, extraVars = {}) {
     const options = {
         passive: modifiers.includes('passive'),
     };
+
+    if (modifiers.includes('camel')) {
+        event = camelCase(event);
+    }
+
     if (modifiers.includes('away')) {
         let handler = e => {
             // Don't do anything if the click came from the element or within it.

+ 6 - 4
src/directives/show.js

@@ -24,12 +24,14 @@ export function handleShowDirective(component, el, value, modifiers, initialUpda
 
     const handle = (resolve) => {
         if (value) {
-            transitionIn(el,() => {
-                show()
-            }, component)
+            if(el.style.display === 'none' || el.__x_transition) {
+                transitionIn(el, () => {
+                    show()
+                }, component)
+            }
             resolve(() => {})
         } else {
-            if (el.style.display !== 'none' ) {
+            if (el.style.display !== 'none') {
                 transitionOut(el, () => {
                     resolve(() => {
                         hide()

+ 13 - 1
src/index.js

@@ -6,6 +6,10 @@ const Alpine = {
 
     pauseMutationObserver: false,
 
+    magicProperties: {},
+
+    onComponentInitializeds: [],
+
     start: async function () {
         if (! isTesting()) {
             await domReady()
@@ -95,8 +99,16 @@ const Alpine = {
 
     clone: function (component, newEl) {
         if (! newEl.__x) {
-            newEl.__x = new Component(newEl, component.getUnobservedData())
+            newEl.__x = new Component(newEl, component)
         }
+    },
+
+    addMagicProperty: function (name, callback) {
+        this.magicProperties[name] = callback
+    },
+
+    onComponentInitialized: function (callback) {
+        this.onComponentInitializeds.push(callback)
     }
 }
 

+ 84 - 30
src/utils.js

@@ -32,6 +32,10 @@ export function kebabCase(subject) {
     return subject.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/[_\s]/, '-').toLowerCase()
 }
 
+export function camelCase(subject) {
+    return subject.toLowerCase().replace(/[^a-zA-Z0-9]+(.)/g, (match, char) => char.toUpperCase())
+}
+
 export function walk(el, callback) {
     if (callback(el) === false) return
 
@@ -109,13 +113,10 @@ export function getXAttrs(el, component, type) {
         // Add x-spread directives to the pile of existing directives.
         directives = directives.concat(Object.entries(spreadObject).map(([name, value]) => parseHtmlAttribute({ name, value })))
     }
+    
+    if (type) return directives.filter(i => i.type === type)
 
-    return directives.filter(i => {
-        // If no type is passed in for filtering, bypass filter
-        if (! type) return true
-
-        return i.type === type
-    })
+    return directives;
 }
 
 function parseHtmlAttribute({ name, value }) {
@@ -161,9 +162,19 @@ export function convertClassStringToArray(classList, filterFn = Boolean) {
     return classList.split(' ').filter(filterFn)
 }
 
+const TRANSITION_TYPE_IN = 'in'
+const TRANSITION_TYPE_OUT = 'out'
+
 export function transitionIn(el, show, component, forceSkip = false) {
+    // We don't want to transition on the initial page load.
     if (forceSkip) return show()
 
+    if (el.__x_transition && el.__x_transition.type === TRANSITION_TYPE_IN) {
+        // there is already a similar transition going on, this was probably triggered by
+        // a change in a different property, let's just leave the previous one doing its job
+        return
+    }
+
     const attrs = getXAttrs(el, component, 'transition')
     const showAttr = getXAttrs(el, component, 'show')[0]
 
@@ -182,7 +193,7 @@ export function transitionIn(el, show, component, forceSkip = false) {
 
         transitionHelperIn(el, modifiers, show)
     // Otherwise, we can assume x-transition:enter.
-    } else if (attrs.filter(attr => ['enter', 'enter-start', 'enter-end'].includes(attr.value)).length > 0) {
+    } else if (attrs.some(attr => ['enter', 'enter-start', 'enter-end'].includes(attr.value))) {
         transitionClassesIn(el, component, attrs, show)
     } else {
     // If neither, just show that damn thing.
@@ -191,9 +202,15 @@ export function transitionIn(el, show, component, forceSkip = false) {
 }
 
 export function transitionOut(el, hide, component, forceSkip = false) {
-     // We don't want to transition on the initial page load.
+    // We don't want to transition on the initial page load.
     if (forceSkip) return hide()
 
+    if (el.__x_transition && el.__x_transition.type === TRANSITION_TYPE_OUT) {
+        // there is already a similar transition going on, this was probably triggered by
+        // a change in a different property, let's just leave the previous one doing its job
+        return
+    }
+
     const attrs = getXAttrs(el, component, 'transition')
     const showAttr = getXAttrs(el, component, 'show')[0]
 
@@ -208,7 +225,7 @@ export function transitionOut(el, hide, component, forceSkip = false) {
             ? modifiers.filter((i, index) => index > modifiers.indexOf('out')) : modifiers
 
         transitionHelperOut(el, modifiers, settingBothSidesOfTransition, hide)
-    } else if (attrs.filter(attr => ['leave', 'leave-start', 'leave-end'].includes(attr.value)).length > 0) {
+    } else if (attrs.some(attr => ['leave', 'leave-start', 'leave-end'].includes(attr.value))) {
         transitionClassesOut(el, component, attrs, hide)
     } else {
         hide()
@@ -230,7 +247,7 @@ export function transitionHelperIn(el, modifiers, showCallback) {
         },
     }
 
-    transitionHelper(el, modifiers, showCallback, () => {}, styleValues)
+    transitionHelper(el, modifiers, showCallback, () => {}, styleValues, TRANSITION_TYPE_IN)
 }
 
 export function transitionHelperOut(el, modifiers, settingBothSidesOfTransition, hideCallback) {
@@ -254,7 +271,7 @@ export function transitionHelperOut(el, modifiers, settingBothSidesOfTransition,
         },
     }
 
-    transitionHelper(el, modifiers, () => {}, hideCallback, styleValues)
+    transitionHelper(el, modifiers, () => {}, hideCallback, styleValues, TRANSITION_TYPE_OUT)
 }
 
 function modifierValue(modifiers, key, fallback) {
@@ -289,7 +306,13 @@ function modifierValue(modifiers, key, fallback) {
     return rawValue
 }
 
-export function transitionHelper(el, modifiers, hook1, hook2, styleValues) {
+export function transitionHelper(el, modifiers, hook1, hook2, styleValues, type) {
+    // clear the previous transition if exists to avoid caching the wrong styles
+    if (el.__x_transition) {
+        cancelAnimationFrame(el.__x_transition.nextFrame)
+        el.__x_transition.callback && el.__x_transition.callback()
+    }
+
     // If the user set these style values, we'll put them back when we're done with them.
     const opacityCache = el.style.opacity
     const transformCache = el.style.transform
@@ -334,7 +357,7 @@ export function transitionHelper(el, modifiers, hook1, hook2, styleValues) {
         },
     }
 
-    transition(el, stages)
+    transition(el, stages, type)
 }
 
 export function transitionClassesIn(el, component, directives, showCallback) {
@@ -348,7 +371,7 @@ export function transitionClassesIn(el, component, directives, showCallback) {
     const enterStart = convertClassStringToArray(ensureStringExpression((directives.find(i => i.value === 'enter-start') || { expression: '' }).expression))
     const enterEnd = convertClassStringToArray(ensureStringExpression((directives.find(i => i.value === 'enter-end') || { expression: '' }).expression))
 
-    transitionClasses(el, enter, enterStart, enterEnd, showCallback, () => {})
+    transitionClasses(el, enter, enterStart, enterEnd, showCallback, () => {}, TRANSITION_TYPE_IN)
 }
 
 export function transitionClassesOut(el, component, directives, hideCallback) {
@@ -356,10 +379,16 @@ export function transitionClassesOut(el, component, directives, hideCallback) {
     const leaveStart = convertClassStringToArray((directives.find(i => i.value === 'leave-start') || { expression: '' }).expression)
     const leaveEnd = convertClassStringToArray((directives.find(i => i.value === 'leave-end') || { expression: '' }).expression)
 
-    transitionClasses(el, leave, leaveStart, leaveEnd, () => {}, hideCallback)
+    transitionClasses(el, leave, leaveStart, leaveEnd, () => {}, hideCallback, TRANSITION_TYPE_OUT)
 }
 
-export function transitionClasses(el, classesDuring, classesStart, classesEnd, hook1, hook2) {
+export function transitionClasses(el, classesDuring, classesStart, classesEnd, hook1, hook2, type) {
+    // clear the previous transition if exists to avoid caching the wrong classes
+    if (el.__x_transition) {
+        cancelAnimationFrame(el.__x_transition.nextFrame)
+        el.__x_transition.callback && el.__x_transition.callback()
+    }
+
     const originalClasses = el.__x_original_classes || []
 
     const stages = {
@@ -386,14 +415,35 @@ export function transitionClasses(el, classesDuring, classesStart, classesEnd, h
         },
     }
 
-    transition(el, stages)
+    transition(el, stages, type)
 }
 
-export function transition(el, stages) {
+export function transition(el, stages, type) {
+    el.__x_transition = {
+        // Set transition type so we can avoid clearing transition if the direction is the same
+       type: type,
+        // create a callback for the last stages of the transition so we can call it
+        // from different point and early terminate it. Once will ensure that function
+        // is only called one time.
+        callback: once(() => {
+            stages.hide()
+
+            // Adding an "isConnected" check, in case the callback
+            // removed the element from the DOM.
+            if (el.isConnected) {
+                stages.cleanup()
+            }
+
+            delete el.__x_transition
+        }),
+        // This store the next animation frame so we can cancel it
+        nextFrame: null
+    }
+
     stages.start()
     stages.during()
 
-    requestAnimationFrame(() => {
+    el.__x_transition.nextFrame =requestAnimationFrame(() => {
         // Note: Safari's transitionDuration property will list out comma separated transition durations
         // for every single transition property. Let's grab the first one and call it a day.
         let duration = Number(getComputedStyle(el).transitionDuration.replace(/,.*/, '').replace('s', '')) * 1000
@@ -404,19 +454,10 @@ export function transition(el, stages) {
 
         stages.show()
 
-        requestAnimationFrame(() => {
+        el.__x_transition.nextFrame =requestAnimationFrame(() => {
             stages.end()
 
-            // Assign current transition to el in case we need to force it.
-            setTimeout(() => {
-                stages.hide()
-
-                // Adding an "isConnected" check, in case the callback
-                // removed the element from the DOM.
-                if (el.isConnected) {
-                    stages.cleanup()
-                }
-            }, duration)
+            setTimeout(el.__x_transition.callback, duration)
         })
     });
 }
@@ -424,3 +465,16 @@ export function transition(el, stages) {
 export function isNumeric(subject){
     return ! isNaN(subject)
 }
+
+// Thanks @vuejs
+// https://github.com/vuejs/vue/blob/4de4649d9637262a9b007720b59f80ac72a5620c/src/shared/util.js
+export function once(callback) {
+    let called = false
+
+    return function () {
+        if (! called) {
+            called = true
+            callback.apply(this, arguments)
+        }
+    }
+}

+ 12 - 0
test/bind.spec.js

@@ -483,3 +483,15 @@ test('extra whitespace in class binding string syntax is ignored', async () => {
     expect(document.querySelector('span').classList.contains('foo')).toBeTruthy()
     expect(document.querySelector('span').classList.contains('bar')).toBeTruthy()
 })
+
+test('.camel modifier correctly sets name of attribute', async () => {
+    document.body.innerHTML = `
+        <div x-data>
+            <svg x-bind:view-box.camel="'0 0 42 42'"></svg>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('svg').getAttribute('viewBox')).toEqual('0 0 42 42')
+})

+ 17 - 0
test/custom-magic-properties.spec.js

@@ -0,0 +1,17 @@
+import Alpine from 'alpinejs'
+
+test('can register custom magic properties', async () => {
+    document.body.innerHTML = `
+        <div x-data>
+            <span x-text="$foo.bar"></span>
+        </div>
+    `
+
+    Alpine.addMagicProperty('foo', () => {
+        return { bar: 'baz' }
+    })
+
+    Alpine.start()
+
+    expect(document.querySelector('span').innerText).toEqual('baz')
+})

+ 21 - 0
test/on.spec.js

@@ -519,3 +519,24 @@ test('autocomplete event does not trigger keydown with modifier callback', async
 
     await wait(() => { expect(document.querySelector('span').innerText).toEqual(1) })
 })
+
+test('.camel modifier correctly binds event listener', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ foo: 'bar' }" x-on:event-name.camel.window="foo = 'bob'">
+            <button x-on:click="$dispatch('eventName')"></button>
+            <p x-text="foo"></p>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('p').innerText).toEqual('bar')
+
+    document.querySelector('button').click();
+
+    await wait(() => {
+        expect(document.querySelector('p').innerText).toEqual('bob');
+    });
+})
+
+

+ 33 - 0
test/show.spec.js

@@ -126,3 +126,36 @@ test('x-show works with nested x-shows of different functions (hiding vs showing
         expect(document.querySelector('h1').getAttribute('style')).toEqual(null)
     })
 })
+
+// Regression in 2.4.0
+test('x-show with x-bind:style inside x-for works correctly', async () => {
+    document.body.innerHTML = `
+        <div x-data="{items: [{ cleared: false }, { cleared: false }]}">
+            <template x-for="(item, index) in items" :key="index">
+                <button x-show="! item.cleared"
+                    x-bind:style="'background: #999'"
+                    @click="item.cleared = true"
+                >
+                </button>
+            </template>
+        </div>
+    `
+    Alpine.start()
+
+    expect(document.querySelectorAll('button')[0].style.display).toEqual('')
+    expect(document.querySelectorAll('button')[1].style.display).toEqual('')
+
+    document.querySelectorAll('button')[0].click()
+
+    await wait(() => {
+        expect(document.querySelectorAll('button')[0].style.display).toEqual('none')
+        expect(document.querySelectorAll('button')[1].style.display).toEqual('')
+    })
+
+    document.querySelectorAll('button')[1].click()
+
+    await wait(() => {
+        expect(document.querySelectorAll('button')[0].style.display).toEqual('none')
+        expect(document.querySelectorAll('button')[1].style.display).toEqual('none')
+    })
+})

+ 125 - 13
test/transition.spec.js

@@ -135,15 +135,12 @@ test('transition out', async () => {
     expect(document.querySelector('span').classList.contains('leave-end')).toEqual(true)
     expect(document.querySelector('span').getAttribute('style')).toEqual(null)
 
-    await new Promise((resolve) =>
-        setTimeout(() => {
-            expect(document.querySelector('span').classList.contains('leave')).toEqual(false)
-            expect(document.querySelector('span').classList.contains('leave-start')).toEqual(false)
-            expect(document.querySelector('span').classList.contains('leave-end')).toEqual(false)
-            expect(document.querySelector('span').getAttribute('style')).toEqual('display: none;')
-            resolve();
-        }, 10)
-    )
+    await timeout(10)
+
+    expect(document.querySelector('span').classList.contains('leave')).toEqual(false)
+    expect(document.querySelector('span').classList.contains('leave-start')).toEqual(false)
+    expect(document.querySelector('span').classList.contains('leave-end')).toEqual(false)
+    expect(document.querySelector('span').getAttribute('style')).toEqual('display: none;')
 })
 
 test('if only transition leave directives are present, don\'t transition in at all', async () => {
@@ -281,8 +278,8 @@ test('transition in not called when item is already visible', async () => {
     });
 
     document.body.innerHTML = `
-        <div x-data="{ show: true }">
-            <button x-on:click="show = true"></button>
+        <div x-data="{ show: true, foo: 'bar' }">
+            <button x-on:click="foo = 'bob'"></button>
 
             <span
                 x-show="show"
@@ -330,8 +327,8 @@ test('transition out not called when item is already hidden', async () => {
     });
 
     document.body.innerHTML = `
-        <div x-data="{ show: false }">
-            <button x-on:click="show = false"></button>
+        <div x-data="{ show: false, foo: 'bar' }">
+            <button x-on:click="foo = 'bob'"></button>
 
             <span
                 x-show="show"
@@ -620,3 +617,118 @@ test('x-transition supports css animation', async () => {
     )
     expect(document.querySelector('span').classList.contains('animation-leave')).toEqual(false)
 })
+
+test('x-transition do not overlap', async () => {
+    jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
+        setTimeout(callback, 0)
+    });
+
+    document.body.innerHTML = `
+        <div x-data="{ show: true }">
+            <button x-on:click="show = ! show"></button>
+
+            <span x-show.transition="show"></span>
+        </div>
+    `
+
+    Alpine.start()
+
+    // Initial state
+    expect(document.querySelector('span').style.display).toEqual("")
+    expect(document.querySelector('span').style.opacity).toEqual("")
+    expect(document.querySelector('span').style.transform).toEqual("")
+    expect(document.querySelector('span').style.transformOrigin).toEqual("")
+
+    // Trigger transition out
+    document.querySelector('button').click()
+
+    // Trigger transition in before the previous one has finished
+    await timeout(10)
+    document.querySelector('button').click()
+
+    // Check the element is still visible and style properties are correct
+    await timeout(200)
+    expect(document.querySelector('span').style.display).toEqual("")
+    expect(document.querySelector('span').style.opacity).toEqual("")
+    expect(document.querySelector('span').style.transform).toEqual("")
+    expect(document.querySelector('span').style.transformOrigin).toEqual("")
+
+    // Hide the element
+    document.querySelector('button').click()
+    await timeout(200)
+    expect(document.querySelector('span').style.display).toEqual("none")
+    expect(document.querySelector('span').style.opacity).toEqual("")
+    expect(document.querySelector('span').style.transform).toEqual("")
+    expect(document.querySelector('span').style.transformOrigin).toEqual("")
+
+    // Trigger transition in
+    document.querySelector('button').click()
+
+    // Trigger transition out before the previous one has finished
+    await timeout(10)
+    document.querySelector('button').click()
+
+    // Check the element is hidden and style properties are correct
+    await timeout(200)
+    expect(document.querySelector('span').style.display).toEqual("none")
+    expect(document.querySelector('span').style.opacity).toEqual("")
+    expect(document.querySelector('span').style.transform).toEqual("")
+    expect(document.querySelector('span').style.transformOrigin).toEqual("")
+})
+
+test('x-transition using classes do not overlap', async () => {
+    jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
+        setTimeout(callback, 0)
+    });
+    jest.spyOn(window, 'getComputedStyle').mockImplementation(el => {
+        return { transitionDuration: '.1s' }
+    });
+
+    document.body.innerHTML = `
+        <div x-data="{ show: true }">
+            <button x-on:click="show = ! show"></button>
+
+            <span x-show="show"
+                x-transition:enter="enter"
+                x-transition:leave="leave">
+            </span>
+        </div>
+    `
+
+    Alpine.start()
+
+    // Initial state
+    expect(document.querySelector('span').style.display).toEqual("")
+
+    const emptyClassList = document.querySelector('span').classList
+
+    // Trigger transition out
+    document.querySelector('button').click()
+
+    // Trigger transition in before the previous one has finished
+    await timeout(10)
+    document.querySelector('button').click()
+
+    // Check the element is still visible and class property is correct
+    await timeout(200)
+    expect(document.querySelector('span').style.display).toEqual("")
+    expect(document.querySelector('span').classList).toEqual(emptyClassList)
+
+    // Hide the element
+    document.querySelector('button').click()
+    await timeout(200)
+    expect(document.querySelector('span').style.display).toEqual("none")
+    expect(document.querySelector('span').classList).toEqual(emptyClassList)
+
+    // Trigger transition in
+    document.querySelector('button').click()
+
+    // Trigger transition out before the previous one has finished
+    await timeout(10)
+    document.querySelector('button').click()
+
+    // Check the element is hidden and class property is correct
+    await timeout(200)
+    expect(document.querySelector('span').style.display).toEqual("none")
+    expect(document.querySelector('span').classList).toEqual(emptyClassList)
+})