Skip to content

Commit

Permalink
Merge pull request #123 from Shopify/delay-navigation-for-defer
Browse files Browse the repository at this point in the history
Implement TurboHead for Head Asset tracking, test refactors, removal of flakiness.
  • Loading branch information
justinthec committed Aug 24, 2016
2 parents bf5bc64 + 4d9f97c commit 6dae1aa
Show file tree
Hide file tree
Showing 22 changed files with 1,325 additions and 249 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ lib/turbomodules.js
*.orig
.all
Gemfile.lock
.ruby-version
.*.swp
.DS_Store
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
language: ruby
rvm:
- '2.2.2'
- '2.3.1'
before_script:
- export DISPLAY=:99.0
- sh -e /etc/init.d/xvfb start
Expand Down
215 changes: 215 additions & 0 deletions lib/assets/javascripts/turbograft/turbohead.coffee
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
class window.TurboHead
constructor: (@activeDocument, @upstreamDocument) ->

update: (successCallback, failureCallback) ->
activeAssets = extractTrackedAssets(@activeDocument)
upstreamAssets = extractTrackedAssets(@upstreamDocument)
{activeScripts, newScripts} = processScripts(activeAssets, upstreamAssets)

if hasScriptConflict(activeScripts, newScripts)
return failureCallback()

updateLinkTags(activeAssets, upstreamAssets)
updateScriptTags(@activeDocument, newScripts, successCallback)

updateLinkTags = (activeAssets, upstreamAssets) ->
activeLinks = activeAssets.filter(filterForNodeType('LINK'))
upstreamLinks = upstreamAssets.filter(filterForNodeType('LINK'))
remainingActiveLinks = removeStaleLinks(activeLinks, upstreamLinks)
reorderedActiveLinks = reorderActiveLinks(remainingActiveLinks, upstreamLinks)
insertNewLinks(reorderedActiveLinks, upstreamLinks)

updateScriptTags = (activeDocument, newScripts, callback) ->
asyncSeries(
newScripts.map((scriptNode) -> insertScriptTask(activeDocument, scriptNode)),
callback
)

extractTrackedAssets = (doc) ->
for node in doc.head.children when node.dataset.turbolinksTrack?
node

filterForNodeType = (nodeType) ->
(node) -> node.nodeName == nodeType

hasScriptConflict = (activeScripts, newScripts) ->
hasExistingScriptAssetName = (upstreamNode) ->
activeScripts.some (activeNode) ->
upstreamNode.dataset.turbolinksTrackScriptAs == activeNode.dataset.turbolinksTrackScriptAs

newScripts.some(hasExistingScriptAssetName)

asyncSeries = (tasks, callback) ->
return callback() if tasks.length == 0
task = tasks.shift()
task(-> asyncSeries(tasks, callback))

insertScriptTask = (activeDocument, scriptNode) ->
# We need to clone script tags in order to ensure that the browser executes them.
newNode = activeDocument.createElement('SCRIPT')
newNode.setAttribute(attr.name, attr.value) for attr in scriptNode.attributes
newNode.appendChild(activeDocument.createTextNode(scriptNode.innerHTML))

return (done) ->
onScriptEvent = (event) ->
triggerEvent('page:script-error', event) if event.type == 'error'
newNode.removeEventListener('load', onScriptEvent)
newNode.removeEventListener('error', onScriptEvent)
done()
newNode.addEventListener('load', onScriptEvent)
newNode.addEventListener('error', onScriptEvent)
activeDocument.head.appendChild(newNode)
triggerEvent('page:after-script-inserted', newNode)

processScripts = (activeAssets, upstreamAssets) ->
activeScripts = activeAssets.filter(filterForNodeType('SCRIPT'))
upstreamScripts = upstreamAssets.filter(filterForNodeType('SCRIPT'))
hasNewSrc = (upstreamNode) ->
activeScripts.every (activeNode) ->
upstreamNode.src != activeNode.src

newScripts = upstreamScripts.filter(hasNewSrc)

{activeScripts, newScripts}

removeStaleLinks = (activeLinks, upstreamLinks) ->
isStaleLink = (link) ->
upstreamLinks.every (upstreamLink) ->
upstreamLink.href != link.href

staleLinks = activeLinks.filter(isStaleLink)

for staleLink in staleLinks
removedLink = document.head.removeChild(staleLink)
triggerEvent('page:after-link-removed', removedLink)

activeLinks.filter((link) -> !isStaleLink(link))

reorderAlreadyExists = (link1, link2, reorders) ->
reorders.some (reorderPair) ->
link1 in reorderPair && link2 in reorderPair

generateReorderGraph = (activeLinks, upstreamLinks) ->
reorders = []
for activeLink1 in activeLinks
for activeLink2 in activeLinks
continue if activeLink1.href == activeLink2.href
continue if reorderAlreadyExists(activeLink1, activeLink2, reorders)

