瀏覽代碼

New historyState stuff

Caleb Porzio 2 年之前
父節點
當前提交
3e6609663a
共有 2 個文件被更改,包括 297 次插入102 次删除
  1. 160 82
      packages/history/src/index.js
  2. 137 20
      tests/cypress/integration/plugins/history.spec.js

+ 160 - 82
packages/history/src/index.js

@@ -1,20 +1,35 @@
+
 export default function history(Alpine) {
     Alpine.magic('queryString', (el, { interceptor }) =>  {
         let alias
+        let alwaysShow = false
+        let usePush = false
 
-        return interceptor((initialValue, getter, setter, path, key) => {
+        return interceptor((initialSeedValue, getter, setter, path, key) => {
             let queryKey = alias || path
 
-            let { initial, replace } = track(queryKey, initialValue)
+            let { initial, replace, push, pop } = track(queryKey, initialSeedValue, alwaysShow)
 
             setter(initial)
 
-            Alpine.effect(() => {
-                replace(getter())
-            })
+            if (! usePush) {
+                Alpine.effect(() => replace(getter()))
+            } else {
+                Alpine.effect(() => push(getter()))
+
+                pop(async newValue => {
+                    setter(newValue)
+
+                    let tillTheEndOfTheMicrotaskQueue = () => Promise.resolve()
+
+                    await tillTheEndOfTheMicrotaskQueue() // ...so that we preserve the internal lock...
+                })
+            }
 
             return initial
         }, func => {
+            func.alwaysShow = () => { alwaysShow = true; return func }
+            func.usePush = () => { usePush = true; return func }
             func.as = key => { alias = key; return func }
         })
     })
@@ -22,57 +37,65 @@ export default function history(Alpine) {
     Alpine.history = { track }
 }
 
-export function track(name, initialValue, except = null) {
-    let pause = false
-    let url = new URL(window.location.href)
-    let value = initialValue
+export function track(name, initialSeedValue, alwaysShow = false) {
+    let { has, get, set, remove } = queryStringUtils()
 
-    if (url.searchParams.has(name)) {
-        value = url.searchParams.get(name)
-    }
-
-    // Nothing happens here...
-    // let object = { value }
+    let url = new URL(window.location.href)
+    let isInitiallyPresentInUrl = has(url, name)
+    let initialValue = isInitiallyPresentInUrl ? get(url, name) : initialSeedValue
+    let initialValueMemo = JSON.stringify(initialValue)
+    let hasReturnedToInitialValue = (newValue) => JSON.stringify(newValue) === initialValueMemo
 
-    // url.searchParams.set(name, value)
+    if (alwaysShow) url = set(url, name, initialValue)
 
-    // replace(url.toString(), name, object)
+    replace(url, name, { value: initialValue })
 
-    return {
-        initial: value, // Initial value...
-        replace(newValue) { // Update via replaceState...
-            let object = { value: newValue }
+    let lock = false
 
-            let url = new URL(window.location.href)
+    let update = (strategy, newValue) => {
+        if (lock) return
 
-            url.searchParams.set(name, newValue)
+        let url = new URL(window.location.href)
 
-            replace(url.toString(), name, object)
-        },
-        push(newValue) { // Update via pushState...
-            if (pause) return
+        if (! alwaysShow && ! isInitiallyPresentInUrl && hasReturnedToInitialValue(newValue)) {
+            url = remove(url, name)
+        } else {
+            url = set(url, name, newValue)
+        }
 
-            let object = { value: newValue }
+        strategy(url, name, { value: newValue})
+    }
 
-            let url = new URL(window.location.href)
+    return {
+        initial: initialValue,
 
-            url.searchParams.set(name, newValue)
+        replace(newValue) { // Update via replaceState...
+            update(replace, newValue)
+        },
 
-            push(url.toString(), name, object)
+        push(newValue) { // Update via pushState...
+            update(push, newValue)
         },
-        pop(receiver) { // onPopState...
+
+        pop(receiver) { // "popstate" handler...
             window.addEventListener('popstate', (e) => {
-                if (! e.state) return
-                if (! e.state.alpine) return
+                if (! e.state || ! e.state.alpine) return
 
-                Object.entries(e.state.alpine).forEach(([newName, { value }]) => {
-                    if (newName !== name) return
+                Object.entries(e.state.alpine).forEach(([iName, { value: newValue }]) => {
+                    if (iName !== name) return
 
-                    pause = true
+                    lock = true
 
-                    receiver(value)
+                    // Allow the "receiver" to be an async function in case a non-syncronous
+                    // operation (like an ajax) requests needs to happen while preserving
+                    // the "locking" mechanism ("lock = true" in this case)...
+                    let result = receiver(newValue)
 
-                    pause = false
+                    if (result instanceof Promise) {
+                        result.finally(() => lock = false)
+                    } else {
+                        lock = false
+                    }
                 })
             })
         }
@@ -84,72 +107,127 @@ function replace(url, key, object) {
 
     if (! state.alpine) state.alpine = {}
 
-    state.alpine[key] = object
+    state.alpine[key] = unwrap(object)
 
-    window.history.replaceState(state, '', url)
+    window.history.replaceState(state, '', url.toString())
 }
 
 function push(url, key, object) {
-    let state = { alpine: {...window.history.state.alpine, ...{[key]: object}} }
+    let state = { alpine: {...window.history.state.alpine, ...{[key]: unwrap(object)}} }
 
-    window.history.pushState(state, '', url)
+    window.history.pushState(state, '', url.toString())
 }
 
-// Alpine.magic('queryString', (el, { interceptor }) =>  {
-//     let alias
+function unwrap(object) {
+    return JSON.parse(JSON.stringify(object))
+}
 
-//     return interceptor((initialValue, getter, setter, path, key) => {
-//         let pause = false
-//         let queryKey = alias || path
+function queryStringUtils() {
+    return {
+        has(url, key) {
+            let search = url.search
 
-//         let value = initialValue
-//         let url = new URL(window.location.href)
+            if (! search) return false
 
-//         if (url.searchParams.has(queryKey)) {
-//             value = url.searchParams.get(queryKey)
-//         }
+            let data = fromQueryString(search)
 
-//         setter(value)
+            return Object.keys(data).includes(key)
+        },
+        get(url, key) {
+            let search = url.search
 
-//         let object = { value }
+            if (! search) return false
 
-//         url.searchParams.set(queryKey, value)
+            let data = fromQueryString(search)
 
-//         replace(url.toString(), path, object)
+            return data[key]
+        },
+        set(url, key, value) {
+            let data = fromQueryString(url.search)
 
-//         window.addEventListener('popstate', (e) => {
-//             if (! e.state) return
-//             if (! e.state.alpine) return
+            data[key] = value
 
-//             Object.entries(e.state.alpine).forEach(([newKey, { value }]) => {
-//                 if (newKey !== key) return
+            url.search = toQueryString(data)
 
-//                 pause = true
+            return url
+        },
+        remove(url, key) {
+            let data = fromQueryString(url.search)
+
+            delete data[key]
+
+            url.search = toQueryString(data)
+
+            return url
+        },
+    }
+}
+
+// This function converts JavaScript data to bracketed query string notation...
+// { items: [['foo']] } -> "items[0][0]=foo"
+function toQueryString(data) {
+    let isObjecty = (subject) => typeof subject === 'object' && subject !== null
+
+    let buildQueryStringEntries = (data, entries = {}, baseKey = '') => {
+        Object.entries(data).forEach(([iKey, iValue]) => {
+            let key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`
+
+            if (! isObjecty(iValue)) {
+                entries[key] = encodeURIComponent(iValue)
+                    .replaceAll('%20', '+') // Conform to RFC1738
+            } else {
+                entries = {...entries, ...buildQueryStringEntries(iValue, entries, key)}
+            }
+        })
+
+        return entries
+    }
+
+    let entries = buildQueryStringEntries(data)
 
-//                 Alpine.disableEffectScheduling(() => {
-//                     setter(value)
-//                 })
 
-//                 pause = false
-//             })
-//         })
+    return Object.entries(entries).map(([key, value]) => `${key}=${value}`).join('&')
+}
+
+// This function converts bracketed query string notation back to JS data...
+// "items[0][0]=foo" -> { items: [['foo']] }
+function fromQueryString(search) {
+    search = search.replace('?', '')
+
+    if (search === '') return {}
+
+    let insertDotNotatedValueIntoData = (key, value, data) => {
+        let [first, second, ...rest] = key.split('.')
+
+        // We're at a leaf node, let's make the assigment...
+        if (! second) return data[key] = value
+
+        // This is where we fill in empty arrays/objects allong the way to the assigment...
+        if (data[first] === undefined) {
+            data[first] = isNaN(second) ? {} : []
+        }
+
+        // Keep deferring assignment until the full key is built up...
+        insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first])
+    }
 
-//         Alpine.effect(() => {
-//             let value = getter()
+    let entries = search.split('&').map(i => i.split('='))
 
-//             if (pause) return
+    let data = {}
 
-//             let object = { value }
+    entries.forEach(([key, value]) => {
+        value = decodeURIComponent(value.replaceAll('+', '%20'))
 
-//             let url = new URL(window.location.href)
+        if (! key.includes('[')) {
+            data[key] = value
+        } else {
+            // Convert to dot notation because it's easier...
+            let dotNotatedKey = key.replaceAll('[', '.').replaceAll(']', '')
 
-//             url.searchParams.set(queryKey, value)
+            insertDotNotatedValueIntoData(dotNotatedKey, value, data)
+        }
+    })
 
-//             push(url.toString(), path, object)
-//         })
+    return data
+}
 
-//         return value
-//     }, func => {
-//         func.as = key => { alias = key; return func }
-//     })
-// })

+ 137 - 20
tests/cypress/integration/plugins/history.spec.js

@@ -1,84 +1,129 @@
 import { haveText, html, test } from '../../utils'
 
-test('can go back and forth',
+test('value is reflected in query string upon changing',
     [html`
         <div x-data="{ count: $queryString(1) }">
             <button @click="count++">Inc</button>
+            <h1 @click="count--">Dec</h1>
             <span x-text="count"></span>
         </div>
     `],
     ({ get, url, go }) => {
         get('span').should(haveText('1'))
-        url().should('include', '?count=1')
+        url().should('not.include', '?count=1')
         get('button').click()
         get('span').should(haveText('2'))
         url().should('include', '?count=2')
-        go('back')
+        get('button').click()
+        get('span').should(haveText('3'))
+        url().should('include', '?count=3')
+        get('h1').click()
+        get('h1').click()
+        get('span').should(haveText('1'))
+        url().should('not.include', '?count=1')
+    },
+)
+
+test('can configure always making the query string value present',
+    [html`
+        <div x-data="{ count: $queryString(1).alwaysShow() }">
+            <button @click="count++">Inc</button>
+            <h1 @click="count--">Dec</h1>
+            <span x-text="count"></span>
+        </div>
+    `],
+    ({ get, url, go }) => {
         get('span').should(haveText('1'))
         url().should('include', '?count=1')
-        go('forward')
+        get('button').click()
         get('span').should(haveText('2'))
         url().should('include', '?count=2')
+        get('h1').click()
+        get('span').should(haveText('1'))
+        url().should('include', '?count=1')
     },
 )
 
-test('property is set from the query string on load',
+test('value is persisted across requests',
     [html`
         <div x-data="{ count: $queryString(1) }">
             <button @click="count++">Inc</button>
             <span x-text="count"></span>
         </div>
     `],
-    ({ get, url }, reload) => {
+    ({ get, url, go }, reload) => {
         get('span').should(haveText('1'))
-        url().should('include', '?count=1')
+        url().should('not.include', '?count=1')
         get('button').click()
         get('span').should(haveText('2'))
         url().should('include', '?count=2')
+
         reload()
+
+        url().should('include', '?count=2')
         get('span').should(haveText('2'))
     },
 )
 
-test('can use a query string key alias',
+test('can provide an alias',
     [html`
-        <div x-data="{ count: $queryString(1).as('foo') }">
+        <div x-data="{ count: $queryString(1).as('tnuoc') }">
             <button @click="count++">Inc</button>
             <span x-text="count"></span>
         </div>
     `],
-    ({ get, url }, reload) => {
+    ({ get, url, go }) => {
         get('span').should(haveText('1'))
-        url().should('include', '?foo=1')
+        url().should('not.include', '?tnuoc=1')
         get('button').click()
         get('span').should(haveText('2'))
-        url().should('include', '?foo=2')
-        reload()
+        url().should('include', '?tnuoc=2')
+    },
+)
+
+test('can use pushState',
+    [html`
+        <div x-data="{ count: $queryString(1).usePush() }">
+            <button @click="count++">Inc</button>
+            <span x-text="count"></span>
+        </div>
+    `],
+    ({ get, url, go }) => {
+        get('span').should(haveText('1'))
+        url().should('not.include', '?count=1')
+        get('button').click()
         get('span').should(haveText('2'))
+        url().should('include', '?count=2')
+        go('back')
+        get('span').should(haveText('1'))
+        url().should('not.include', '?count=1')
+        go('forward')
+        get('span').should(haveText('2'))
+        url().should('include', '?count=2')
     },
 )
 
 test('can go back and forth with multiple components',
     [html`
-        <div x-data="{ foo: $queryString(1) }" id="foo">
+        <div x-data="{ foo: $queryString(1).usePush() }" id="foo">
             <button @click="foo++">Inc</button>
             <span x-text="foo"></span>
         </div>
 
-        <div x-data="{ bar: $queryString(1) }" id="bar">
+        <div x-data="{ bar: $queryString(1).usePush() }" id="bar">
             <button @click="bar++">Inc</button>
             <span x-text="bar"></span>
         </div>
     `],
     ({ get, url, go }) => {
         get('#foo span').should(haveText('1'))
-        url().should('include', 'foo=1')
+        url().should('not.include', 'foo=1')
         get('#foo button').click()
         get('#foo span').should(haveText('2'))
         url().should('include', 'foo=2')
 
         get('#bar span').should(haveText('1'))
-        url().should('include', 'bar=1')
+        url().should('not.include', 'bar=1')
         get('#bar button').click()
         get('#bar span').should(haveText('2'))
         url().should('include', 'bar=2')
@@ -86,15 +131,87 @@ test('can go back and forth with multiple components',
         go('back')
 
         get('#bar span').should(haveText('1'))
-        url().should('include', 'bar=1')
+        url().should('not.include', 'bar=1')
         get('#foo span').should(haveText('2'))
         url().should('include', 'foo=2')
 
         go('back')
 
         get('#bar span').should(haveText('1'))
-        url().should('include', 'bar=1')
+        url().should('not.include', 'bar=1')
         get('#foo span').should(haveText('1'))
-        url().should('include', 'foo=1')
+        url().should('not.include', 'foo=1')
+    },
+)
+
+test('supports arrays',
+    [html`
+        <div x-data="{ items: $queryString(['foo']) }">
+            <button @click="items.push('bar')">Inc</button>
+            <span x-text="JSON.stringify(items)"></span>
+        </div>
+    `],
+    ({ get, url, go }, reload) => {
+        get('span').should(haveText('["foo"]'))
+        url().should('not.include', '?items')
+        get('button').click()
+        get('span').should(haveText('["foo","bar"]'))
+        url().should('include', '?items[0]=foo&items[1]=bar')
+        reload()
+        url().should('include', '?items[0]=foo&items[1]=bar')
+        get('span').should(haveText('["foo","bar"]'))
+    },
+)
+
+test('supports deep arrays',
+    [html`
+        <div x-data="{ items: $queryString(['foo', ['bar', 'baz']]) }">
+            <button @click="items[1].push('bob')">Inc</button>
+            <span x-text="JSON.stringify(items)"></span>
+        </div>
+    `],
+    ({ get, url, go }, reload) => {
+        get('span').should(haveText('["foo",["bar","baz"]]'))
+        url().should('not.include', '?items')
+        get('button').click()
+        get('span').should(haveText('["foo",["bar","baz","bob"]]'))
+        url().should('include', '?items[0]=foo&items[1][0]=bar&items[1][1]=baz&items[1][2]=bob')
+        reload()
+        url().should('include', '?items[0]=foo&items[1][0]=bar&items[1][1]=baz&items[1][2]=bob')
+        get('span').should(haveText('["foo",["bar","baz","bob"]]'))
+    },
+)
+
+test('supports objects',
+    [html`
+        <div x-data="{ items: $queryString({ foo: 'bar' }) }">
+            <button @click="items.bob = 'lob'">Inc</button>
+            <span x-text="JSON.stringify(items)"></span>
+        </div>
+    `],
+    ({ get, url, go }, reload) => {
+        get('span').should(haveText('{"foo":"bar"}'))
+        url().should('not.include', '?items')
+        get('button').click()
+        get('span').should(haveText('{"foo":"bar","bob":"lob"}'))
+        url().should('include', '?items[foo]=bar&items[bob]=lob')
+        reload()
+        url().should('include', '?items[foo]=bar&items[bob]=lob')
+        get('span').should(haveText('{"foo":"bar","bob":"lob"}'))
+    },
+)
+
+test('encodes values according to RFC 1738 (plus signs for spaces)',
+    [html`
+        <div x-data="{ foo: $queryString('hey&there').alwaysShow(), bar: $queryString('hey there').alwaysShow() }">
+            <span x-text="JSON.stringify(foo)+JSON.stringify(bar)"></span>
+        </div>
+    `],
+    ({ get, url, go }, reload) => {
+        url().should('include', '?foo=hey%26there&bar=hey+there')
+        get('span').should(haveText('"hey&there""hey there"'))
+        reload()
+        url().should('include', '?foo=hey%26there&bar=hey+there')
+        get('span').should(haveText('"hey&there""hey there"'))
     },
 )