Skip to content

Commit

Permalink
Merge pull request #137 from Shopify/revert-to-the-simpler-solution-s…
Browse files Browse the repository at this point in the history
…adface

Revert to the simpler solution, :(
  • Loading branch information
Mathew Allen authored Sep 2, 2016
2 parents f550c81 + 1254356 commit ae22c32
Show file tree
Hide file tree
Showing 5 changed files with 331 additions and 527 deletions.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ In botany, one can take parts of a tree and splice it onto another tree. The DO

## One render path
Turbograft gives you the ability to maintain a single, canonical render path for views. Your ERB views are the single definition of what will be rendered, without the worry of conditionally fetching snippets of HTML from elsewhere. This approach leads to clear, simplified code.

## Client-side performance
Partial page refreshes mean that CSS and JavaScript are only reloaded when you need them to be. Turbograft improves on the native, single-page application feel for the user while keeping these benefits inherited from Turbolinks.

Head asset tracking means that you can split your large CSS and Javascript bundles into smaller area bundles, decreasing your page weight and further increasing the responsiveness of your app.

## Simplicity
Turbograft was built with simplicity in mind. It intends to offer the smallest amount of overhead required on top of a traditional Rails stack to solve the problem of making a Rails app feel native to the browser.

Expand Down Expand Up @@ -173,6 +177,16 @@ and

The `data-tg-remote-noserialize` is useful in scenarios where a whole section of the page should be editable, i.e. not `disabled`, but should only conditionally be submitted to the server.

## Head Asset Tracking
### NOTE: This functionality is experimental, has changed significantly since 0.3.0, and may change again in the future before 1.0
The Turbohead module allows you to track css and javascript assets in the head of the document and change them intelligently. This can be useful in large applications which want to lighten their asset weight by splitting their script and style bundles by area.

When a `<script>` or `<link>` tag with a unique name in it's `data-turbolinks-track` is encountered in a response document Turbograft will insert it into the active DOM and, if it's a script, force it to execute.

If an asset with a different `src`/`href` but the same `data-turbolinks-track` value is found upstream, turbograft will force a full page refresh. This prevents potential multiple executions of a script bundle when a new version of your app is shipped.

As of version `0.4.0`, this functionality has been made backwards compatible. If `data-turbolinks-track="true"` head assets are present, turbograft will cause a full page refresh when the set is changed in any way.

## Example App

There is an example app that you can boot to play with TurboGraft. Open the console and network inspector and see it in action! This same app is also used in the TurboGraft browser testing suite.
Expand Down
278 changes: 88 additions & 190 deletions lib/assets/javascripts/turbograft/turbohead.coffee
Original file line number Diff line number Diff line change
@@ -1,42 +1,88 @@
TRACKED_ASSET_SELECTOR = '[data-turbolinks-track]'
TRACKED_ATTRIBUTE_NAME = 'turbolinksTrack'
ANONYMOUS_TRACK_VALUE = 'true'

class window.TurboHead
constructor: (@activeDocument, @upstreamDocument) ->
@activeAssets = extractTrackedAssets(@activeDocument)
@upstreamAssets = extractTrackedAssets(@upstreamDocument)
@newScripts = @upstreamAssets
.filter(attributeMatches('nodeName', 'SCRIPT'))
.filter(noAttributeMatchesIn('src', @activeAssets))

update: (successCallback, failureCallback) ->
activeAssets = extractTrackedAssets(@activeDocument)
upstreamAssets = extractTrackedAssets(@upstreamDocument)
{activeScripts, newScripts} = processScripts(activeAssets, upstreamAssets)
@newLinks = @upstreamAssets
.filter(attributeMatches('nodeName', 'LINK'))
.filter(noAttributeMatchesIn('href', @activeAssets))

if hasScriptConflict(activeScripts, newScripts)
return failureCallback()
hasChangedAnonymousAssets: () ->
anonymousUpstreamAssets = @upstreamAssets
.filter(datasetMatches(TRACKED_ATTRIBUTE_NAME, ANONYMOUS_TRACK_VALUE))
anonymousActiveAssets = @activeAssets
.filter(datasetMatches(TRACKED_ATTRIBUTE_NAME, ANONYMOUS_TRACK_VALUE))

updateLinkTags(activeAssets, upstreamAssets)
updateScriptTags(@activeDocument, newScripts, successCallback)
if anonymousActiveAssets.length != anonymousUpstreamAssets.length
return true

updateLinkTags = (activeAssets, upstreamAssets) ->
activeLinks = activeAssets.filter(filterForNodeType('LINK'))
upstreamLinks = upstreamAssets.filter(filterForNodeType('LINK'))
remainingActiveLinks = removeStaleLinks(activeLinks, upstreamLinks)
reorderedActiveLinks = reorderActiveLinks(remainingActiveLinks, upstreamLinks)
insertNewLinks(reorderedActiveLinks, upstreamLinks)
noMatchingSrc = noAttributeMatchesIn('src', anonymousUpstreamAssets)
noMatchingHref = noAttributeMatchesIn('href', anonymousUpstreamAssets)

updateScriptTags = (activeDocument, newScripts, callback) ->
asyncSeries(
newScripts.map((scriptNode) -> insertScriptTask(activeDocument, scriptNode)),
callback
)
anonymousActiveAssets.some((node) ->
noMatchingSrc(node) || noMatchingHref(node)
)

hasNamedAssetConflicts: () ->
@newScripts
.concat(@newLinks)
.filter(noDatasetMatches(TRACKED_ATTRIBUTE_NAME, ANONYMOUS_TRACK_VALUE))
.some(datasetMatchesIn(TRACKED_ATTRIBUTE_NAME, @activeAssets))

hasAssetConflicts: () ->
@hasNamedAssetConflicts() || @hasChangedAnonymousAssets()

insertNewAssets: (callback) ->
updateLinkTags(@activeDocument, @newLinks)
updateScriptTags(@activeDocument, @newScripts, callback)

extractTrackedAssets = (doc) ->
[].slice.call(doc.querySelectorAll('[data-turbolinks-track]'))
[].slice.call(doc.querySelectorAll(TRACKED_ASSET_SELECTOR))

attributeMatches = (attribute, value) ->
(node) -> node[attribute] == value

attributeMatchesIn = (attribute, collection) ->
(node) ->
collection.some((nodeFromCollection) -> node[attribute] == nodeFromCollection[attribute])

filterForNodeType = (nodeType) ->
(node) -> node.nodeName == nodeType
noAttributeMatchesIn = (attribute, collection) ->
(node) ->
!collection.some((nodeFromCollection) -> node[attribute] == nodeFromCollection[attribute])

hasScriptConflict = (activeScripts, newScripts) ->
hasExistingScriptAssetName = (upstreamNode) ->
activeScripts.some (activeNode) ->
upstreamNode.dataset.turbolinksTrackScriptAs == activeNode.dataset.turbolinksTrackScriptAs
datasetMatches = (attribute, value) ->
(node) -> node.dataset[attribute] == value

newScripts.some(hasExistingScriptAssetName)
noDatasetMatches = (attribute, value) ->
(node) -> node.dataset[attribute] != value

datasetMatchesIn = (attribute, collection) ->
(node) ->
value = node.dataset[attribute]
collection.some(datasetMatches(attribute, value))

noDatasetMatchesIn = (attribute, collection) ->
(node) ->
value = node.dataset[attribute]
!collection.some(datasetMatches(attribute, value))

updateLinkTags = (activeDocument, newLinks) ->
# style tag load events don't work in all browsers
# as such we just hope they load ¯\_(ツ)_/¯
newLinks.forEach((linkNode) -> insertLinkTask(activeDocument, linkNode)())

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

asyncSeries = (tasks, callback) ->
return callback() if tasks.length == 0
Expand All @@ -48,167 +94,19 @@ insertScriptTask = (activeDocument, scriptNode) ->
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)
insertAssetTask(activeDocument, newNode, 'script')

