فهرست منبع

Merge branch 'livewire-v3'

Caleb Porzio 2 سال پیش
والد
کامیت
5ede2501b7
36فایلهای تغییر یافته به همراه1987 افزوده شده و 679 حذف شده
  1. 26 161
      index.html
  2. 16 0
      index2.html
  3. 113 272
      package-lock.json
  4. 4 1
      package.json
  5. 12 3
      packages/alpinejs/src/alpine.js
  6. 0 1
      packages/alpinejs/src/directives/x-bind.js
  7. 1 1
      packages/alpinejs/src/directives/x-for.js
  8. 4 4
      packages/alpinejs/src/directives/x-init.js
  9. 100 65
      packages/alpinejs/src/directives/x-model.js
  10. 16 5
      packages/alpinejs/src/directives/x-modelable.js
  11. 8 1
      packages/alpinejs/src/directives/x-teleport.js
  12. 40 0
      packages/alpinejs/src/entangle.js
  13. 2 0
      packages/alpinejs/src/evaluator.js
  14. 10 2
      packages/alpinejs/src/lifecycle.js
  15. 1 1
      packages/alpinejs/src/mutation.js
  16. 55 48
      packages/morph/src/dom.js
  17. 127 114
      packages/morph/src/morph.js
  18. 405 0
      packages/morph/src/old_morph.js
  19. 5 0
      packages/navigate/builds/cdn.js
  20. 3 0
      packages/navigate/builds/module.js
  21. 9 0
      packages/navigate/package.json
  22. 156 0
      packages/navigate/src/bar.js
  23. 9 0
      packages/navigate/src/bus.js
  24. 6 0
      packages/navigate/src/fetch.js
  25. 71 0
      packages/navigate/src/history.js
  26. 219 0
      packages/navigate/src/index.js
  27. 51 0
      packages/navigate/src/links.js
  28. 106 0
      packages/navigate/src/page.js
  29. 27 0
      packages/navigate/src/persist.js
  30. 45 0
      packages/navigate/src/prefetch.js
  31. 24 0
      packages/navigate/src/scroll.js
  32. 15 0
      packages/persist/src/index.js
  33. 1 0
      scripts/build.js
  34. 71 0
      tests/cypress/integration/entangle.spec.js
  35. 66 0
      tests/cypress/integration/plugins/morph.spec.js
  36. 163 0
      tests/cypress/integration/plugins/navigate.spec.js

+ 26 - 161
index.html

@@ -1,12 +1,13 @@
 <html>
-    <script src="./packages/intersect/dist/cdn.js" defer></script>
+    <!-- <script src="./packages/intersect/dist/cdn.js" defer></script>
     <script src="./packages/morph/dist/cdn.js" defer></script>
     <script src="./packages/history/dist/cdn.js"></script>
     <script src="./packages/persist/dist/cdn.js"></script>
     <script src="./packages/focus/dist/cdn.js"></script>
     <script src="./packages/mask/dist/cdn.js"></script>
-    <script src="./packages/ui/dist/cdn.js" defer></script>
+    <script src="./packages/ui/dist/cdn.js" defer></script> -->
     <script src="./packages/alpinejs/dist/cdn.js" defer></script>
+    <!-- <script src="//cdn.tailwindcss.com"></script> -->
     <script src="//cdn.tailwindcss.com"></script>
 
     <div x-data="{ users: [{ name: 'lebowski' }] }">
@@ -17,170 +18,34 @@
         <button @click="users = []">Reset</button>
     </div>
 
-    <!-- <script src="https://cdn.jsdelivr.net/npm/alpinejs@3.0.0/dist/cdn.min.js" defer></script> -->
+    <!-- Play around here... -->
 
-    <!-- <div x-data="{ value: null }">
-        Value: <span x-text="value"></span>
+    <div x-data>
+        <div id="thing" x-yo>i do not belong here...</div>
 
-        <button @click="value = 'bar'">Change value</button>
+        <br>
+        <br>
+        <br>
+        <br>
 
-        <div x-listbox x-model="value">
-            <button x-listbox:button>toggle</button>
-
-            <ul x-listbox:options>
-                <li x-listbox:option value="foo" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Foo</li>
-                <li x-listbox:option value="bar" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Bar</li>
-                <li x-listbox:option value="baz" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Baz</li>
-            </ul>
-        </div>
-    </div> -->
-
-    <!-- <div
-    x-data="{ selected: undefined, people: [
-        { id: 1, name: 'Wade Cooper', foo: { bar: 'baz' } },
-        { id: 2, name: 'Arlene Mccoy' },
-        { id: 3, name: 'Devon Webb' },
-        { id: 4, name: 'Tom Cook' },
-        { id: 5, name: 'Tanya Fox', disabled: true },
-        { id: 6, name: 'Hellen Schmidt' },
-        { id: 7, name: 'Caroline Schultz' },
-        { id: 8, name: 'Mason Heaney' },
-        { id: 9, name: 'Claudie Smitham' },
-        { id: 10, name: 'Emil Schaefer' },
-        ]}"
-        class="flex justify-center w-screen h-full p-12 bg-gray-50"
-    >
-        <button @click="selected = people[1]">Change value</button>
-
-        <button @click="
-            people.sort((a, b) => a.name > b.name ? 1 : -1)
-        ">Reorder</button>
-
-        <button @click="
-            people = people.filter(i => i.name !== 'Arlene Mccoy')
-        ">Destroy item</button>
-
-        <div class="w-full max-w-xs mx-auto">
-            <div x-listbox name="something" x-model="selected" class="space-y-1">
-                <label x-listbox:label class="block text-sm font-medium leading-5 text-gray-700 mb-1">
-                    Assigned to
-                </label>
-
-                <div class="relative">
-                    <span class="inline-block w-full rounded-md shadow-sm">
-                        <button x-listbox:button class="relative w-full py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
-                            <span class="block truncate" x-text="selected ? selected.name : 'Select Person'"></span>
-                            <span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
-                                <svg class="w-5 h-5 text-gray-400" viewBox="0 0 20 20" fill="none" stroke="currentColor">
-                                    <path d="M7 7l3-3 3 3m0 6l-3 3-3-3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
-                                </svg>
-                            </span>
-                        </button>
-                    </span>
-
-                    <div class="absolute w-full mt-1 bg-white rounded-md shadow-lg">
-                        <ul x-listbox:options class="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5">
-                            <template x-for="person in people" :key="person.id">
-                                <li
-                                    x-listbox:option :value="person"
-                                    class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
-                                    :disabled="person.disabled"
-                                    :class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900', person.disabled && 'bg-gray-50 text-gray-300'].filter(Boolean)"
-                                >
-                                    <span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'" x-text="person.name"></span>
-
-                                    <span
-                                        x-show="$listboxOption.isSelected"
-                                        class="absolute inset-y-0 right-0 flex items-center pr-4"
-                                        :class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
-                                    >
-                                        <svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
-                                            <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
-                                        </svg>
-                                    </span>
-                                </li>
-                            </template>
-                        </ul>
-                    </div>
-                </div>
-            </div>
-        </div>
-    </div> -->
-
-
-    <!-- MULTIPLE: -->
-    <div
-        x-data="{ selected: [], people: [
-            { id: 1, name: 'Wade Cooper' },
-            { id: 2, name: 'Arlene Mccoy' },
-            { id: 3, name: 'Devon Webb' },
-            { id: 4, name: 'Tom Cook' },
-            { id: 5, name: 'Tanya Fox', disabled: true },
-            { id: 6, name: 'Hellen Schmidt' },
-            { id: 7, name: 'Caroline Schultz' },
-            { id: 8, name: 'Mason Heaney' },
-            { id: 9, name: 'Claudie Smitham' },
-            { id: 10, name: 'Emil Schaefer' },
-        ]}"
-        class="flex justify-center w-screen h-full p-12 bg-gray-50"
-    >
-        <div class="w-full max-w-xs mx-auto">
-
-            <button @click="selected.push(people[1])">Change value</button>
-
-            <button @click="
-                people.sort((a, b) => a.name > b.name ? 1 : -1)
-            ">Reorder</button>
-
-            <button @click="
-                people = people.filter(i => i.name !== 'Arlene Mccoy')
-            ">Destroy item</button>
+        <button @click="document.getElementById('thing').remove()">remove</button>
+    </div>
 
-            <div x-listbox name="people" multiple class="space-y-1">
-                <label x-listbox:label class="block text-sm font-medium leading-5 text-gray-700 mb-1">
-                    Assigned to
-                </label>
+    <script>
+        document.addEventListener('alpine:init', () => {
+            Alpine.directive('yo', (el, {}, { cleanup }) => {
+                cleanup(() => {
+                    console.log('removed')
+                })
+            })
+        })
+    </script>
 
-                <div class="relative">
-                    <span class="inline-block w-full rounded-md shadow-sm">
-                        <button x-listbox:button class="relative w-full py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
-                            <span class="block truncate" x-text="selected.length > 0 ? selected.map(i => i.name).join(', ') : 'Select Person'"></span>
-                            <span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
-                                <svg class="w-5 h-5 text-gray-400" viewBox="0 0 20 20" fill="none" stroke="currentColor">
-                                    <path d="M7 7l3-3 3 3m0 6l-3 3-3-3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
-                                </svg>
-                            </span>
-                        </button>
-                    </span>
+    <div x-data="{ users: [{ name: 'lebowski' }] }">
+        <template x-for="(user, idx) in users">
+            <span x-text="users[idx].name" x-yo></span>
+        </template>
 
-                    <div class="absolute w-full mt-1 bg-white rounded-md shadow-lg">
-                        <div x-show="$listbox.isOpen">
-                            wrapper
-                            <ul x-listbox:options static class="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5">
-                                <template x-for="person in people" :key="person.id">
-                                    <li
-                                        x-listbox:option :value="person"
-                                        class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
-                                        :disabled="person.disabled"
-                                        :class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900', person.disabled && 'bg-gray-50 text-gray-300'].filter(Boolean)"
-                                        >
-                                        <span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'" x-text="person.name"></span>
-                                        <span
-                                            x-show="$listboxOption.isSelected"
-                                            class="absolute inset-y-0 right-0 flex items-center pr-4"
-                                            :class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
-                                        >
-                                            <svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
-                                                <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
-                                            </svg>
-                                        </span>
-                                    </li>
-                                </template>
-                            </ul>
-                        </div>
-                    </div>
-                </div>
-            </div>
-        </div>
+        <button @click="users = []">Reset</button>
     </div>
 </html>

+ 16 - 0
index2.html

@@ -0,0 +1,16 @@
+<html>
+    <script src="./packages/navigate/dist/cdn.js" defer></script>
+    <script src="./packages/alpinejs/dist/cdn.js" defer></script>
+
+    <hr>
+    <a href="/index.html">Previous</a>
+    <hr>
+
+    Second page:
+
+    <div x-data="{ count: 1 }" x-navigate:persist="foo">
+        <span x-text="count"></span>
+
+        <button @click="count++">+</button>
+    </div>
+</html>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 113 - 272
package-lock.json


+ 4 - 1
package.json

@@ -7,7 +7,7 @@
         "axios": "^0.21.1",
         "brotli-size": "^4.0.0",
         "chalk": "^4.1.1",
