Skip to content

Commit

Permalink
Fix push-state bug with mismatching component ids
Browse files Browse the repository at this point in the history
  • Loading branch information
calebporzio committed Sep 13, 2020
1 parent 1cb6e76 commit 002c0b9
Show file tree
Hide file tree
Showing 10 changed files with 273 additions and 32 deletions.
2 changes: 1 addition & 1 deletion dist/livewire.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/livewire.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/manifest.json
@@ -1 +1 @@
{"/livewire.js":"/livewire.js?id=f0fd1dc073b40d76e758"}
{"/livewire.js":"/livewire.js?id=a81202664112397d89a2"}
59 changes: 59 additions & 0 deletions js/Store.js
Expand Up @@ -120,6 +120,29 @@ const store = {
this.hooks.call(name, ...params)
},

changeComponentId(component, newId) {
let oldId = component.id

component.id = newId
component.fingerprint.id = newId

this.componentsById[newId] = component

delete this.componentsById[oldId]

// Now go through any parents of this component and change
// the component's child id references.
this.components().forEach(component => {
let children = component.serverMemo.children || {}

Object.entries(children).forEach(([key, { id, tagName }]) => {
if (id === oldId) {
children[key].id = newId
}
})
})
},

removeComponent(component) {
// Remove event listeners attached to the DOM.
component.tearDown()
Expand All @@ -130,6 +153,42 @@ const store = {
onError(callback) {
this.onErrorCallback = callback
},

getClosestParentId(childId, subsetOfParentIds) {
let distancesByParentId = {}

subsetOfParentIds.forEach(parentId => {
let distance = this.getDistanceToChild(parentId, childId)

if (distance) distancesByParentId[parentId] = distance
})

let smallestDistance = Math.min(...Object.values(distancesByParentId))

let closestParentId

Object.entries(distancesByParentId).forEach(([parentId, distance]) => {
if (distance === smallestDistance) closestParentId = parentId
})

return closestParentId
},

getDistanceToChild(parentId, childId, distanceMemo = 1) {
let parentComponent = this.findComponent(parentId)

if (! parentComponent) return

let childIds = parentComponent.childIds

if (childIds.includes(childId)) return distanceMemo

for (let i = 0; i < childIds.length; i++) {
let distance = this.getDistanceToChild(childIds[i], childId, distanceMemo + 1)

if (distance) return distance
}
}
}

export default store
200 changes: 172 additions & 28 deletions js/component/SyncBrowserHistory.js
Expand Up @@ -6,68 +6,66 @@ export default function () {
let initializedPath = false

// This is to prevent exponentially increasing the size of our state on page refresh.
if (window.history.state) window.history.state.livewire = {};
if (window.history.state) window.history.state.livewire = (new LivewireState).toStateArray();

store.registerHook('component.initialized', component => {
if (! component.effects.path) return

let state = generateNewState(component, generateInitialFauxResponse(component))
let url = initializedPath ? undefined : component.effects.path
// We are using setTimeout() to make sure all the components on the page have
// loaded before we store anything in the history state (because the position
// of a component on a page matters for generating its state signature).
setTimeout(() => {
let state = generateNewState(component, generateInitialFauxResponse(component))

store.callHook('beforeReplaceState', state, url, component)
let url = initializedPath ? undefined : component.effects.path

history.replaceState(state, '', url)
initializedPath = true
store.callHook('beforeReplaceState', state, url, component)

history.replaceState(state, '', onlyChangeThePathAndQueryString(url))

initializedPath = true
})
})

store.registerHook('message.processed', (message, component) => {
// Preventing a circular dependancy.
if (message.replaying) return

let { response } = message

let effects = response.effects || {}

if ('path' in effects && effects.path !== window.location.href) {
let state = generateNewState(component, response)

store.callHook('beforePushState', state, effects.path, component)

history.pushState(state, '', effects.path)
history.pushState(state, '', onlyChangeThePathAndQueryString(effects.path))
}
})

window.addEventListener('popstate', event => {
if (!(event && event.state && event.state.livewire)) return

Object.entries(event.state.livewire).forEach(([id, storageKey]) => {
let component = store.findComponent(id)
if (! component) return

let response = JSON.parse(sessionStorage.getItem(storageKey))

if (! response) return console.warn(`Livewire: sessionStorage key not found: ${storageKey}`)
if (! (event.state && event.state.livewire)) return

(new LivewireState(event.state.livewire)).replayResponses((response, component) => {
let message = new Message(component, [])

message.storeResponse(response)

message.replaying = true

component.handleResponse(message)
})
})

function generateNewState(component, response, cache = {}) {
let state = (history.state && history.state.livewire) ? { ...history.state.livewire } : {}

let storageKey = Math.random().toString(36).substring(2)

// Add ALL properties as "dirty" so that when the back button is pressed,
// they ALL are forced to refresh on the page (even if the HTML didn't change).
response.effects.dirty = Object.keys(response.serverMemo.data)

sessionStorage.setItem(storageKey, JSON.stringify(response))
function generateNewState(component, response) {
let state = history.state && history.state.livewire
? new LivewireState([...history.state.livewire])
: new LivewireState

state[component.id] = storageKey
state.storeResponse(response, component)

return { livewire: state }
return { livewire: state.toStateArray() }
}

function generateInitialFauxResponse(component) {
Expand All @@ -78,4 +76,150 @@ export default function () {
effects: { ...effects, html: el.outerHTML }
}
}

function onlyChangeThePathAndQueryString(url) {
if (! url) return

let destination = new URL(url)

let afterOrigin = destination.href.replace(destination.origin, '')

return window.location.origin + afterOrigin + window.location.hash
}

store.registerHook('element.updating', (from, to, component) => {
// It looks like the element we are about to update is the root
// element of the component. Let's store this knowledge to
// reference after update in the "element.updated" hook.
if (from.getAttribute('wire:id') === component.id) {
component.lastKnownDomId = component.id
}
})

store.registerHook('element.updated', (node, component) => {
// If the element that was just updated was the root DOM element.
if (component.lastKnownDomId) {
// Let's check and see if the wire:id was the thing that changed.
if (node.getAttribute('wire:id') !== component.lastKnownDomId) {
// If so, we need to change this ID globally everwhere it's referenced.
store.changeComponentId(component, node.getAttribute('wire:id'))
}

// Either way, we'll unset this for the next update.
delete component.lastKnownDomId
}

// We have to update the component ID because we are replaying responses
// from similar components but with completely different IDs. If didn't
// update the component ID, the checksums would fail.
})
}

class LivewireState
{
constructor(stateArray = []) { this.items = stateArray }

toStateArray() { return this.items }

pushItemInProperOrder(signature, storageKey, component) {
let targetItem = { signature, storageKey }

// First, we'll check if this signature already has an entry, if so, replace it.
let existingIndex = this.items.findIndex(item => item.signature === signature)

if (existingIndex !== -1) return this.items[existingIndex] = targetItem

// If it doesn't already exist, we'll add it, but we MUST first see if any of its
// parents components have entries, and insert it immediately before them.
// This way, when we replay responses, we will always start with the most
// inward components and go outwards.

let closestParentId = store.getClosestParentId(component.id, this.componentIdsWithStoredResponses())

if (! closestParentId) return this.items.unshift(targetItem)

let closestParentIndex = this.items.findIndex(item => {
let { originalComponentId } = this.parseSignature(item.signature)

if (originalComponentId === closestParentId) return true
})

this.items.splice(closestParentIndex, 0, targetItem);
}

storeResponse(response, component) {
// Add ALL properties as "dirty" so that when the back button is pressed,
// they ALL are forced to refresh on the page (even if the HTML didn't change).
response.effects.dirty = Object.keys(response.serverMemo.data)

let storageKey = this.storeInSession(response)

let signature = this.getComponentNameBasedSignature(component)

this.pushItemInProperOrder(signature, storageKey, component)
}

replayResponses(callback) {
this.items.forEach(({ signature, storageKey }) => {
let component = this.findComponentBySignature(signature)

if (! component) return

let response = this.getFromSession(storageKey)

if (! response) return console.warn(`Livewire: sessionStorage key not found: ${storageKey}`)

callback(response, component)
})
}

storeInSession(value) {
let key = Math.random().toString(36).substring(2)

sessionStorage.setItem(key, JSON.stringify(Object.entries(value)))

return key
}

getFromSession(key) {
return Object.fromEntries(JSON.parse(sessionStorage.getItem(key)))
}

// We can't just store component reponses by their id because
// ids change on every refresh, so history state won't have
// a component to apply it's changes to. Instead we must
// generate a unique id based on the components name
// and it's relative position amongst others with
// the same name that are loaded on the page.
getComponentNameBasedSignature(component) {
let componentName = component.fingerprint.name
let sameNamedComponents = store.getComponentsByName(componentName)
let componentIndex = sameNamedComponents.indexOf(component)

return `${component.id}:${componentName}:${componentIndex}`
}

findComponentBySignature(signature) {
let { componentName, componentIndex } = this.parseSignature(signature)

let sameNamedComponents = store.getComponentsByName(componentName)

// If we found the component in the proper place, return it,
// otherwise return the first one.
return sameNamedComponents[componentIndex] || sameNamedComponents[0] || console.warn(`Livewire: couldn't find component on page: ${componentName}`)
}

parseSignature(signature) {
let [originalComponentId, componentName, componentIndex] = signature.split(':')

return { originalComponentId, componentName, componentIndex }
}

componentIdsWithStoredResponses() {
return this.items.map(({ signature }) => {
let { originalComponentId } = this.parseSignature(signature)

return originalComponentId
})
}
}
4 changes: 4 additions & 0 deletions js/component/index.js
Expand Up @@ -60,6 +60,10 @@ export default class Component {
return this.serverMemo.data
}

get childIds() {
return Object.values(this.serverMemo.children).map(child => child.id)
}

initialize() {
this.walk(
// Will run for every node in the component tree (not child component nodes).
Expand Down
7 changes: 6 additions & 1 deletion src/ComponentChecksumManager.php
Expand Up @@ -8,9 +8,14 @@ public function generate($fingerprint, $memo)
{
$hashKey = app('encrypter')->getKey();

// It's actually Ok if the "children" tracking is tampered with.
// Also, this way JavaScript can modify children as it needs to for
// dom-diffing purposes.
$memoSansChildren = array_diff_key($memo, array_flip(['children']));

$stringForHashing = ''
.json_encode($fingerprint)
.json_encode($memo);
.json_encode($memoSansChildren);

return hash_hmac('sha256', $stringForHashing, $hashKey);
}
Expand Down
9 changes: 9 additions & 0 deletions tests/Browser/Pagination/Test.php
Expand Up @@ -53,6 +53,15 @@ public function test_tailwind()
->assertDontSee('Post #4')
->assertSee('Post #7')
->assertQueryStringHas('page', '3')

/**
* Test that hitting the back button takes you back to the previous page after a refresh.
*/
->refresh()
->back()
->assertQueryStringHas('page', '2')
->assertDontSee('Post #7')
->assertSee('Post #4')
;
});
}
Expand Down
1 change: 1 addition & 0 deletions tests/Browser/QueryString/Component.php
Expand Up @@ -17,6 +17,7 @@ class Component extends BaseComponent
'foo',
'bar' => ['except' => 'except-value'],
'bob',
'showNestedComponent',
];

public function modifyBob()
Expand Down

0 comments on commit 002c0b9

Please sign in to comment.