insertLinkTask = (activeDocument, node) ->
insertAssetTask(activeDocument, node.cloneNode(), 'link')

insertAssetTask = (activeDocument, newNode, name) ->
(done) ->
onAssetEvent = (event) ->
triggerEvent("page:#{name}-error", event) if event.type == 'error'
newNode.removeEventListener('load', onAssetEvent)
newNode.removeEventListener('error', onAssetEvent)
done() if typeof done == 'function'
newNode.addEventListener('load', onAssetEvent)
newNode.addEventListener('error', onAssetEvent)
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)
triggerEvent("page:after-#{name}-inserted", newNode)
11 changes: 4 additions & 7 deletions lib/assets/javascripts/turbograft/turbolinks.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,10 @@ class window.Turbolinks
if options.partialReplace
updateBody(upstreamDocument, xhr, options)
else
new TurboHead(document, upstreamDocument).update(
onHeadUpdateSuccess = ->
updateBody(upstreamDocument, xhr, options)
,
onHeadUpdateError = ->
Turbolinks.fullPageNavigate(url.absolute)
)
turbohead = new TurboHead(document, upstreamDocument)
if turbohead.hasAssetConflicts()
return Turbolinks.fullPageNavigate(url.absolute)
turbohead.insertNewAssets(-> updateBody(upstreamDocument, xhr, options))
else
triggerEvent 'page:error', xhr
Turbolinks.fullPageNavigate(url.absolute) if url?
Expand Down
Loading

0 comments on commit ae22c32

Please sign in to comment.