-        "cypress": "^5.5.0",
+        "cypress": "^7.0.0",
         "cypress-plugin-tab": "^1.0.5",
         "dot-json": "^1.2.2",
         "esbuild": "^0.8.39",
@@ -21,5 +21,8 @@
         "jest": "jest test",
         "update-docs": "node ./scripts/update-docs.js",
         "release": "node ./scripts/release.js"
+    },
+    "dependencies": {
+        "nprogress": "^0.2.0"
     }
 }

+ 12 - 3
packages/alpinejs/src/alpine.js

@@ -1,17 +1,19 @@
 import { setReactivityEngine, disableEffectScheduling, reactive, effect, release, raw } from './reactivity'
 import { mapAttributes, directive, setPrefix as prefix, prefix as prefixed } from './directives'
-import { start, addRootSelector, addInitSelector, closestRoot, findClosest, initTree } from './lifecycle'
-import { mutateDom, deferMutations, flushAndStopDeferringMutations } from './mutation'
+import { start, addRootSelector, addInitSelector, closestRoot, findClosest, initTree, destroyTree, interceptInit } from './lifecycle'
+import { mutateDom, deferMutations, flushAndStopDeferringMutations, startObservingMutations, stopObservingMutations } from './mutation'
 import { mergeProxies, closestDataStack, addScopeToNode, scope as $data } from './scope'
 import { setEvaluator, evaluate, evaluateLater, dontAutoEvaluateFunctions } from './evaluator'
 import { transition } from './directives/x-transition'
-import { clone, skipDuringClone } from './clone'
+import { clone, skipDuringClone, onlyDuringClone } from './clone'
 import { interceptor } from './interceptor'
 import { getBinding as bound } from './utils/bind'
 import { debounce } from './utils/debounce'
 import { throttle } from './utils/throttle'
 import { setStyles } from './utils/styles'
+import { entangle } from './entangle'
 import { nextTick } from './nextTick'
+import { walk } from './utils/walk'
 import { plugin } from './plugin'
 import { magic } from './magics'
 import { store } from './store'
@@ -27,24 +29,30 @@ let Alpine = {
     flushAndStopDeferringMutations,
     dontAutoEvaluateFunctions,
     disableEffectScheduling,
+    startObservingMutations,
+    stopObservingMutations,
     setReactivityEngine,
     closestDataStack,
     skipDuringClone,
+    onlyDuringClone,
     addRootSelector,
     addInitSelector,
     addScopeToNode,
     deferMutations,
     mapAttributes,
     evaluateLater,
+    interceptInit,
     setEvaluator,
     mergeProxies,
     findClosest,
     closestRoot,
+    destroyTree,
     interceptor, // INTERNAL: not public API and is subject to change without major release.
     transition, // INTERNAL
     setStyles, // INTERNAL
     mutateDom,
     directive,
+    entangle,
     throttle,
     debounce,
     evaluate,
@@ -59,6 +67,7 @@ let Alpine = {
     clone,
     bound,
     $data,
+    walk,
     data,
     bind,
 }

+ 0 - 1
packages/alpinejs/src/directives/x-bind.js

@@ -34,7 +34,6 @@ directive('bind', (el, { value, modifiers, expression, original }, { effect }) =
     }))
 })
 
-
 function storeKeyForXFor(el, expression) {
     el._x_keyExpression = expression
 }

+ 1 - 1
packages/alpinejs/src/directives/x-for.js

@@ -140,7 +140,7 @@ function loop(el, iteratorNames, evaluateItems, evaluateKey) {
             if (!! lookup[key]._x_effects) {
                 lookup[key]._x_effects.forEach(dequeueJob)
             }
-            
+
             lookup[key].remove()
 
             lookup[key] = null

+ 4 - 4
packages/alpinejs/src/directives/x-init.js

@@ -5,9 +5,9 @@ import { skipDuringClone } from "../clone";
 addInitSelector(() => `[${prefix('init')}]`)
 
 directive('init', skipDuringClone((el, { expression }, { evaluate }) => {
-  if (typeof expression === 'string') {
-    return !! expression.trim() && evaluate(expression, {}, false)
-  }
+    if (typeof expression === 'string') {
+        return !! expression.trim() && evaluate(expression, {}, false)
+    }
 
-  return evaluate(expression, {}, false)
+    return evaluate(expression, {}, false)
 }))

+ 100 - 65
packages/alpinejs/src/directives/x-model.js

@@ -4,11 +4,56 @@ import { mutateDom } from '../mutation'
 import { nextTick } from '../nextTick'
 import bind from '../utils/bind'
 import on from '../utils/on'
+import { warn } from '../utils/warn'
 
 directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
-    let evaluate = evaluateLater(el, expression)
-    let assignmentExpression = `${expression} = rightSideOfExpression($event, ${expression})`
-    let evaluateAssignment = evaluateLater(el, assignmentExpression)
+    let scopeTarget = el
+
+    if (modifiers.includes('parent')) {
+        scopeTarget = el.parentNode
+    }
+
+    let evaluateGet = evaluateLater(scopeTarget, expression)
+    let evaluateSet
+
+    if (typeof expression === 'string') {
+        evaluateSet = evaluateLater(scopeTarget, `${expression} = __placeholder`)
+    } else if (typeof expression === 'function' && typeof expression() === 'string') {
+        evaluateSet = evaluateLater(scopeTarget, `${expression()} = __placeholder`)
+    } else {
+        evaluateSet = () => {}
+    }
+
+    let getValue = () => {
+        let result
+
+        evaluateGet(value => result = value)
+
+        return isGetterSetter(result) ? result.get() : result
+    }
+
+    let setValue = value => {
+        let result
+
+        evaluateGet(value => result = value)
+
+        if (isGetterSetter(result)) {
+            result.set(value)
+        } else {
+            evaluateSet(() => {}, {
+                scope: { '__placeholder': value }
+            })
+        }
+    }
+
+    if (typeof expression === 'string' && el.type === 'radio') {
+        // Radio buttons only work properly when they share a name attribute.
+        // People might assume we take care of that for them, because
+        // they already set a shared "x-model" attribute.
+        mutateDom(() => {
+            if (! el.hasAttribute('name')) el.setAttribute('name', expression)
+        })
+    }
 
     // If the element we are binding to is a select, a radio, or checkbox
     // we'll listen for the change event instead of the "input" event.
@@ -17,13 +62,8 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
         || modifiers.includes('lazy')
             ? 'change' : 'input'
 
-    let assigmentFunction = generateAssignmentFunction(el, modifiers, expression)
-
     let removeListener = on(el, event, modifiers, (e) => {
-        evaluateAssignment(() => {}, { scope: {
-            '$event': e,
-            rightSideOfExpression: assigmentFunction
-        }})
+        setValue(getInputValue(el, modifiers, e, getValue()))
     })
 
     // Register the listener removal callback on the element, so that
@@ -47,81 +87,72 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
     }
 
     // Allow programmatic overiding of x-model.
-    let evaluateSetModel = evaluateLater(el, `${expression} = __placeholder`)
     el._x_model = {
         get() {
-            let result
-            evaluate(value => result = value)
-            return result
+            return getValue()
         },
         set(value) {
-            evaluateSetModel(() => {}, { scope: { '__placeholder': value }})
+            setValue(value)
         },
     }
 
-    el._x_forceModelUpdate = () => {
-        evaluate(value => {
-            // If nested model key is undefined, set the default value to empty string.
-            if (value === undefined && expression.match(/\./)) value = ''
+    el._x_forceModelUpdate = (value) => {
+        value = value === undefined ? getValue() : value
 
-            // @todo: This is nasty
-            window.fromModel = true
-            mutateDom(() => bind(el, 'value', value))
-            delete window.fromModel
-        })
+        // If nested model key is undefined, set the default value to empty string.
+        if (value === undefined && typeof expression === 'string' && expression.match(/\./)) value = ''
+
+        // @todo: This is nasty
+        window.fromModel = true
+        mutateDom(() => bind(el, 'value', value))
+        delete window.fromModel
     }
 
     effect(() => {
+        // We need to make sure we're always "getting" the value up front,
+        // so that we don't run into a situation where because of the early
+        // the reactive value isn't gotten and therefore disables future reactions.
+        let value = getValue()
+
         // Don't modify the value of the input if it's focused.
         if (modifiers.includes('unintrusive') && document.activeElement.isSameNode(el)) return
 
-        el._x_forceModelUpdate()
+        el._x_forceModelUpdate(value)
     })
 })
 