upstreamLink1 = upstreamLinks.filter((link) -> link.href == activeLink1.href)[0]
upstreamLink2 = upstreamLinks.filter((link) -> link.href == activeLink2.href)[0]

orderHasChanged =
(activeLinks.indexOf(activeLink1) < activeLinks.indexOf(activeLink2)) !=
(upstreamLinks.indexOf(upstreamLink1) < upstreamLinks.indexOf(upstreamLink2))

reorders.push([activeLink1, activeLink2]) if orderHasChanged
reorders

nextMove = (activeLinks, reorders) ->
changesAssociatedTo = (link) ->
reorders.filter (reorderPair) ->
link in reorderPair

linksSortedByMovePriority = activeLinks
.slice()
.sort (link1, link2) ->
changesAssociatedTo(link2).length - changesAssociatedTo(link1).length

linkToMove = linksSortedByMovePriority[0]

linksToPassBy = changesAssociatedTo(linkToMove).map (reorderPair) ->
(reorderPair.filter (link) -> link.href != linkToMove.href)[0]

{linkToMove, linksToPassBy}

reorderActiveLinks = (activeLinks, upstreamLinks) ->
activeLinksCopy = activeLinks.slice()
pendingReorders = generateReorderGraph(activeLinksCopy, upstreamLinks)

removeReorder = (link1, link2) ->
reorderToRemove = (pendingReorders.filter (reorderPair) ->
link1 in reorderPair && link2 in reorderPair)[0]
indexToRemove = pendingReorders.indexOf(reorderToRemove)
pendingReorders.splice(indexToRemove, 1)

addNewReorder = (link1, link2) ->
pendingReorders.push [link1, link2]

markReorderAsFinished = (linkToMove, linkToPass, remainingLinksToPass) ->
removeReorder(linkToMove, linkToPass)
removalIndex = remainingLinksToPass.indexOf(linkToPass)
remainingLinksToPass.splice(removalIndex, 1)

removeLink = (linkToRemove, indexOfLink) ->
removedLink = document.head.removeChild(linkToRemove)
triggerEvent('page:after-link-removed', removedLink)
activeLinksCopy.splice(indexOfLink, 1)

performMove = (linkToMove, linksToPassBy) ->
moveDirection = if activeLinksCopy.indexOf(linkToMove) > activeLinksCopy.indexOf(linksToPassBy[0]) then 'UP' else 'DOWN'
startIndex = activeLinksCopy.indexOf(linkToMove)

switch moveDirection
when 'UP'
for i in [(startIndex - 1)..0]
currentLink = activeLinksCopy[i]
if currentLink in linksToPassBy
markReorderAsFinished(linkToMove, currentLink, linksToPassBy)

if linksToPassBy.length == 0
removeLink(linkToMove, startIndex)

document.head.insertBefore(linkToMove, activeLinksCopy[i])
activeLinksCopy.splice(i, 0, linkToMove)
triggerEvent('page:after-link-inserted', linkToMove)
return
else
addNewReorder(linkToMove, currentLink, pendingReorders)
when 'DOWN'
for i in [(startIndex + 1)...activeLinksCopy.length]
currentLink = activeLinksCopy[i]
if currentLink in linksToPassBy
markReorderAsFinished(linkToMove, currentLink, linksToPassBy)

if linksToPassBy.length == 0
removeLink(linkToMove, startIndex)

targetIndex = i - 1
if targetIndex == activeLinksCopy.length - 1
document.head.appendChild(linkToMove)
activeLinksCopy.push(linkToMove)
else
document.head.insertBefore(linkToMove, activeLinksCopy[targetIndex + 1])
activeLinksCopy.splice(targetIndex + 1, 0, linkToMove)
triggerEvent('page:after-link-inserted', linkToMove)
return
else
addNewReorder(linkToMove, currentLink, pendingReorders)

while pendingReorders.length > 0
{linkToMove, linksToPassBy} = nextMove(activeLinksCopy, pendingReorders)
performMove(linkToMove, linksToPassBy)

activeLinksCopy

insertNewLinks = (activeLinks, upstreamLinks) ->
isNewLink = (link) ->
activeLinks.every (activeLink) ->
activeLink.href != link.href

upstreamLinks
.filter(isNewLink)
.reverse() # This is because we can't insert before a sibling that hasn't been inserted yet.
.forEach (newUpstreamLink) ->
index = upstreamLinks.indexOf(newUpstreamLink)
newActiveLink = newUpstreamLink.cloneNode()
if index == upstreamLinks.length - 1
document.head.appendChild(newActiveLink)
activeLinks.push(newActiveLink)
else
targetIndex = activeLinks.indexOf((activeLinks.filter (link) ->
link.href == upstreamLinks[index + 1].href)[0])
document.head.insertBefore(newActiveLink, activeLinks[targetIndex])
activeLinks.splice(targetIndex, 0, newActiveLink)
triggerEvent('page:after-link-inserted', newActiveLink)
81 changes: 42 additions & 39 deletions lib/assets/javascripts/turbograft/turbolinks.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ removeNode = (node) ->
class window.Turbolinks
createDocument = null
currentState = null
loadedAssets = null
referer = null

