Caleb Porzio 3 سال پیش
والد
کامیت
7db009afff

+ 4 - 2
index.html

@@ -2,6 +2,8 @@
 <script src="./packages/navigate/dist/cdn.js" defer></script>
 <script src="./packages/alpinejs/dist/cdn.js" defer></script>
 
+
+
 <hr>
 <a href="/index2.html">Next</a>
 <hr>
@@ -46,11 +48,11 @@ First page:
     </div>
 </div>
 
-<div x-data="{ count: $history(1) }" x-navigate:persist="foo">
+<!-- <div x-data="{ count: $history(1) }" x-navigate:persist="foo">
     <span x-text="count"></span>
 
     <button @click="count++">+</button>
-</div>
+</div> -->
 
 <div x-data="{ count: 1 }">
     <span x-text="count"></span>

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


+ 1 - 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",

+ 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)
 }))

+ 1 - 1
packages/morph/src/morph.js

@@ -327,7 +327,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.

+ 150 - 60
packages/navigate/src/index.js

@@ -1,103 +1,193 @@
 import { replaceUrl, pushUrl, fromSessionStorage } from './history'
 import { swapPage } from './page'
+import { endProgressBar, startProgressBar } from './progressBar'
 
 export default function (Alpine) {
-    // 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)
+    setInitialPageUsingHistoryReplaceStateForFutureBackButtons()
 
     // Listen for back button presses...
-    window.addEventListener('popstate', event => {
-        let { html } = fromSessionStorage(event)
+    window.addEventListener('popstate', e => handleBackButtonPress(e, Alpine))
 
-        html && swapPage(Alpine, html, () => {
-            document.dispatchEvent(new CustomEvent('alpine:navigated', { bubbles: true }))
+    // Listen for any <a> tag click...
+    Array.from(document.links).forEach(el => {
+        el.addEventListener('mouseenter', () => handleLinkHover(el))
+        el.addEventListener('click', e => handleLinkClick(el, e))
+    })
+
+    document.addEventListener('alpine:navigated', () => {
+        Array.from(document.links).forEach(el => {
+            el.addEventListener('mouseenter', () => handleLinkHover(el))
+            el.addEventListener('click', e => handleLinkClick(el, e))
         })
     })
 
-    // Listen for any <a> tag click...
-    document.addEventListener('click', e => {
-        if (e.target.tagName.toLowerCase() !== 'a') return
+    // Alpine.magic('history', (el, { interceptor }) =>  {
+    //     let alias
 
-        let url = new URL(window.location.href, document.baseURI)
-        replaceUrl(url, document.documentElement.outerHTML)
+    //     return interceptor((initialValue, getter, setter, path, key) => {
+    //         let pause = false
+    //         let queryKey = alias || path
 
-        let destination = new URL(e.target.getAttribute('href'), document.baseURI)
+    //         let value = initialValue
+    //         let url = new URL(window.location.href)
 
-        document.dispatchEvent(new CustomEvent('alpine:navigating', { bubbles: true }))
+    //         if (url.searchParams.has(queryKey)) {
+    //             value = url.searchParams.get(queryKey)
+    //         }
 
-        fetch(destination.pathname).then(i => i.text()).then(html => {
-            swapPage(Alpine, html, () => {
-                pushUrl(destination, html)
+    //         setter(value)
 
-                document.dispatchEvent(new CustomEvent('alpine:navigated', { bubbles: true }))
-            })
-        })
+    //         let object = { value }
 
-        e.preventDefault()
-    })
+    //         url.searchParams.set(queryKey, value)
 
-    Alpine.magic('history', (el, { interceptor }) =>  {
-        let alias
+    //         replace(url.toString(), path, object)
 
-        return interceptor((initialValue, getter, setter, path, key) => {
-            let pause = false
-            let queryKey = alias || path
+    //         window.addEventListener('popstate', (e) => {
+    //             if (! e.state) return
+    //             if (! e.state.alpine) return
 
-            let value = initialValue
-            let url = new URL(window.location.href)
+    //             Object.entries(e.state.alpine).forEach(([newKey, { value }]) => {
+    //                 if (newKey !== key) return
 
-            if (url.searchParams.has(queryKey)) {
-                value = url.searchParams.get(queryKey)
-            }
+    //                 pause = true
 
-            setter(value)
+    //                 Alpine.disableEffectScheduling(() => {
+    //                     setter(value)
+    //                 })
 
-            let object = { value }
+    //                 pause = false
+    //             })
+    //         })
 
-            url.searchParams.set(queryKey, value)
+    //         Alpine.effect(() => {
+    //             let value = getter()
 
-            replace(url.toString(), path, object)
+    //             if (pause) return
 
-            window.addEventListener('popstate', (e) => {
-                if (! e.state) return
-                if (! e.state.alpine) return
+    //             let object = { value }
 
-                Object.entries(e.state.alpine).forEach(([newKey, { value }]) => {
-                    if (newKey !== key) return
+    //             let url = new URL(window.location.href)
 
-                    pause = true
+    //             url.searchParams.set(queryKey, value)
 
-                    Alpine.disableEffectScheduling(() => {
-                        setter(value)
-                    })
+    //             push(url.toString(), path, object)
+    //         })
 
-                    pause = false
-                })
-            })
+    //         return value
+    //     }, func => {
+    //         func.as = key => { alias = key; return func }
+    //     })
+    // })
+}
 
-            Alpine.effect(() => {
-                let value = getter()
+function setInitialPageUsingHistoryReplaceStateForFutureBackButtons() {
+    // 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)
 
-                if (pause) return
+    replaceUrl(url, document.documentElement.outerHTML)
+}
 
-                let object = { value }
+function handleBackButtonPress(e, Alpine) {
+    let { html } = fromSessionStorage(e)
 
-                let url = new URL(window.location.href)
+    document.dispatchEvent(new CustomEvent('alpine:navigating', { bubbles: true }))
 
-                url.searchParams.set(queryKey, value)
+    html && swapPage(Alpine, html, () => {
+        document.dispatchEvent(new CustomEvent('alpine:navigated', { bubbles: true }))
+    })
 
-                push(url.toString(), path, object)
-            })
+    restoreScroll()
+}
+
+// Warning: this could cause some memory leaks
+let prefetches = new Map
+
+function handleLinkHover(el) {
+    if (prefetches.has(el)) return
+
+    let destination = new URL(el.getAttribute('href'), document.baseURI)
 
-            return value
-        }, func => {
-            func.as = key => { alias = key; return func }
+    prefetches.set(el, { finished: false, html: null, whenFinished: () => {} })
+
+    fetch(destination.pathname).then(i => i.text()).then(html => {
+        let state = prefetches.get(el)
+        state.html = html
+        state.finished = true
+        state.whenFinished()
+    })
+}
+
+function handleLinkClick(el, e) {
+    let destination = new URL(el.getAttribute('href'), document.baseURI)
+
+    let handleHtml = html => {
+        let url = new URL(window.location.href, document.baseURI)
+
+        storeScrollRestorationDataInHTML()
+
+        replaceUrl(url, document.documentElement.outerHTML)
+
+        swapPage(Alpine, html, () => {
+            pushUrl(destination, html)
+
+            document.dispatchEvent(new CustomEvent('alpine:navigated', { bubbles: true }))
         })
+    }
+
+    document.dispatchEvent(new CustomEvent('alpine:navigating', { bubbles: true }))
+
+    if (prefetches.has(el)) {
+        let state = prefetches.get(el)
+        if (! state.finished) {
+            startProgressBar()
+
+            state.whenFinished = () => {
+                endProgressBar(() => {
+                    handleHtml(state.html)
+                    prefetches.delete(el)
+                })
+            }
+        } else {
+            handleHtml(state.html)
+            prefetches.delete(el)
+        }
+    } else {
+        startProgressBar()
+
+        fetch(destination.pathname).then(i => i.text()).then(html => {
+            endProgressBar(() => {
+                handleHtml(html)
+            })
+        })
+    }
+
+    e.preventDefault()
+}
+
+function storeScrollRestorationDataInHTML() {
+    document.body.setAttribute('data-scroll-x', document.body.scrollLeft)
+    document.body.setAttribute('data-scroll-y', document.body.scrollTop)
+
+    document.querySelectorAll('[x-navigate\\:scroll]').forEach(el => {
+        el.setAttribute('data-scroll-x', el.scrollLeft)
+        el.setAttribute('data-scroll-y', el.scrollTop)
     })
 }
 
+function restoreScroll() {
+    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')
+    }
+
+    scroll(document.body)
+
+    document.querySelectorAll('[x-navigate\\:scroll]').forEach(scroll)
+}
+
 function replace(url, key, object) {
     let state = window.history.state || {}
 

+ 66 - 9
packages/navigate/src/page.js

@@ -1,15 +1,21 @@
+
 export function swapPage(Alpine, html, beforeInit = () => {}) {
     let newDocument = (new DOMParser()).parseFromString(html, "text/html")
     let newBody = document.adoptNode(newDocument.body)
+    let newHead = document.adoptNode(newDocument.head)
 
     Alpine.stopObservingMutations()
 
     persistElements(() => {
+        mergeNewHead(newHead)
         prepNewScriptTagsToRun(newBody)
 
         document.body.replaceWith(newBody)
     })
 
+    // For some reason this is triggering an error with one of my chrome extensions.
+    // autofocusEl()
+
     beforeInit()
 
     setTimeout(() => {
@@ -23,6 +29,7 @@ function persistElements(callback) {
     let els = {}
 
     document.querySelectorAll('[x-navigate\\:persist]').forEach(i => {
+        console.log(i)
         els[i.getAttribute('x-navigate:persist')] = i
     })
 
@@ -38,17 +45,67 @@ function persistElements(callback) {
 function prepNewScriptTagsToRun(newBody) {
     newBody.querySelectorAll('script').forEach(i => {
         if (i.hasAttribute('x-navigate:ignore')) return
-        // Only re-run inline scripts.
-        if (i.hasAttribute('src')) return
 
-        let el = document.createElement('script')
-        el.textContent = i.innerText
-        el.async = false
+        i.replaceWith(cloneScriptTag(i))
+    })
+}
+
+function mergeNewHead(newHead) {
+    let headChildrenHtmlLookup = Array.from(document.head.children).map(i => i.outerHTML)
 
-        for (let { name, value } of i.attributes) {
-            el.setAttribute(name, value)
+    // 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)
+            }
         }
+    }
 
-        i.replaceWith(el)
-    })
+    // 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'
+}
+
+function autofocusEl() {
+    document.querySelector('[autofocus]') && document.querySelector('[autofocus]').focus()
 }

+ 59 - 0
packages/navigate/src/progressBar.js

@@ -0,0 +1,59 @@
+
+export function startProgressBar() {
+    createBar()
+
+    incrementBar()
+}
+
+export function endProgressBar(callback) {
+    finishProgressBar(() => { destroyBar(); callback() })
+}
+
+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)'
+    setTimeout(callback, 500)
+}
+
+function destroyBar() {
+    document.getElementById('alpine-progress-bar').remove()
+}

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

@@ -0,0 +1,160 @@
+import { beEqualTo, beVisible, haveAttribute, haveFocus, haveText, html, notBeVisible, test } from '../../utils'
+
+// Test persistant peice of layout
+// Handle 404
+// Handle non-origin links and such
+
+it('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('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('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 نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است