-function generateAssignmentFunction(el, modifiers, expression) {
-    if (el.type === 'radio') {
-        // Radio buttons only work properly when they share a name attribute.
-        // People might assume we take care of that for them, because
-        // they already set a shared "x-model" attribute.
-        mutateDom(() => {
-            if (! el.hasAttribute('name')) el.setAttribute('name', expression)
-        })
-    }
-
-    return (event, currentValue) => {
-        return mutateDom(() => {
-            // Check for event.detail due to an issue where IE11 handles other events as a CustomEvent.
-            // Safari autofill triggers event as CustomEvent and assigns value to target
-            // so we return event.target.value instead of event.detail
-            if (event instanceof CustomEvent && event.detail !== undefined) {
-                return event.detail || event.target.value
-            } else if (el.type === 'checkbox') {
-                // If the data we are binding to is an array, toggle its value inside the array.
-                if (Array.isArray(currentValue)) {
-                    let newValue = modifiers.includes('number') ? safeParseNumber(event.target.value) : event.target.value
-
-                    return event.target.checked ? currentValue.concat([newValue]) : currentValue.filter(el => ! checkedAttrLooseCompare(el, newValue))
-                } else {
-                    return event.target.checked
-                }
-            } else if (el.tagName.toLowerCase() === 'select' && el.multiple) {
-                return modifiers.includes('number')
-                    ? Array.from(event.target.selectedOptions).map(option => {
-                        let rawValue = option.value || option.text
-                        return safeParseNumber(rawValue)
-                    })
-                    : Array.from(event.target.selectedOptions).map(option => {
-                        return option.value || option.text
-                    })
+function getInputValue(el, modifiers, event, currentValue) {
+    return mutateDom(() => {
+        // Check for event.detail due to an issue where IE11 handles other events as a CustomEvent.
+        // Safari autofill triggers event as CustomEvent and assigns value to target
+        // so we return event.target.value instead of event.detail
+        if (event instanceof CustomEvent && event.detail !== undefined) {
+            return event.detail || event.target.value
+        } else if (el.type === 'checkbox') {
+            // If the data we are binding to is an array, toggle its value inside the array.
+            if (Array.isArray(currentValue)) {
+                let newValue = modifiers.includes('number') ? safeParseNumber(event.target.value) : event.target.value
+
+                return event.target.checked ? currentValue.concat([newValue]) : currentValue.filter(el => ! checkedAttrLooseCompare(el, newValue))
             } else {
-                let rawValue = event.target.value
-                return modifiers.includes('number')
-                    ? safeParseNumber(rawValue)
-                    : (modifiers.includes('trim') ? rawValue.trim() : rawValue)
+                return event.target.checked
             }
-        })
-    }
+        } else if (el.tagName.toLowerCase() === 'select' && el.multiple) {
+            return modifiers.includes('number')
+                ? Array.from(event.target.selectedOptions).map(option => {
+                    let rawValue = option.value || option.text
+                    return safeParseNumber(rawValue)
+                })
+                : Array.from(event.target.selectedOptions).map(option => {
+                    return option.value || option.text
+                })
+        } else {
+            let rawValue = event.target.value
+            return modifiers.includes('number')
+                ? safeParseNumber(rawValue)
+                : (modifiers.includes('trim') ? rawValue.trim() : rawValue)
+        }
+    })
 }
 
 function safeParseNumber(rawValue) {
@@ -137,3 +168,7 @@ function checkedAttrLooseCompare(valueA, valueB) {
 function isNumeric(subject){
     return ! Array.isArray(subject) && ! isNaN(subject)
 }
+
+function isGetterSetter(value) {
+    return value !== null && typeof value === 'object' && typeof value.get === 'function' && typeof value.set === 'function'
+}

+ 16 - 5
packages/alpinejs/src/directives/x-modelable.js

@@ -1,6 +1,7 @@
 import { directive } from '../directives'
+import { entangle } from '../entangle';
 
-directive('modelable', (el, { expression }, { effect, evaluateLater }) => {
+directive('modelable', (el, { expression }, { effect, evaluateLater, cleanup }) => {
     let func = evaluateLater(expression)
     let innerGet = () => { let result; func(i => result = i); return result; }
     let evaluateInnerSet = evaluateLater(`${expression} = __placeholder`)
@@ -18,11 +19,21 @@ directive('modelable', (el, { expression }, { effect, evaluateLater }) => {
         // in x-modelable/model, but the input events from the native input
         // override any functionality added by x-modelable causing confusion.
         el._x_removeModelListeners['default']()
-    
+
         let outerGet = el._x_model.get
         let outerSet = el._x_model.set
-    
-        effect(() => innerSet(outerGet()))
-        effect(() => outerSet(innerGet()))
+
+        let releaseEntanglement = entangle(
+            {
+                get() { return outerGet() },
+                set(value) { outerSet(value) },
+            },
+            {
+                get() { return innerGet() },
+                set(value) { innerSet(value) },
+            },
+        )
+
+        cleanup(releaseEntanglement)
     })
 })

+ 8 - 1
packages/alpinejs/src/directives/x-teleport.js

@@ -1,13 +1,20 @@
+import { onlyDuringClone, skipDuringClone } from "../clone"
 import { directive } from "../directives"
 import { addInitSelector, initTree } from "../lifecycle"
 import { mutateDom } from "../mutation"
 import { addScopeToNode } from "../scope"
 import { warn } from "../utils/warn"
 
+let teleportContainerDuringClone = document.createElement('div')
+
 directive('teleport', (el, { modifiers, expression }, { cleanup }) => {
     if (el.tagName.toLowerCase() !== 'template') warn('x-teleport can only be used on a <template> tag', el)
 
-    let target = document.querySelector(expression)
+    let target = skipDuringClone(() => {
+        return document.querySelector(expression)
+    }, () => {
+        return teleportContainerDuringClone
+    })()
 
     if (! target) warn(`Cannot find x-teleport element for selector: "${expression}"`)
 

+ 40 - 0
packages/alpinejs/src/entangle.js

@@ -0,0 +1,40 @@
+import { effect, release } from './reactivity'
+
+export function entangle({ get: outerGet, set: outerSet }, { get: innerGet, set: innerSet }) {
+    let firstRun = true
+    let outerHash, innerHash
+
+    let reference = effect(() => {
+        let outer, inner
+
+        if (firstRun) {
+            outer = outerGet()
+            innerSet(outer)
+            inner = innerGet()
+            firstRun = false
+        } else {
+            outer = outerGet()
+            inner = innerGet()
+
+            outerHashLatest = JSON.stringify(outer)
+            innerHashLatest = JSON.stringify(inner)
+
+            if (outerHashLatest !== outerHash) { // If outer changed...
+                inner = innerGet()
+                innerSet(outer)
+                inner = outer // Assign inner to outer so that it can be serialized for diffing...
+            } else { // If inner changed...
+                outerSet(inner)
+                outer = inner // Assign outer to inner so that it can be serialized for diffing...
+            }
+        }
+
+        // Re serialize values...
+        outerHash = JSON.stringify(outer)
+        innerHash = JSON.stringify(inner)
+    })
+
+    return () => {
+        release(reference)
+    }
+}

+ 2 - 0
packages/alpinejs/src/evaluator.js

@@ -134,6 +134,8 @@ export function runIfTypeOfFunction(receiver, value, scope, params, el) {
         } else {
             receiver(result)
         }
+    } else if (typeof value === 'object' && value instanceof Promise) {
+        value.then(i => receiver(i))
     } else {
         receiver(value)
     }

+ 10 - 2
packages/alpinejs/src/lifecycle.js

@@ -68,9 +68,17 @@ export function isRoot(el) {
     return rootSelectors().some(selector => el.matches(selector))
 }
 
-export function initTree(el, walker = walk) {
+let initInterceptors = []
+
+export function interceptInit(callback) { initInterceptors.push(callback) }
+
+export function initTree(el, walker = walk, intercept = () => {}) {
     deferHandlingDirectives(() => {
         walker(el, (el, skip) => {
+            intercept(el, skip)
+
+            initInterceptors.forEach(i => i(el, skip))
+
             directives(el, el.attributes).forEach(handle => handle())
 
             el._x_ignore && skip()
@@ -78,6 +86,6 @@ export function initTree(el, walker = walk) {
     })
 }
 
-function destroyTree(root) {
+export function destroyTree(root) {
     walk(root, el => cleanupAttributes(el))
 }

+ 1 - 1
packages/alpinejs/src/mutation.js

@@ -172,7 +172,7 @@ function onMutate(mutations) {
         if (addedNodes.includes(node)) continue
 
         onElRemoveds.forEach(i => i(node))
-        
+
         if (node._x_cleanups) {
             while (node._x_cleanups.length) node._x_cleanups.pop()()
         }

+ 55 - 48
packages/morph/src/dom.js

@@ -1,50 +1,71 @@
-class DomManager {
-    el = undefined
 
-    constructor(el) {
-        this.el = el
-    }
+export function createElement(html) {
+    const template = document.createElement('template')
+    template.innerHTML = html
+    return template.content.firstElementChild
+}
 
-    traversals = {
-        'first': 'firstElementChild',
-        'next': 'nextElementSibling',
-        'parent': 'parentElement',
-    }
+export function textOrComment(el) {
+    return el.nodeType === 3
+        || el.nodeType === 8
+}
 
-    nodes() {
-        this.traversals = {
-            'first': 'firstChild',
-            'next': 'nextSibling',
-            'parent': 'parentNode',
-        }; return this
-    }
+export let dom = {
+    replace(children, old, replacement) {
+        let index = children.indexOf(old)
 
-    first() {
-        return this.teleportTo(this.el[this.traversals['first']])
-    }
+        if (index === -1) throw 'Cant find element in children'
 
-    next() {
-        return this.teleportTo(this.teleportBack(this.el[this.traversals['next']]))
-    }
+        old.replaceWith(replacement)
 
-    before(insertee) {
-        this.el[this.traversals['parent']].insertBefore(insertee, this.el); return insertee
-    }
+        children[index] = replacement
 
-    replace(replacement) {
-        this.el[this.traversals['parent']].replaceChild(replacement, this.el); return replacement
-    }
+        return children
+    },
+    before(children, reference, subject) {
+        let index = children.indexOf(reference)
 
-    append(appendee) {
-        this.el.appendChild(appendee); return appendee
-    }
+        if (index === -1) throw 'Cant find element in children'
+
+        reference.before(subject)
+
+        children.splice(index, 0, subject)
+
+        return children
+    },
+    append(children, subject, appendFn) {
+        let last = children[children.length - 1]
+
+        appendFn(subject)
 
+        children.push(subject)
+
+        return children
+    },
+    remove(children, subject) {
+        let index = children.indexOf(subject)
+
+        if (index === -1) throw 'Cant find element in children'
+
+        subject.remove()
+
+        return children.filter(i => i !== subject)
+    },
+    first(children) {
+        return this.teleportTo(children[0])
+    },
+    next(children, reference) {
+        let index = children.indexOf(reference)
+
+        if (index === -1) return
+
+        return this.teleportTo(this.teleportBack(children[index + 1]))
+    },
     teleportTo(el) {
         if (! el) return el
         if (el._x_teleport) return el._x_teleport
         return el
-    }
-
+    },
     teleportBack(el) {
         if (! el) return el
         if (el._x_teleportBack) return el._x_teleportBack
@@ -52,17 +73,3 @@ class DomManager {
     }
 }
 
-export function dom(el) {
-    return new DomManager(el)
-}
-
-export function createElement(html) {
-    const template = document.createElement('template')
-    template.innerHTML = html
-    return template.content.firstElementChild
-}
-
-export function textOrComment(el) {
-    return el.nodeType === 3
-        || el.nodeType === 8
-}

+ 127 - 114
packages/morph/src/morph.js

@@ -4,7 +4,7 @@ let resolveStep = () => {}
 
 let logger = () => {}
 
-export async function morph(from, toHtml, options) {
+export function morph(from, toHtml, options) {
     // We're defining these globals and methods inside this function (instead of outside)
     // because it's an async function and if run twice, they would overwrite
     // each other.
@@ -19,16 +19,6 @@ export async function morph(from, toHtml, options) {
         ,removed
         ,adding
         ,added
-        ,debug
-
-
-    function breakpoint(message) {
-        if (! debug) return
-
-        logger((message || '').replace('\n', '\\n'), fromEl, toEl)
-
-        return new Promise(resolve => resolveStep = () => resolve())
-    }
 
     function assignOptions(options = {}) {
         let defaultGetKey = el => el.getAttribute('key')
@@ -42,10 +32,9 @@ export async function morph(from, toHtml, options) {
         added = options.added || noop
         key = options.key || defaultGetKey
         lookahead = options.lookahead || false
-        debug = options.debug || false
     }
 
-    async function patch(from, to) {
+    function patch(from, to) {
         // This is a time saver, however, it won't catch differences in nested <template> tags.
         // I'm leaving this here as I believe it's an important speed improvement, I just
         // don't see a way to enable it currently:
@@ -53,11 +42,8 @@ export async function morph(from, toHtml, options) {
         // if (from.isEqualNode(to)) return
 
         if (differentElementNamesTypesOrKeys(from, to)) {
-            let result = patchElement(from, to)
-
-            await breakpoint('Swap elements')
-
-            return result
+            // Swap elements...
+            return patchElement(from, to)
         }
 
         let updateChildrenOnly = false
@@ -67,19 +53,21 @@ export async function morph(from, toHtml, options) {
         window.Alpine && initializeAlpineOnTo(from, to, () => updateChildrenOnly = true)
 
         if (textOrComment(to)) {
-            await patchNodeValue(from, to)
+            patchNodeValue(from, to)
             updated(from, to)
 
             return
         }
 
         if (! updateChildrenOnly) {
-            await patchAttributes(from, to)
+            patchAttributes(from, to)
         }
 
         updated(from, to)
 
-        await patchChildren(from, to)
+        patchChildren(Array.from(from.childNodes), Array.from(to.childNodes), (toAppend) => {
+            from.appendChild(toAppend)
+        })
     }
 
     function differentElementNamesTypesOrKeys(from, to) {
@@ -95,23 +83,22 @@ export async function morph(from, toHtml, options) {
 
         if (shouldSkip(adding, toCloned)) return
 
-        dom(from).replace(toCloned)
+        dom.replace([from], from, toCloned)
 
         removed(from)
         added(toCloned)
     }
 
-    async function patchNodeValue(from, to) {
+    function patchNodeValue(from, to) {
         let value = to.nodeValue
 
         if (from.nodeValue !== value) {
+            // Change text node...
             from.nodeValue = value
-
-            await breakpoint('Change text node to: ' + value)
         }
     }
 
-    async function patchAttributes(from, to) {
+    function patchAttributes(from, to) {
         if (from._x_isShown && ! to._x_isShown) {
             return
         }
@@ -126,9 +113,8 @@ export async function morph(from, toHtml, options) {
             let name = domAttributes[i].name;
 
             if (! to.hasAttribute(name)) {
+                // Remove attribute...
                 from.removeAttribute(name)
-
-                await breakpoint('Remove attribute')
             }
         }
 
@@ -138,105 +124,150 @@ export async function morph(from, toHtml, options) {
 
             if (from.getAttribute(name) !== value) {
                 from.setAttribute(name, value)
-
-                await breakpoint(`Set [${name}] attribute to: "${value}"`)
             }
         }
     }
 
-    async function patchChildren(from, to) {
-        let domChildren = from.childNodes
-        let toChildren = to.childNodes
-
-        let toKeyToNodeMap = keyToMap(toChildren)
-        let domKeyDomNodeMap = keyToMap(domChildren)
+    function patchChildren(fromChildren, toChildren, appendFn) {
+        // I think I can get rid of this for now:
+        let fromKeyDomNodeMap = {} // keyToMap(fromChildren)
+        let fromKeyHoldovers = {}
 
-        let currentTo = dom(to).nodes().first()
-        let currentFrom = dom(from).nodes().first()
-
-        let domKeyHoldovers = {}
+        let currentTo = dom.first(toChildren)
+        let currentFrom = dom.first(fromChildren)
 
         while (currentTo) {
             let toKey = getKey(currentTo)
-            let domKey = getKey(currentFrom)
+            let fromKey = getKey(currentFrom)
 
             // Add new elements
             if (! currentFrom) {
-                if (toKey && domKeyHoldovers[toKey]) {
-                    let holdover = domKeyHoldovers[toKey]
+                if (toKey && fromKeyHoldovers[toKey]) {
+                    // Add element (from key)...
+                    let holdover = fromKeyHoldovers[toKey]
 
-                    dom(from).append(holdover)
+                    fromChildren = dom.append(fromChildren, holdover, appendFn)
                     currentFrom = holdover
-
-                    await breakpoint('Add element (from key)')
                 } else {
-                    let added = addNodeTo(currentTo, from) || {}
+                    if(! shouldSkip(adding, currentTo)) {
+                        // Add element...
+                        let clone = currentTo.cloneNode(true)
+
+                        fromChildren = dom.append(fromChildren, clone, appendFn)
 
-                    await breakpoint('Add element: ' + (added.outerHTML || added.nodeValue))
+                        added(clone)
+                    }
 
-                    currentTo = dom(currentTo).nodes().next()
+                    currentTo = dom.next(toChildren, currentTo)
 
                     continue
                 }
             }
 
-            if (lookahead) {
-                let nextToElementSibling = dom(currentTo).next()
+            // Handle conditional markers (presumably added by backends like Livewire)...
+            let isIf = node => node.nodeType === 8 && node.textContent === ' __BLOCK__ '
+            let isEnd = node => node.nodeType === 8 && node.textContent === ' __ENDBLOCK__ '
+
+            if (isIf(currentTo) && isIf(currentFrom)) {
+                let newFromChildren = []
+                let appendPoint
+                let nestedIfCount = 0
+                while (currentFrom) {
+                    let next = dom.next(fromChildren, currentFrom)
+
+                    if (isIf(next)) {
+                        nestedIfCount++
+                    } else if (isEnd(next) && nestedIfCount > 0) {
+                        nestedIfCount--
+                    } else if (isEnd(next) && nestedIfCount === 0) {
+                        currentFrom = dom.next(fromChildren, next)
+                        appendPoint = next
+
+                        break;
+                    }
+
+                    newFromChildren.push(next)
+                    currentFrom = next
+                }
+
+                let newToChildren = []
+                nestedIfCount = 0
+                while (currentTo) {
+                    let next = dom.next(toChildren, currentTo)
+
+                    if (isIf(next)) {
+                        nestedIfCount++
+                    } else if (isEnd(next) && nestedIfCount > 0) {
+                        nestedIfCount--
+                    } else if (isEnd(next) && nestedIfCount === 0) {
+                        currentTo = dom.next(toChildren, next)
+
+                        break;
+                    }
+
+                    newToChildren.push(next)
+                    currentTo = next
+                }
+
+                patchChildren(newFromChildren, newToChildren, node => appendPoint.before(node))
+
+                continue
+            }
+
+            // Lookaheads should only apply to non-text-or-comment elements...
+            if (currentFrom.nodeType === 1 && lookahead) {
+                let nextToElementSibling = dom.next(toChildren, currentTo)
 
                 let found = false
 
-                while (!found && nextToElementSibling) {
+                while (! found && nextToElementSibling) {
                     if (currentFrom.isEqualNode(nextToElementSibling)) {
-                        found = true
+                        found = true; // This ";" needs to be here...
 
-                        currentFrom = addNodeBefore(currentTo, currentFrom)
+                        [fromChildren, currentFrom] = addNodeBefore(fromChildren, currentTo, currentFrom)
 
-                        domKey = getKey(currentFrom)
-
-                        await breakpoint('Move element (lookahead)')
+                        fromKey = getKey(currentFrom)
                     }
 
-                    nextToElementSibling = dom(nextToElementSibling).next()
+                    nextToElementSibling = dom.next(toChildren, nextToElementSibling)
                 }
             }
 
-            if (toKey !== domKey) {
-                if (! toKey && domKey) {
-                    domKeyHoldovers[domKey] = currentFrom
-                    currentFrom = addNodeBefore(currentTo, currentFrom)
-                    domKeyHoldovers[domKey].remove()
-                    currentFrom = dom(currentFrom).nodes().next()
-                    currentTo = dom(currentTo).nodes().next()
-
-                    await breakpoint('No "to" key')
+            if (toKey !== fromKey) {
+                if (! toKey && fromKey) {
+                    // No "to" key...
+                    fromKeyHoldovers[fromKey] = currentFrom; // This ";" needs to be here...
+                    [fromChildren, currentFrom] = addNodeBefore(fromChildren, currentTo, currentFrom)
+                    fromChildren = dom.remove(fromChildren, fromKeyHoldovers[fromKey])
+                    currentFrom = dom.next(fromChildren, currentFrom)
+                    currentTo = dom.next(toChildren, currentTo)
 
                     continue
                 }
 
-                if (toKey && ! domKey) {
-                    if (domKeyDomNodeMap[toKey]) {
-                        currentFrom = dom(currentFrom).replace(domKeyDomNodeMap[toKey])
-
-                        await breakpoint('No "from" key')
+                if (toKey && ! fromKey) {
+                    if (fromKeyDomNodeMap[toKey]) {
+                        // No "from" key...
+                        fromChildren = dom.replace(fromChildren, currentFrom, fromKeyDomNodeMap[toKey])
+                        currentFrom = fromKeyDomNodeMap[toKey]
                     }
                 }
 
-                if (toKey && domKey) {
-                    domKeyHoldovers[domKey] = currentFrom
-                    let domKeyNode = domKeyDomNodeMap[toKey]
+                if (toKey && fromKey) {
+                    let fromKeyNode = fromKeyDomNodeMap[toKey]
 
-                    if (domKeyNode) {
-                        currentFrom = dom(currentFrom).replace(domKeyNode)
-
-                        await breakpoint('Move "from" key')
+                    if (fromKeyNode) {
+                        // Move "from" key...
+                        fromKeyHoldovers[fromKey] = currentFrom
+                        fromChildren = dom.replace(fromChildren, currentFrom, fromKeyNode)
+                        currentFrom = fromKeyNode
                     } else {
-                        domKeyHoldovers[domKey] = currentFrom
-                        currentFrom = addNodeBefore(currentTo, currentFrom)
-                        domKeyHoldovers[domKey].remove()
-                        currentFrom = dom(currentFrom).next()
-                        currentTo = dom(currentTo).next()
-
-                        await breakpoint('Swap elements with keys')
+                        // Swap elements with keys...
+                        fromKeyHoldovers[fromKey] = currentFrom; // This ";" needs to be here...
+                        [fromChildren, currentFrom] = addNodeBefore(fromChildren, currentTo, currentFrom)
+                        fromChildren = dom.remove(fromChildren, fromKeyHoldovers[fromKey])
+                        currentFrom = dom.next(fromChildren, currentFrom)
+                        currentTo = dom.next(toChildren, currentTo)
 
                         continue
                     }
@@ -244,12 +275,12 @@ export async function morph(from, toHtml, options) {
             }
 
             // Get next from sibling before patching in case the node is replaced
-            let currentFromNext = currentFrom && dom(currentFrom).nodes().next()
+            let currentFromNext = currentFrom && dom.next(fromChildren, currentFrom)
 
             // Patch elements
-            await patch(currentFrom, currentTo)
+            patch(currentFrom, currentTo)
 
-            currentTo = currentTo && dom(currentTo).nodes().next()
+            currentTo = currentTo && dom.next(toChildren, currentTo)
             currentFrom = currentFromNext
         }
 
@@ -261,7 +292,7 @@ export async function morph(from, toHtml, options) {
         while (currentFrom) {
             if(! shouldSkip(removing, currentFrom)) removals.push(currentFrom)
 
-            currentFrom = dom(currentFrom).nodes().next()
+            currentFrom = dom.next(fromChildren, currentFrom)
         }
 
         // Now we can do the actual removals.
@@ -270,8 +301,6 @@ export async function morph(from, toHtml, options) {
 
             domForRemoval.remove()
 
-            await breakpoint('remove el')
-
             removed(domForRemoval)
         }
     }
@@ -294,32 +323,18 @@ export async function morph(from, toHtml, options) {
         return map
     }
 
-    function addNodeTo(node, parent) {
+    function addNodeBefore(children, node, beforeMe) {
         if(! shouldSkip(adding, node)) {
             let clone = node.cloneNode(true)
 
-            dom(parent).append(clone)
+            children = dom.before(children, beforeMe, clone)
 
             added(clone)
 
-            return clone
+            return [children, clone]
         }
 
-        return null;
-    }
-
-    function addNodeBefore(node, beforeMe) {
-        if(! shouldSkip(adding, node)) {
-            let clone = node.cloneNode(true)
-
-            dom(beforeMe).before(clone)
-
-            added(clone)
-
-            return clone
-        }
-
-        return beforeMe
+        return [children, node]
     }
 
     // Finally we morph the element
@@ -327,7 +342,7 @@ export async function morph(from, toHtml, options) {
     assignOptions(options)
 
     fromEl = from
-    toEl = createElement(toHtml)
+    toEl = typeof toHtml === 'string' ? createElement(toHtml) : toHtml
 
     // If there is no x-data on the element we're morphing,
     // let's seed it with the outer Alpine scope on the page.
@@ -337,9 +352,7 @@ export async function morph(from, toHtml, options) {
         toEl._x_dataStack && window.Alpine.clone(from, toEl)
     }
 
-    await breakpoint()
-
-    await patch(from, toEl)
+    patch(from, toEl)
 
     // Release these for the garbage collector.
     fromEl = undefined

+ 405 - 0
packages/morph/src/old_morph.js

@@ -0,0 +1,405 @@
+import { dom, createElement, textOrComment} from './dom.js'
+
+let resolveStep = () => {}
+
+let logger = () => {}
+
+export async function morph(from, toHtml, options) {
+    // We're defining these globals and methods inside this function (instead of outside)
+    // because it's an async function and if run twice, they would overwrite
+    // each other.
+
+    let fromEl
+    let toEl
+    let key
+        ,lookahead
+        ,updating
+        ,updated
+        ,removing
+        ,removed
+        ,adding
+        ,added
+        ,debug
+
+
+    function breakpoint(message) {
+        if (! debug) return
+
+        logger((message || '').replace('\n', '\\n'), fromEl, toEl)
+
+        return new Promise(resolve => resolveStep = () => resolve())
+    }
+
+    function assignOptions(options = {}) {
+        let defaultGetKey = el => el.getAttribute('key')
+        let noop = () => {}
+
+        updating = options.updating || noop
+        updated = options.updated || noop
+        removing = options.removing || noop
+        removed = options.removed || noop
+        adding = options.adding || noop
+        added = options.added || noop
+        key = options.key || defaultGetKey
+        lookahead = options.lookahead || false
+        debug = options.debug || false
+    }
+
+    async function patch(from, to) {
+        // This is a time saver, however, it won't catch differences in nested <template> tags.
+        // I'm leaving this here as I believe it's an important speed improvement, I just
+        // don't see a way to enable it currently:
+        //
+        // if (from.isEqualNode(to)) return
+
+        if (differentElementNamesTypesOrKeys(from, to)) {
+            let result = patchElement(from, to)
+
+            await breakpoint('Swap elements')
+
+            return result
+        }
+
+        let updateChildrenOnly = false
+
+        if (shouldSkip(updating, from, to, () => updateChildrenOnly = true)) return
+
+        window.Alpine && initializeAlpineOnTo(from, to, () => updateChildrenOnly = true)
+
+        if (textOrComment(to)) {
+            await patchNodeValue(from, to)
+            updated(from, to)
+
+            return
+        }
+
+        if (! updateChildrenOnly) {
+            await patchAttributes(from, to)
+        }
+
+        updated(from, to)
+
+        await patchChildren(from, to)
+    }
+
+    function differentElementNamesTypesOrKeys(from, to) {
+        return from.nodeType != to.nodeType
+            || from.nodeName != to.nodeName
+            || getKey(from) != getKey(to)
+    }
+
+    function patchElement(from, to) {
+        if (shouldSkip(removing, from)) return
+
+        let toCloned = to.cloneNode(true)
+
+        if (shouldSkip(adding, toCloned)) return
+
+        dom(from).replace(toCloned)
+
+        removed(from)
+        added(toCloned)
+    }
+
+    async function patchNodeValue(from, to) {
+        let value = to.nodeValue
+
+        if (from.nodeValue !== value) {
+            from.nodeValue = value
+
+            await breakpoint('Change text node to: ' + value)
+        }
+    }
+
+    async function patchAttributes(from, to) {
+        if (from._x_isShown && ! to._x_isShown) {
+            return
+        }
+        if (! from._x_isShown && to._x_isShown) {
+            return
+        }
+
+        let domAttributes = Array.from(from.attributes)
+        let toAttributes = Array.from(to.attributes)
+
+        for (let i = domAttributes.length - 1; i >= 0; i--) {
+            let name = domAttributes[i].name;
+
+            if (! to.hasAttribute(name)) {
+                from.removeAttribute(name)
+
+                await breakpoint('Remove attribute')
+            }
+        }
+
+        for (let i = toAttributes.length - 1; i >= 0; i--) {
+            let name = toAttributes[i].name
+            let value = toAttributes[i].value
+
+            if (from.getAttribute(name) !== value) {
+                from.setAttribute(name, value)
+
+                await breakpoint(`Set [${name}] attribute to: "${value}"`)
+            }
+        }
+    }
+
+    async function patchChildren(from, to) {
+        let domChildren = from.childNodes
+        let toChildren = to.childNodes
+
+        let toKeyToNodeMap = keyToMap(toChildren)
+        let domKeyDomNodeMap = keyToMap(domChildren)
+
+        let currentTo = dom(to).nodes().first()
+        let currentFrom = dom(from).nodes().first()
+
+        let domKeyHoldovers = {}
+
+        let isInsideWall = false
+
+        while (currentTo) {
+            // If "<!-- end -->"
+            if (
+                currentTo.nodeType === 8
+                && currentTo.textContent === ' end '
+            ) {
+                isInsideWall = false
+                currentTo = dom(currentTo).nodes().next()
+                currentFrom = dom(currentFrom).nodes().next()
+                continue
+            }
+
+            if (insideWall)
+
+            if (isInsideWall) {
+                console.log(currentFrom, currentTo)
+            }
+
+            let toKey = getKey(currentTo)
+            let domKey = getKey(currentFrom)
+
+            // Add new elements
+            if (! currentFrom) {
+                if (toKey && domKeyHoldovers[toKey]) {
+                    let holdover = domKeyHoldovers[toKey]
+
+                    dom(from).append(holdover)
+                    currentFrom = holdover
+
+                    await breakpoint('Add element (from key)')
+                } else {
+                    let added = addNodeTo(currentTo, from) || {}
+
+                    await breakpoint('Add element: ' + (added.outerHTML || added.nodeValue))
+
+                    currentTo = dom(currentTo).nodes().next()
+
+                    continue
+                }
+            }
+
+            // If "<!-- if -->"
+            if (
+                currentTo.nodeType === 8
+                && currentTo.textContent === ' if '
+                && currentFrom.nodeType === 8
+                && currentFrom.textContent === ' if '
+            ) {
+                isInsideWall = true
+                currentTo = dom(currentTo).nodes().next()
+                currentFrom = dom(currentFrom).nodes().next()
+                continue
+            }
+
+            if (lookahead) {
+                let nextToElementSibling = dom(currentTo).next()
+
+                let found = false
+
+                while (!found && nextToElementSibling) {
+                    if (currentFrom.isEqualNode(nextToElementSibling)) {
+                        found = true
+
+                        currentFrom = addNodeBefore(currentTo, currentFrom)
+
+                        domKey = getKey(currentFrom)
+
+                        await breakpoint('Move element (lookahead)')
+                    }
+
+                    nextToElementSibling = dom(nextToElementSibling).next()
+                }
+            }
+
+            if (toKey !== domKey) {
+                if (! toKey && domKey) {
+                    domKeyHoldovers[domKey] = currentFrom
+                    currentFrom = addNodeBefore(currentTo, currentFrom)
+                    domKeyHoldovers[domKey].remove()
+                    currentFrom = dom(currentFrom).nodes().next()
+                    currentTo = dom(currentTo).nodes().next()
+
+                    await breakpoint('No "to" key')
+
+                    continue
+                }
+
+                if (toKey && ! domKey) {
+                    if (domKeyDomNodeMap[toKey]) {
+                        currentFrom = dom(currentFrom).replace(domKeyDomNodeMap[toKey])
+
+                        await breakpoint('No "from" key')
+                    }
+                }
+
+                if (toKey && domKey) {
+                    domKeyHoldovers[domKey] = currentFrom
+                    let domKeyNode = domKeyDomNodeMap[toKey]
+
+                    if (domKeyNode) {
+                        currentFrom = dom(currentFrom).replace(domKeyNode)
+
+                        await breakpoint('Move "from" key')
+                    } else {
+                        domKeyHoldovers[domKey] = currentFrom
+                        currentFrom = addNodeBefore(currentTo, currentFrom)
+                        domKeyHoldovers[domKey].remove()
+                        currentFrom = dom(currentFrom).next()
+                        currentTo = dom(currentTo).next()
+
+                        await breakpoint('Swap elements with keys')
+
+                        continue
+                    }
+                }
+            }
+
+            // Get next from sibling before patching in case the node is replaced
+            let currentFromNext = currentFrom && dom(currentFrom).nodes().next()
+
+            // Patch elements
+            await patch(currentFrom, currentTo)
+
+            currentTo = currentTo && dom(currentTo).nodes().next()
+            currentFrom = currentFromNext
+        }
+
+        // Cleanup extra froms.
+        let removals = []
+
+        // We need to collect the "removals" first before actually
+        // removing them so we don't mess with the order of things.
+        while (currentFrom) {
+            if(! shouldSkip(removing, currentFrom)) removals.push(currentFrom)
+
+            currentFrom = dom(currentFrom).nodes().next()
+        }
+
+        // Now we can do the actual removals.
+        while (removals.length) {
+            let domForRemoval = removals.shift()
+
+            domForRemoval.remove()
+
+            await breakpoint('remove el')
+
+            removed(domForRemoval)
+        }
+    }
+
+    function getKey(el) {
+        return el && el.nodeType === 1 && key(el)
+    }
+
+    function keyToMap(els) {
+        let map = {}
+
+        els.forEach(el => {
+            let theKey = getKey(el)
+
+            if (theKey) {
+                map[theKey] = el
+            }
+        })
+
+        return map
+    }
+
+    function addNodeTo(node, parent) {
+        if(! shouldSkip(adding, node)) {
+            let clone = node.cloneNode(true)
+
+            dom(parent).append(clone)
+
+            added(clone)
+
+            return clone
+        }
+
+        return null;
+    }
+
+    function addNodeBefore(node, beforeMe) {
+        if(! shouldSkip(adding, node)) {
+            let clone = node.cloneNode(true)
+
+            dom(beforeMe).before(clone)
+
+            added(clone)
+
+            return clone
+        }
+
+        return beforeMe
+    }
+
+    // Finally we morph the element
+
+    assignOptions(options)
+
+    fromEl = from
+    toEl = typeof toHtml === 'string' ? createElement(toHtml) : toHtml
+
+    // If there is no x-data on the element we're morphing,
+    // let's seed it with the outer Alpine scope on the page.
+    if (window.Alpine && window.Alpine.closestDataStack && ! from._x_dataStack) {
+        toEl._x_dataStack = window.Alpine.closestDataStack(from)
+
+        toEl._x_dataStack && window.Alpine.clone(from, toEl)
+    }
+
+    await breakpoint()
+
+    await patch(from, toEl)
+
+    // Release these for the garbage collector.
+    fromEl = undefined
+    toEl = undefined
+
+    return from
+}
+
+morph.step = () => resolveStep()
+morph.log = (theLogger) => {
+    logger = theLogger
+}
+
+function shouldSkip(hook, ...args) {
+    let skip = false
+
+    hook(...args, () => skip = true)
+
+    return skip
+}
+
+function initializeAlpineOnTo(from, to, childrenOnly) {
+    if (from.nodeType !== 1) return
+
+    // If the element we are updating is an Alpine component...
+    if (from._x_dataStack) {
+        // Then temporarily clone it (with it's data) to the "to" element.
+        // This should simulate backend Livewire being aware of Alpine changes.
+        window.Alpine.clone(from, to)
+    }
+}

+ 5 - 0
packages/navigate/builds/cdn.js

@@ -0,0 +1,5 @@
+import navigate from '../src/index.js'
+
+document.addEventListener('alpine:init', () => {
+    window.Alpine.plugin(navigate)
+})

+ 3 - 0
packages/navigate/builds/module.js

@@ -0,0 +1,3 @@
+import navigate from '../src/index.js'
+
+export default navigate

+ 9 - 0
packages/navigate/package.json

@@ -0,0 +1,9 @@
+{
+    "name": "@alpinejs/navigate",
+    "version": "3.10.2",
+    "description": "An Alpine plugin for adding SPA-like navigation to your app.",
+    "author": "Caleb Porzio",
+    "license": "MIT",
+    "main": "dist/module.cjs.js",
+    "module": "dist/module.esm.js"
+}

+ 156 - 0
packages/navigate/src/bar.js

@@ -0,0 +1,156 @@
+import NProgress from 'nprogress'
+
+NProgress.configure({ minimum: 0.1 });
+NProgress.configure({ trickleSpeed: 200 });
+
+injectStyles()
+
+let inProgress = false
+export function showAndStartProgressBar() {
+    inProgress = true
+    // Only show progress bar if it's been a little bit...
+    setTimeout(() => {
+        if (! inProgress) return
+        NProgress.start()
+    }, 150)
+
+    // createBar()
+
+    // incrementBar()
+}
+
+export function finishAndHideProgressBar() {
+    inProgress = false
+    NProgress.done()
+    NProgress.remove()
+
+    // finishProgressBar(); destroyBar()
+}
+
+function createBar() {
+    let bar = document.createElement('div')
+
+    bar.setAttribute('id', 'alpine-progress-bar')
+    bar.setAttribute('x-navigate:persist', 'alpine-progress-bar')
+    bar.setAttribute('style', `
+        width: 100%;
+        height: 5px;
+        background: black;
+        position: absolute;
+        top: 0;
+        left: 0;
+        right: 0;
+        transition: all 0.5s ease;
+        transform: scaleX(0);
+        transform-origin: left;
+    `)
+
+    document.body.appendChild(bar)
+
+    return bar
+}
+
+function incrementBar(goal = .1) {
+    let bar = document.getElementById('alpine-progress-bar')
+
+    if (! bar) return
+
+    let percentage = Number(bar.style.transform.match(/scaleX\((.+)\)/)[1])
+
+    if (percentage > 1) return
+
+    bar.style.transform = 'scaleX(' + goal + ')'
+
+    setTimeout(() => {
+        incrementBar(percentage + .1)
+    }, 50)
+}
+
+function finishProgressBar(callback) {
+    let bar = document.getElementById('alpine-progress-bar')
+    bar.style.transform = 'scaleX(1)'
+}
+
+function destroyBar() {
+    document.getElementById('alpine-progress-bar').remove()
+}
+
+function injectStyles() {
+    let style = document.createElement('style')
+    style.innerHTML = `/* Make clicks pass-through */
+    #nprogress {
+      pointer-events: none;
+    }
+
+    #nprogress .bar {
+    //   background: #FC70A9;
+      background: #29d;
+
+      position: fixed;
+      z-index: 1031;
+      top: 0;
+      left: 0;
+
+      width: 100%;
+      height: 2px;
+    }
+
+    /* Fancy blur effect */
+    #nprogress .peg {
+      display: block;
+      position: absolute;
+      right: 0px;
+      width: 100px;
+      height: 100%;
+      box-shadow: 0 0 10px #29d, 0 0 5px #29d;
+      opacity: 1.0;
+
+      -webkit-transform: rotate(3deg) translate(0px, -4px);
+          -ms-transform: rotate(3deg) translate(0px, -4px);
+              transform: rotate(3deg) translate(0px, -4px);
+    }
+
+    /* Remove these to get rid of the spinner */
+    #nprogress .spinner {
+      display: block;
+      position: fixed;
+      z-index: 1031;
+      top: 15px;
+      right: 15px;
+    }
+
+    #nprogress .spinner-icon {
+      width: 18px;
+      height: 18px;
+      box-sizing: border-box;
+
+      border: solid 2px transparent;
+      border-top-color: #29d;
+      border-left-color: #29d;
+      border-radius: 50%;
+
+      -webkit-animation: nprogress-spinner 400ms linear infinite;
+              animation: nprogress-spinner 400ms linear infinite;
+    }
+
+    .nprogress-custom-parent {
+      overflow: hidden;
+      position: relative;
+    }
+
+    .nprogress-custom-parent #nprogress .spinner,
+    .nprogress-custom-parent #nprogress .bar {
+      position: absolute;
+    }
+
+    @-webkit-keyframes nprogress-spinner {
+      0%   { -webkit-transform: rotate(0deg); }
+      100% { -webkit-transform: rotate(360deg); }
+    }
+    @keyframes nprogress-spinner {
+      0%   { transform: rotate(0deg); }
+      100% { transform: rotate(360deg); }
+    }
+    `
+    document.head.appendChild(style)
+}

+ 9 - 0
packages/navigate/src/bus.js

@@ -0,0 +1,9 @@
+let listeners = {}
+
+export function listen(event, callback) {
+    listeners[event] = [...(listeners[event] || []), callback]
+}
+
+export function emit(event, ...props) {
+    (listeners[event] || []).forEach(handle => handle(...props))
+}

+ 6 - 0
packages/navigate/src/fetch.js

@@ -0,0 +1,6 @@
+
+export function fetchHtml(destination, callback) {
+    fetch(destination.pathname).then(i => i.text()).then(html => {
+        callback(html)
+    })
+}

+ 71 - 0
packages/navigate/src/history.js

@@ -0,0 +1,71 @@
+
+export function updateCurrentPageHtmlInHistoryStateForLaterBackButtonClicks() {
+    // Create a history state entry for the initial page load.
+    // (This is so later hitting back can restore this page).
+    let url = new URL(window.location.href, document.baseURI)
+
+    replaceUrl(url, document.documentElement.outerHTML)
+}
+
+export function whenTheBackOrForwardButtonIsClicked(callback) {
+    window.addEventListener('popstate', e => {
+        let { html } = fromSessionStorage(e)
+
+        callback(html)
+    })
+}
+
+export function updateUrlAndStoreLatestHtmlForFutureBackButtons(html, destination) {
+    pushUrl(destination, html)
+}
+
+export function pushUrl(url, html) {
+    updateUrl('pushState', url, html)
+}
+
+export function replaceUrl(url, html) {
+    updateUrl('replaceState', url, html)
+}
+
+function updateUrl(method, url, html) {
+    let key = (new Date).getTime()
+
+    tryToStoreInSession(key, JSON.stringify({ html: html }))
+
+    let state = Object.assign(history.state || {}, { alpine: key })
+
+    // 640k character limit:
+    history[method](state, document.title, url)
+}
+
+export function fromSessionStorage(event) {
+    if (! event.state.alpine) return {}
+
+    let state = JSON.parse(sessionStorage.getItem('alpine:'+event.state.alpine))
+
+    return state
+}
+
+function tryToStoreInSession(timestamp, value) {
+    // sessionStorage has a max storage limit (usally 5MB).
+    // If we meet that limit, we'll start removing entries
+    // (oldest first), until there's enough space to store
+    // the new one.
+    try {
+        sessionStorage.setItem('alpine:'+timestamp, value)
+    } catch (error) {
+        // 22 is Chrome, 1-14 is other browsers.
+        if (! [22, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14].includes(error.code)) return
+
+        let oldestTimestamp = Object.keys(sessionStorage)
+            .map(key => Number(key.replace('alpine:', '')))
+            .sort()
+            .shift()
+
+        if (! oldestTimestamp) return
+
+        sessionStorage.removeItem('alpine:'+oldestTimestamp)
+
+        tryToStoreInSession(timestamp, value)
+    }
+}

+ 219 - 0
packages/navigate/src/index.js

@@ -0,0 +1,219 @@
+import { transition } from "alpinejs/src/directives/x-transition"
+import { finishAndHideProgressBar, showAndStartProgressBar } from "./bar"
+import { fetchHtml } from "./fetch"
+import { updateCurrentPageHtmlInHistoryStateForLaterBackButtonClicks, updateUrlAndStoreLatestHtmlForFutureBackButtons, whenTheBackOrForwardButtonIsClicked } from "./history"
+import { extractDestinationFromLink, hijackNewLinksOnThePage, whenALinkIsClicked, whenALinkIsHovered } from "./links"
+import { swapCurrentPageWithNewHtml } from "./page"
+import { putPersistantElementsBack, storePersistantElementsForLater } from "./persist"
+import { getPretchedHtmlOr, prefetchHtml, storeThePrefetchedHtmlForWhenALinkIsClicked } from "./prefetch"
+import { restoreScrollPosition, storeScrollInformationInHtmlBeforeNavigatingAway } from "./scroll"
+
+let enablePrefetch = true
+let enablePersist = true
+let showProgressBar = true
+let restoreScroll = true
+let autofocus = false
+
+export default function (Alpine) {
+    updateCurrentPageHtmlInHistoryStateForLaterBackButtonClicks()
+
+    enablePrefetch && whenALinkIsHovered((el) => {
+        let forDestination = extractDestinationFromLink(el)
+
+        prefetchHtml(forDestination, html => {
+            storeThePrefetchedHtmlForWhenALinkIsClicked(html, forDestination)
+        })
+    })
+
+    whenALinkIsClicked((el) => {
+        showProgressBar && showAndStartProgressBar()
+
+        let fromDestination = extractDestinationFromLink(el)
+
+        fetchHtmlOrUsePrefetchedHtml(fromDestination, html => {
+            restoreScroll && storeScrollInformationInHtmlBeforeNavigatingAway()
+
+            showProgressBar && finishAndHideProgressBar()
+
+            updateCurrentPageHtmlInHistoryStateForLaterBackButtonClicks()
+
+            preventAlpineFromPickingUpDomChanges(Alpine, andAfterAllThis => {
+                enablePersist && storePersistantElementsForLater()
+
+                swapCurrentPageWithNewHtml(html, () => {
+                    enablePersist && putPersistantElementsBack()
+
+                    // Added setTimeout here to detect a currently hovered prefetch link...
+                    // (hack for laracon)
+                    setTimeout(() => hijackNewLinksOnThePage())
+
+                    restoreScroll && restoreScrollPosition()
+
+                    fireEventForOtherLibariesToHookInto()
+
+                    updateUrlAndStoreLatestHtmlForFutureBackButtons(html, fromDestination)
+
+                    andAfterAllThis(() => {
+                        autofocus && autofocusElementsWithTheAutofocusAttribute()
+
+                        nowInitializeAlpineOnTheNewPage(Alpine)
+                    })
+                })
+
+            })
+        })
+    })
+
+    whenTheBackOrForwardButtonIsClicked((html) => {
+        // @todo: see if there's a way to update the current HTML BEFORE
+        // the back button is hit, and not AFTER:
+        storeScrollInformationInHtmlBeforeNavigatingAway()
+        // updateCurrentPageHtmlInHistoryStateForLaterBackButtonClicks()
+
+        preventAlpineFromPickingUpDomChanges(Alpine, andAfterAllThis => {
+            enablePersist && storePersistantElementsForLater()
+
+            swapCurrentPageWithNewHtml(html, andThen => {
+                enablePersist && putPersistantElementsBack()
+
+                hijackNewLinksOnThePage()
+
+                restoreScroll && restoreScrollPosition()
+
+                fireEventForOtherLibariesToHookInto()
+
+                andAfterAllThis(() => {
+                    autofocus && autofocusElementsWithTheAutofocusAttribute()
+
+                    nowInitializeAlpineOnTheNewPage(Alpine)
+                })
+            })
+
+        })
+    })
+}
+
+function fetchHtmlOrUsePrefetchedHtml(fromDestination, callback) {
+    getPretchedHtmlOr(fromDestination, callback, () => {
+        fetchHtml(fromDestination, callback)
+    })
+}
+
+function preventAlpineFromPickingUpDomChanges(Alpine, callback) {
+    Alpine.stopObservingMutations()
+
+    callback((afterAllThis) => {
+        Alpine.startObservingMutations()
+
+        setTimeout(() => {
+            afterAllThis()
+        })
+    })
+}
+
+function fireEventForOtherLibariesToHookInto() {
+    document.dispatchEvent(new CustomEvent('alpine:navigated', { bubbles: true }))
+}
+
+function nowInitializeAlpineOnTheNewPage(Alpine) {
+    Alpine.initTree(document.body, undefined, (el, skip) => {
+        if (el._x_wasPersisted) skip()
+    })
+}
+
+function autofocusElementsWithTheAutofocusAttribute() {
+    document.querySelector('[autofocus]') && document.querySelector('[autofocus]').focus()
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+    // Alpine.magic('history', (el, { interceptor }) =>  {
+    //     let alias
+
+    //     return interceptor((initialValue, getter, setter, path, key) => {
+    //         let pause = false
+    //         let queryKey = alias || path
+
+    //         let value = initialValue
+    //         let url = new URL(window.location.href)
+
+    //         if (url.searchParams.has(queryKey)) {
+    //             value = url.searchParams.get(queryKey)
+    //         }
+
+    //         setter(value)
+
+    //         let object = { value }
+
+    //         url.searchParams.set(queryKey, value)
+
+    //         replace(url.toString(), path, object)
+
+    //         window.addEventListener('popstate', (e) => {
+    //             if (! e.state) return
+    //             if (! e.state.alpine) return
+
+    //             Object.entries(e.state.alpine).forEach(([newKey, { value }]) => {
+    //                 if (newKey !== key) return
+
+    //                 pause = true
+
+    //                 Alpine.disableEffectScheduling(() => {
+    //                     setter(value)
+    //                 })
+
+    //                 pause = false
+    //             })
+    //         })
+
+    //         Alpine.effect(() => {
+    //             let value = getter()
+
+    //             if (pause) return
+
+    //             let object = { value }
+
+    //             let url = new URL(window.location.href)
+
+    //             url.searchParams.set(queryKey, value)
+
+    //             push(url.toString(), path, object)
+    //         })
+
+    //         return value
+    //     }, func => {
+    //         func.as = key => { alias = key; return func }
+    //     })
+    // })
+// }
+
+
+
+// function replace(url, key, object) {
+//     let state = window.history.state || {}
+
+//     if (! state.alpine) state.alpine = {}
+
+//     state.alpine[key] = object
+
+//     window.history.replaceState(state, '', url)
+// }
+
+// function push(url, key, object) {
+//     let state = { alpine: {...window.history.state.alpine, ...{[key]: object}} }
+
+//     window.history.pushState(state, '', url)
+// }

+ 51 - 0
packages/navigate/src/links.js

@@ -0,0 +1,51 @@
+
+let handleLinkClick = () => {}
+let handleLinkHover = () => {}
+
+export function whenALinkIsClicked(callback) {
+    handleLinkClick = callback
+
+    initializeLinksForClicking()
+}
+
+export function whenALinkIsHovered(callback) {
+    handleLinkHover = callback
+
+    initializeLinksForHovering()
+}
+
+export function extractDestinationFromLink(linkEl) {
+    return new URL(linkEl.getAttribute('href'), document.baseURI)
+}
+
+export function hijackNewLinksOnThePage() {
+    initializeLinksForClicking()
+    initializeLinksForHovering()
+}
+
+function initializeLinksForClicking() {
+    getLinks().forEach(el => {
+        el.addEventListener('click', e => {
+            e.preventDefault()
+
+            handleLinkClick(el)
+        })
+    })
+}
+
+function initializeLinksForHovering() {
+    getLinks()
+        .filter(i => i.hasAttribute('wire:navigate.prefetch'))
+        .forEach(el => {
+            el.addEventListener('mouseenter', e => {
+                handleLinkHover(el)
+            })
+        })
+}
+
+function getLinks() {
+    return Array.from(document.links)
+        .filter(i => i.hasAttribute('wire:navigate')
+        || i.hasAttribute('wire:navigate.prefetch'))
+}
+

+ 106 - 0
packages/navigate/src/page.js

@@ -0,0 +1,106 @@
+import Alpine from "alpinejs/src/alpine"
+
+export function swapCurrentPageWithNewHtml(html, andThen) {
+    let newDocument = (new DOMParser()).parseFromString(html, "text/html")
+    let newBody = document.adoptNode(newDocument.body)
+    let newHead = document.adoptNode(newDocument.head)
+
+    mergeNewHead(newHead)
+    prepNewScriptTagsToRun(newBody)
+
+    transitionOut(document.body)
+
+    // @todo: only setTimeout when applying transitions
+    // setTimeout(() => {
+        let oldBody = document.body
+
+        document.body.replaceWith(newBody)
+
+        Alpine.destroyTree(oldBody)
+
+        transitionIn(newBody)
+
+        andThen()
+    // }, 0)
+}
+
+function transitionOut(body) {
+    return;
+    body.style.transition = 'all .5s ease'
+    body.style.opacity = '0'
+}
+
+function transitionIn(body) {
+    return;
+    body.style.opacity = '0'
+    body.style.transition = 'all .5s ease'
+
+    requestAnimationFrame(() => {
+        body.style.opacity = '1'
+    })
+}
+
+function prepNewScriptTagsToRun(newBody) {
+    newBody.querySelectorAll('script').forEach(i => {
+        if (i.hasAttribute('x-navigate:ignore')) return
+
+        i.replaceWith(cloneScriptTag(i))
+    })
+}
+
+function mergeNewHead(newHead) {
+    let headChildrenHtmlLookup = Array.from(document.head.children).map(i => i.outerHTML)
+
+    // Only add scripts and styles that aren't already loaded on the page.
+    let garbageCollector = document.createDocumentFragment()
+
+    for (child of Array.from(newHead.children)) {
+        if (isAsset(child)) {
+            if (! headChildrenHtmlLookup.includes(child.outerHTML)) {
+                if (isScript(child)) {
+                    document.head.appendChild(cloneScriptTag(child))
+                } else {
+                    document.head.appendChild(child)
+                }
+            } else {
+                garbageCollector.appendChild(child)
+            }
+        }
+    }
+
+    // How to free up the garbage collector?
+
+    // Remove existing non-asset elements like meta, base, title, template.
+    for (child of Array.from(document.head.children)) {
+        if (! isAsset(child)) child.remove()
+    }
+
+    // Add new non-asset elements left over in the new head element.
+    for (child of Array.from(newHead.children)) {
+        document.head.appendChild(child)
+    }
+}
+
+function cloneScriptTag(el) {
+    let script = document.createElement('script')
+
+    script.textContent = el.textContent
+    script.async = el.async
+
+    for (attr of el.attributes) {
+        script.setAttribute(attr.name, attr.value)
+    }
+
+    return script
+}
+
+function isAsset (el) {
+    return (el.tagName.toLowerCase() === 'link' && el.getAttribute('rel').toLowerCase() === 'stylesheet')
+        || el.tagName.toLowerCase() === 'style'
+        || el.tagName.toLowerCase() === 'script'
+}
+
+function isScript (el)   {
+    return el.tagName.toLowerCase() === 'script'
+}
+

+ 27 - 0
packages/navigate/src/persist.js

@@ -0,0 +1,27 @@
+import Alpine from "alpinejs/src/alpine"
+
+let els = {}
+
+export function storePersistantElementsForLater() {
+    els = {}
+
+    document.querySelectorAll('[x-navigate\\:persist]').forEach(i => {
+        els[i.getAttribute('x-navigate:persist')] = i
+
+        Alpine.mutateDom(() => {
+            i.remove()
+        })
+    })
+}
+
+export function putPersistantElementsBack() {
+    document.querySelectorAll('[x-navigate\\:persist]').forEach(i => {
+        let old = els[i.getAttribute('x-navigate:persist')]
+
+        old._x_wasPersisted = true
+
+        Alpine.mutateDom(() => {
+            i.replaceWith(old)
+        })
+    })
+}

+ 45 - 0
packages/navigate/src/prefetch.js

@@ -0,0 +1,45 @@
+
+// Warning: this could cause some memory leaks
+let prefetches = {}
+
+export function prefetchHtml(destination, callback) {
+    let path = destination.pathname
+
+    if (prefetches[path]) return
+
+    prefetches[path] = { finished: false, html: null, whenFinished: () => {} }
+
+    fetch(path).then(i => i.text()).then(html => {
+        callback(html)
+    })
+}
+
+export function storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination) {
+    let state = prefetches[destination.pathname]
+    state.html = html
+    state.finished = true
+    state.whenFinished()
+}
+
+export function getPretchedHtmlOr(destination, receive, ifNoPrefetchExists) {
+    let path = destination.pathname
+
+    if (! prefetches[path]) return ifNoPrefetchExists()
+
+    if (prefetches[path].finished) {
+        let html = prefetches[path].html
+
+        delete prefetches[path]
+
+        return receive(html)
+    } else {
+        prefetches[path].whenFinished = () => {
+            let html = prefetches[path].html
+
+            delete prefetches[path]
+
+            receive(html)
+        }
+    }
+}
+

+ 24 - 0
packages/navigate/src/scroll.js

@@ -0,0 +1,24 @@
+
+export function storeScrollInformationInHtmlBeforeNavigatingAway() {
+    document.body.setAttribute('data-scroll-x', document.body.scrollLeft)
+    document.body.setAttribute('data-scroll-y', document.body.scrollTop)
+
+    document.querySelectorAll(['[x-navigate\\:scroll]', '[wire\\:scroll]']).forEach(el => {
+        el.setAttribute('data-scroll-x', el.scrollLeft)
+        el.setAttribute('data-scroll-y', el.scrollTop)
+    })
+}
+
+export function restoreScrollPosition() {
+    let scroll = el => {
+        el.scrollTo(Number(el.getAttribute('data-scroll-x')), Number(el.getAttribute('data-scroll-y')))
+        el.removeAttribute('data-scroll-x')
+        el.removeAttribute('data-scroll-y')
+    }
+
+    queueMicrotask(() => {
+        scroll(document.body)
+
+        document.querySelectorAll(['[x-navigate\\:scroll]', '[wire\\:scroll]']).forEach(scroll)
+    })
+}

+ 15 - 0
packages/persist/src/index.js

@@ -29,6 +29,21 @@ export default function (Alpine) {
 
     Object.defineProperty(Alpine, '$persist', { get: () => persist() })
     Alpine.magic('persist', persist)
+    Alpine.persist = (key, { get, set }, storage = localStorage) => {
+        let initial = storageHas(key, storage)
+            ? storageGet(key, storage)
+            : get()
+
+        set(initial)
+
+        Alpine.effect(() => {
+            let value = get()
+
+            storageSet(key, value, storage)
+
+            set(value)
+        })
+    }
 }
 
 function storageHas(key, storage) {

+ 1 - 0
scripts/build.js

@@ -13,6 +13,7 @@ let brotliSize = require('brotli-size');
     'morph',
     'focus',
     'mask',
+    'navigate',
     'ui',
 ]).forEach(package => {
     if (! fs.existsSync(`./packages/${package}/dist`)) {

+ 71 - 0
tests/cypress/integration/entangle.spec.js

@@ -0,0 +1,71 @@
+import { haveValue, html, test } from '../utils'
+
+test('can entangle to getter/setter pairs',
+    [html`
+    <div x-data="{ outer: 'foo' }">
+        <input x-model="outer" outer>
+
+        <div x-data="{ inner: 'bar' }" x-init="() => {}; Alpine.entangle(
+            {
+                get() { return outer },
+                set(value) { outer = value },
+            },
+            {
+                get() { return inner },
+                set(value) { inner = value },
+            }
+        )">
+            <input x-model="inner" inner>
+        </div>
+    </div>
+    `],
+    ({ get }) => {
+        get('input[outer]').should(haveValue('foo'))
+        get('input[inner]').should(haveValue('foo'))
+
+        get('input[inner]').type('bar')
+        get('input[inner]').should(haveValue('foobar'))
+        get('input[outer]').should(haveValue('foobar'))
+
+        get('input[outer]').type('baz')
+        get('input[outer]').should(haveValue('foobarbaz'))
+        get('input[inner]').should(haveValue('foobarbaz'))
+    }
+)
+
+test('can release entanglement',
+    [html`
+        <div x-data="{ outer: 'foo' }">
+            <input x-model="outer" outer>
+
+            <div x-data="{ inner: 'bar', release: () => {} }" x-init="() => {}; release = Alpine.entangle(
+                {
+                    get() { return outer },
+                    set(value) { outer = value },
+                },
+                {
+                    get() { return inner },
+                    set(value) { inner = value },
+                }
+            )">
+                <input x-model="inner" inner>
+
+                <button @click="release()">release</button>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('input[outer]').should(haveValue('foo'))
+        get('input[inner]').should(haveValue('foo'))
+
+        get('input[inner]').type('bar')
+        get('input[inner]').should(haveValue('foobar'))
+        get('input[outer]').should(haveValue('foobar'))
+
+        get('button').click()
+
+        get('input[inner]').type('baz')
+        get('input[inner]').should(haveValue('foobarbaz'))
+        get('input[outer]').should(haveValue('foobar'))
+    }
+)

+ 66 - 0
tests/cypress/integration/plugins/morph.spec.js

@@ -355,3 +355,69 @@ test('can morph table tr',
         get('td').should(haveText('2'))
     },
 )
+
+test('can morph with conditional markers',
+    [html`
+        <main>
+            <!-- __BLOCK__ -->
+            <div>foo<input></div>
+            <!-- __ENDBLOCK__ -->
+            <div>bar<input></div>
+        </main>
+    `],
+    ({ get }, reload, window, document) => {
+        let toHtml = html`
+        <main>
+            <!-- __BLOCK__ -->
+            <div>foo<input></div>
+            <div>baz<input></div>
+            <!-- __ENDBLOCK__ -->
+            <div>bar<input></div>
+        </main>
+        `
+
+        get('div:nth-of-type(1) input').type('foo')
+        get('div:nth-of-type(2) input').type('bar')
+
+        get('main').then(([el]) => window.Alpine.morph(el, toHtml))
+
+        get('div:nth-of-type(1) input').should(haveValue('foo'))
+        get('div:nth-of-type(2) input').should(haveValue(''))
+        get('div:nth-of-type(3) input').should(haveValue('bar'))
+    },
+)
+
+test('can morph with flat-nested conditional markers',
+    [html`
+        <main>
+            <!-- __BLOCK__ -->
+            <div>foo<input></div>
+            <!-- __BLOCK__ -->
+            <!-- __ENDBLOCK__ -->
+            <!-- __ENDBLOCK__ -->
+            <div>bar<input></div>
+        </main>
+    `],
+    ({ get }, reload, window, document) => {
+        let toHtml = html`
+        <main>
+            <!-- __BLOCK__ -->
+            <div>foo<input></div>
+            <!-- __BLOCK__ -->
+            <!-- __ENDBLOCK__ -->
+            <div>baz<input></div>
+            <!-- __ENDBLOCK__ -->
+            <div>bar<input></div>
+        </main>
+        `
+
+        get('div:nth-of-type(1) input').type('foo')
+        get('div:nth-of-type(2) input').type('bar')
+
+        get('main').then(([el]) => window.Alpine.morph(el, toHtml))
+
+        get('div:nth-of-type(1) input').should(haveValue('foo'))
+        get('div:nth-of-type(2) input').should(haveValue(''))
+        get('div:nth-of-type(3) input').should(haveValue('bar'))
+    },
+)

+ 163 - 0
tests/cypress/integration/plugins/navigate.spec.js

@@ -0,0 +1,163 @@
+import { beEqualTo, beVisible, haveAttribute, haveFocus, haveText, html, notBeVisible, test } from '../../utils'
+
+// Test persistant peice of layout
+// Handle non-origin links and such
+// Handle 404
+// Middle/command click link in new tab works?
+// Infinite scroll scenario, back button works
+//
+
+it.skip('navigates pages without reload',
+    () => {
+        cy.intercept('/first', {
+            headers: { 'content-type': 'text/html' },
+            body: html`
+                <html>
+                    <head>
+                        <script src="/../../packages/navigate/dist/cdn.js" defer></script>
+                        <script src="/../../packages/alpinejs/dist/cdn.js" defer></script>
+                    </head>
+                    <body>
+                        <a href="/second">Navigate</a>
+
+                        <h2>First Page</h2>
+
+                        <script>
+                            window.fromFirstPage = true
+                        </script>
+                    </body>
+                </html>
+        `})
+
+        cy.intercept('/second', {
+            headers: { 'content-type': 'text/html' },
+            body: html`
+                <html>
+                    <head>
+                        <script src="/../../packages/navigate/dist/cdn.js" defer></script>
+                        <script src="/../../packages/alpinejs/dist/cdn.js" defer></script>
+                    </head>
+                    <body>
+                        <h2>Second Page</h2>
+                    </body>
+                </html>
+        `})
+
+        cy.visit('/first')
+        cy.window().its('fromFirstPage').should(beEqualTo(true))
+        cy.get('h2').should(haveText('First Page'))
+
+        cy.get('a').click()
+
+        cy.url().should('include', '/second')
+        cy.get('h2').should(haveText('Second Page'))
+        cy.window().its('fromFirstPage').should(beEqualTo(true))
+    },
+)
+
+it.skip('autofocuses autofocus elements',
+    () => {
+        cy.intercept('/first', {
+            headers: { 'content-type': 'text/html' },
+            body: html`
+                <html>
+                    <head>
+                        <script src="/../../packages/navigate/dist/cdn.js" defer></script>
+                        <script src="/../../packages/alpinejs/dist/cdn.js" defer></script>
+                    </head>
+                    <body>
+                        <a href="/second">Navigate</a>
+                    </body>
+                </html>
+        `})
+
+        cy.intercept('/second', {
+            headers: { 'content-type': 'text/html' },
+            body: html`
+                <html>
+                    <head>
+                        <script src="/../../packages/navigate/dist/cdn.js" defer></script>
+                        <script src="/../../packages/alpinejs/dist/cdn.js" defer></script>
+                    </head>
+                    <body>
+                        <input type="text" autofocus>
+                    </body>
+                </html>
+        `})
+
+        cy.visit('/first')
+
+        cy.get('a').click()
+
+        cy.url().should('include', '/second')
+        cy.get('input').should(haveFocus())
+    },
+)
+
+it.skip('scripts and styles are properly merged/run or skipped',
+    () => {
+        cy.intercept('/first', {
+            headers: { 'content-type': 'text/html' },
+            body: html`
+                <html>
+                    <head>
+                        <title>First Page</title>
+                        <meta name="description" content="first description">
+                        <script src="/../../packages/navigate/dist/cdn.js" defer></script>
+                        <script src="/../../packages/alpinejs/dist/cdn.js" defer></script>
+                    </head>
+                    <body>
+                        <a href="/second">Navigate</a>
+                    </body>
+                </html>
+        `})
+
+        cy.intercept('/head-script.js', {
+            headers: { 'content-type': 'text/js' },
+            body: `window.fromHeadScript = true`
+        })
+
+        cy.intercept('/body-script.js', {
+            headers: { 'content-type': 'text/js' },
+            body: `window.fromBodyScript = true`
+        })
+
+        cy.intercept('/head-style.css', {
+            headers: { 'content-type': 'text/css' },
+            body: `body { background: black !important; }`
+        })
+
+        cy.intercept('/second', {
+            headers: { 'content-type': 'text/html' },
+            body: html`
+                <html>
+                    <head>
+                        <title>Second Page</title>
+                        <meta name="description" content="second description">
+                        <script src="/../../packages/navigate/dist/cdn.js" defer></script>
+                        <script src="/../../packages/alpinejs/dist/cdn.js" defer></script>
+                        <script src="head-script.js" defer></script>
+                        <script>window.fromHeadScriptInline = true</script>
+                        <link rel="stylesheet" src="head-style.css"></script>
+                    </head>
+                    <body>
+                        <script src="body-script.js" defer></script>
+                        <script>window.fromBodyScriptInline = true</script>
+                    </body>
+                </html>
+        `})
+
+        cy.visit('/first')
+
+        cy.get('a').click()
+
+        cy.url().should('include', '/second')
+        cy.title().should(beEqualTo('Second Page'))
+        cy.get('meta').should(haveAttribute('name', 'description'))
+        cy.get('meta').should(haveAttribute('content', 'second description'))
+        cy.window().its('fromHeadScript').should(beEqualTo(true))
+        cy.window().its('fromHeadScriptInline').should(beEqualTo(true))
+        cy.window().its('fromBodyScript').should(beEqualTo(true))
+        cy.window().its('fromBodyScriptInline').should(beEqualTo(true))
+    },
+)

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است