fetch = (url, options = {}) ->
Expand All @@ -80,6 +79,10 @@ class window.Turbolinks

fetchReplacement url, options

@fullPageNavigate: (url) ->
triggerEvent('page:before-full-refresh', url: url)
document.location.href = url

@pushState: (state, title, url) ->
window.history.pushState(state, title, url)

Expand All @@ -89,26 +92,36 @@ class window.Turbolinks
fetchReplacement = (url, options) ->
triggerEvent 'page:fetch', url: url.absolute

xhr?.abort()
if xhr?
# Workaround for sinon xhr.abort()
# https://github.com/sinonjs/sinon/issues/432#issuecomment-216917023
xhr.readyState = 0
xhr.statusText = "abort"
xhr.abort()

xhr = new XMLHttpRequest

xhr.open 'GET', url.withoutHashForIE10compatibility(), true
xhr.setRequestHeader 'Accept', 'text/html, application/xhtml+xml, application/xml'
xhr.setRequestHeader 'X-XHR-Referer', referer

options.headers ?= {}

for k,v of options.headers
xhr.setRequestHeader k, v

xhr.onload = ->
if xhr.status >= 500
document.location.href = url.absolute
Turbolinks.fullPageNavigate(url.absolute)
else
Turbolinks.loadPage(url, xhr, options)
xhr = null

xhr.onloadend = -> xhr = null
xhr.onerror = ->
document.location.href = url.absolute
xhr.onerror = ->
# Workaround for sinon xhr.abort()
if xhr.statusText == "abort"
xhr = null
return
Turbolinks.fullPageNavigate(url.absolute)

xhr.send()

Expand All @@ -117,17 +130,27 @@ class window.Turbolinks
@loadPage: (url, xhr, options = {}) ->
triggerEvent 'page:receive'
options.updatePushState ?= true

if doc = processResponse(xhr, options.partialReplace)
if upstreamDocument = processResponse(xhr, options.partialReplace)
reflectNewUrl url if options.updatePushState
nodes = changePage(extractTitleAndBody(doc)..., options)
reflectRedirectedUrl(xhr) if options.updatePushState
triggerEvent 'page:load', nodes
options.onLoadFunction?()
else
document.location.href = url.absolute

return
new TurboHead(document, upstreamDocument).update(
onHeadUpdateSuccess = ->
nodes = changePage(
upstreamDocument.querySelector('title')?.textContent,
removeNoscriptTags(upstreamDocument.querySelector('body')),
CSRFToken.get(upstreamDocument).token,
'runScripts',
options
)
reflectRedirectedUrl(xhr) if options.updatePushState
options.onLoadFunction?()
triggerEvent 'page:load', nodes
,
onHeadUpdateError = ->
Turbolinks.fullPageNavigate(url.absolute)
)
else
Turbolinks.fullPageNavigate(url.absolute)

changePage = (title, body, csrfToken, runScripts, options = {}) ->
document.title = title if title
Expand Down Expand Up @@ -210,7 +233,7 @@ class window.Turbolinks
newNode = newNode.cloneNode(true)
replaceNode(newNode, existingNode)

if newNode.nodeName == 'SCRIPT' && newNode.getAttribute("data-turbolinks-eval") != "false"
if newNode.nodeName == 'SCRIPT' && newNode.dataset.turbolinksEval != "false"
executeScriptTag(newNode)
else
refreshedNodes.push(newNode)
Expand Down Expand Up @@ -307,29 +330,9 @@ class window.Turbolinks
validContent = ->
xhr.getResponseHeader('Content-Type').match /^(?:text\/html|application\/xhtml\+xml|application\/xml)(?:;|$)/

extractTrackAssets = (doc) ->
for node in doc.querySelector('head').childNodes when node.getAttribute?('data-turbolinks-track')?
node.getAttribute('src') or node.getAttribute('href')

assetsChanged = (doc) ->
loadedAssets ||= extractTrackAssets document
fetchedAssets = extractTrackAssets doc
fetchedAssets.length isnt loadedAssets.length or intersection(fetchedAssets, loadedAssets).length isnt loadedAssets.length

intersection = (a, b) ->
[a, b] = [b, a] if a.length > b.length
value for value in a when value in b

if !clientOrServerError() && validContent()
doc = createDocument xhr.responseText
changed = assetsChanged(doc)

if doc && (!changed || partial)
return doc

extractTitleAndBody = (doc) ->
title = doc.querySelector 'title'
[ title?.textContent, removeNoscriptTags(doc.querySelector('body')), CSRFToken.get(doc).token, 'runScripts' ]
upstreamDocument = createDocument(xhr.responseText)
return upstreamDocument

installHistoryChangeHandler = (event) ->
if event.state?.turbolinks
Expand Down
Loading

0 comments on commit 6dae1aa

Please sign in to comment.