Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Hang Loose, Enhance Progressively

Hang Loose, Enhance Progressively

A case study in Flight component development as examined through the lens of implementing pushState() for twitter.com.

Todd Kloots

April 12, 2013
Tweet

More Decks by Todd Kloots

Other Decks in Programming

Transcript

  1. This is a case study in Flight component development—specifically, using

    Flight to add features to an existing application. And I plan to examine the problem through the lens of my experience with adding support for pushState() to twitter.com.
  2. A Flight Case Study Leveraging loose coupling to add features

    while minimizing complexity The Twitter Group The original subtitle for this talk was “Leveraging loose coupling to add features while minimizing complexity.” When I ran this by one of my coworkers he told me it made me sound like a consultant. His comment seemed valid. But I do feel that this subtitle was a good summary of main point of this talk. So, I took that idea and ran with it. So, here’s the 1990s consultancy-style PowerPoint rendering of the subtitle for this talk.
  3. This a screen shot from the game Katamari Damacy. In

    this game you play a prince on a mission to rebuild the stars. You do this by rolling a really adhesive ball around to collect increasingly larger objects. (For more: http://en.wikipedia.org/wiki/ Katamari_Damacy) This is a good metaphor for the evolution of a software application as new features are added over time. Most often when you are adding features to an application, with each feature your application grows in: 1. Size - Adding references to new components 2. Complexity - Lots of checking of switches 3. Becomes more difficult to tests - specifically unit tests are more difficult to write
  4. When I arrived I was given the task of implementing

    pushState() for twitter.com. At the time the Web Team was nearly done migrating all of twitter.com to Flight. Dan had started the pushState() implementation, but it was still early stages. Further, Dan's design vision for our pushState() implementation was to implement pushState() as a Progressive Enhancement. http://engineering.twitter.com/2012/05/improving-performance-on-twittercom.html
  5. For those of you not familiar with pushState(): pushState() is

    part of the HTML5 History API. It allows you to modify the browser history with a state object and URL that reflect the current state of the page/application without triggering a page reload. Enables improvement to an established techniques of state management that previously used hashes. https://developer.mozilla.org/en-US/docs/DOM/Manipulating_the_browser_history
  6. As I said earlier, the design vision for adding pushState()

    support was that it be done using Progressive Enhancement. For those of you not already familiar with Progressive Enhancement, come with me now back to the 90s...
  7. In the late 90s Graceful Degradation was a popular design

    pattern for web development. How it worked: 1. Sketch out how your site or application would work assuming the ideal feature set 2. Knowingly withhold features from older browsers (typically through browser detection) to ensure the user wasn’t completely blocked from using the site Some hallmarks of this style of development were: 1. Browser detection 2. Creation of simpler, or text-only experiences aimed at users of older browsers or users with disabilities http://webtips.dan.info/graceful.html
  8. Graceful Degradation was later replaced by the Progressive Enhancement design

    pattern. This pattern was very much an inversion of Graceful Degradation. How it works: 1. You begin by explicitly defining the core features of your site or application—those that should work regardless of the client’s abilities 2. All other features are layered on top of the core experience (usually via feature detection) in a way that is purely additive http://www.hesketh.com/thought-leadership/our-publications/inclusive-web-design-future
  9. Core Functionality 1. User should be able to navigate between

    sections of the site 2. Navigation between sections should be fast 3. URLs should be accessible regardless of the client’s abilities Example of considering pushState() through the lens of Progressive Enhancement. We’d start by outlining the core functionality. #3 was especially important for us. Twitter is a discussion in the public square. As such, we want to limit barriers to access in the interest making that content available to the widest possible audience.
  10. twitter.com twitter.com/i/connect So, the idea was to preserve full page

    navigation for browsers that don’t support pushState().
  11. twitter.com twitter.com/i/connect 1. XHR for /i/connect 2. pushState(connectData, “Interactions”, “/i/connect”);

    3. Replace content view But if the browser does support pushState(), the user would navigate using Pjax (Progressive Ajax): Here’s how it works: 1. JavaScript hijacks click on a link 2. XHR for the path specified by the link 3. onSuccess, DOM is updated with the new view 4. URL updated to reflect the new state without reloading the page via pushState()
  12. So, this was my mission. 1) Implement a new feature

    that was going to affect the behavior of the entire site 2) Implement that feature via Progressive Enhancement 3) Because of the scope of the feature, it would impact many existing components in the ecosystem 4) And, oh yeah new guy, don’t break anything. So, testing was important.
  13. twitter.com twitter.com/i/connect 1. XHR for /i/connect 2. pushState(connectData, “Interactions”, “/i/connect”);

    3. Replace content view A quick look back at the design goal for how pushState() should work.
  14. Client Server Pjax Endpoints Navigation UI Component Navigation Data Component

    The core of our implementation was the introduction of two new components: a UI and data component.
  15. var NavigationUI = require('app/ui/navigation'), NavigationData = require('app/data/navigation'); var pushStateSupported =

    !!(window.history && history.pushState); if (options.pushState && pushStateSupported) { NavigationUI.attachTo(document); } NavigationData.attachTo(document, options, { pushStateSupported: pushStateSupported }); Both components are attached the document. And because we’re implementing via Progressive Enhancement, the UI component is conditionally instantiated based on a combination of a directive from the server and feature detection on the client.
  16. <a class="js-nav" href="/i/connect">Connect</a> A document-level click event listener intercepts clicks

    on links with a class of “js-nav” and triggers the pushState() navigation for that URL.
  17. this.defaultAttrs({ pushStateSelector: 'a.js-nav' }); this.navigate = function(e) { var $target,

    $link; if (e.shiftKey || e.ctrlKey || e.metaKey || (e.which != undefined && e.which > 1)) { return; } $target = $(e.target); $link = $target.closest(this.attr.pushStateSelector); if ($link.length && !e.isDefaultPrevented()) { this.trigger($link, 'uiNavigate', { href: $link.attr('href') }); e.preventDefault(); } }; this.on('click', this.navigate); The UI component has two primary responsibilities: 1) Intercept click events and triggering a custom “uiNavigate” event 2) Update the content view in response to successful XHRs
  18. this.navigateUsingPushState = function(e, data) { var path = fullPath(); requestedURL

    = data.href; if (data.href == path && this.pageCache[path]) { return; } this.getPageData(data.href); // XHR }; this.navigateUsingRedirect = function (e, data) { var url = data.href; if (url != currentURL) { location.href = url; } }; this.after('initialize', function() { if (this.attr.pushState && this.attr.pushStateSupported) { this.on('uiNavigate', this.navigateUsingPushState); } else { this.on('uiNavigate', this.navigateUsingRedirect); } }); The data component listens for “uiNavigate” events and... 1) If pushState is supported make the XHR for the specified content view 2) For browsers that don’t support pushState, the data component just redirects to the specified URL
  19. Navigation UI Component Navigation Data Component uiNavigate dataPageRefresh If pushState()

    is supported, the data component triggers a “dataPageRefresh” to notify the UI component that the content view should be updated.
  20. this.updatePage = function(e, data, isPopState) { this.trigger('uiBeforePageChanged', data); var html

    = data.page; this.$node.find(data.init_data.viewContainer).html(html); using(data.module, function(page) { page(data.init_data); this.trigger('uiPageChanged', data); }.bind(this)); }; this.on('dataPageRefresh', this.updatePage); The UI component replaces the HTML for the content view and subsequently fetches and initializes the required JavaScript components for that URL. Lastly, the UI component triggers the “uiPageChanged” event so that other components are aware that the navigation to a new page is complete.
  21. Navigation UI Component Navigation Data Component uiNavigate dataPageRefresh What’s especially

    interesting is that while the “uiNavigate” event is triggered by the pushState() UI component, it is also triggered by/can be triggered by other UI components. And since custom events are fired against the DOM the same outcome can be expected.
  22. Navigation Data Component uiNavigate Keyboard Shortcuts UI Component Search UI

    Component uiNavigate dataPageRefresh Navigation UI Component uiNavigate For example, both the keyboard shortcuts and search components trigger the “uiNavigate” event. But these components aren’t at all concerned with support for pushState(). The “uiNavigate” event is simply an abstraction of the user’s intent to navigate. How navigation is implemented is not their concern. Further, the navigation data component isn’t at all concerned with the source of the “uiNavigate” event. It simply facilitates navigation to the best of the client’s abilities.
  23. Navigation Data Component uiNavigate Keyboard Shortcuts UI Component Search UI

    Component uiNavigate Further, this architecture proves to be very robust and with the grain of Progressive Enhancement. Remember that: 1) The Navigation UI component is only added if the browser supports pushState(). 2) The Navigation component is flexible and can respond to “uiNavigate” events either via XHR or redirects With this architecture, if the browser doesn’t support pushState() the navigation data component navigates via redirects and individual features, like Search and Keyboard Shortcuts, continue to work.
  24. this.navigateUsingPushState = function(e, data) { var path = fullPath(); requestedURL

    = data.href; if (data.href == path && this.pageCache[path]) { return; } this.getPageData(data.href); // XHR }; this.navigateUsingRedirect = function (e, data) { var url = data.href; if (url != currentURL) { location.href = url; } }; this.after('initialize', function() { if (this.attr.pushState && this.attr.pushStateSupported) { this.on('uiNavigate', this.navigateUsingPushState); } else { this.on('uiNavigate', this.navigateUsingRedirect); } }); Here’s a look back at how that fork is implemented in the data component.
  25. Navigation UI Component uiPageChanged Global Navigation Google Analytics Who To

    Follow As a result of the decoupled, event-oriented architecture of Flight—not only can any component trigger an event, any component in the application can respond to any event as well. This made it very easy to update existing components in the application so that they responded correctly if the user was navigating via pushState(). Here you see that the navigation UI component triggers a “uiPageChanged” event to notify all interested components that the user has successfully navigated to a new section.
  26. this.updateActive = function(e, data) { var $nav = this.select('nav'); if

    (data) { $nav.removeClass(this.attr.activeClass); $nav.filter('[data-global-action=' + data.section + ']').addClass(this.attr.activeClass); this.removeGlowFromActive(); } }; this.after('initialize', function() { this.on(document, 'uiPageChanged', this.updateActive); }); Here an example of how the global nav is updated in response to pushState() navigation. In the case of full page reloads, the server manages the application of classes to update the state of the top nav. With pushState() the top nav is persistent, meaning the client now also needs to manage these state updates. What’s nice is that you can see here how the changes to support pushState() are purely additive, and are limited in scope. If pushState() isn’t supported this event listener is simply never called.
  27. this.reallyCheck = function () { return Math.floor(Math.random() * 2); };

    this.checkLastReadDM = function (event, data) { if (this.reallyCheck()) { this.trigger('dataUserHasUnreadDMs'); } }; this.after('initialize', function () { this.on('uiSwiftLoaded uiPageChanged', this.checkLastReadDM); }); In some instances, modifications to existing components was as simple as adding an event to an existing event listener. For example, the “uiSwiftLoaded” event is our application ready event. In the case of this component we’re just responding to “uiPageChanged” by reusing an existing method of the component.
  28. Of course when you examine our implementation of checking the

    DM read state you can easily see why this feature is so broken and why users are complaining.
  29. describe('when a dataPageRefresh fires in response to a popstate event',

    function() { afterEach(function() { $(window).scrollTop(0); }); it('does not scroll the window to the top', function() { $(window).scrollTop(200); this.component.$node.trigger('dataPageRefresh', { isPopState: true }); expect($(window).scrollTop()).toBe(200); }); }); The decoupled nature of Flight components made testing easier as well. You don’t have to add component references or mocks to tests, you just need to trigger events and assert the expected results. This keeps unit tests focused exclusively on the component you’re testing. For example, the test for the navigation UI component didn’t need to include the data component or a mock of the data component, it just triggers the data components events and asserts the expected results.
  30. describe('when uiPageChanged triggered with section', function() { beforeEach(function() { $(document).trigger('uiPageChanged',

    { section: 'discover' }); }); it('highlights nav item', function() { expect(this.component.$node.find('#discover')).toHaveClass('active'); }); it('removes highlight from old item', function() { expect(this.component.$node.find('#home')).not.toHaveClass('active'); }); }); The same proved true of updating tests for existing components. All that was required was to trigger the new events introduced by the new pushState() components.
  31. Summary • With the grain of Progressive Enhancement • Features

    are sandboxed and limited in scope • Unit tests remain easy to write and isolated to the component
  32. For more on our implementation of pushState, you can checkout

    this blog post. http://engineering.twitter.com/2012/12/implementing-pushstate-for-twittercom_7.html
  33. Further, for more information on how to implement pushState() via

    Progressive Enhancement you can checkout my slides from my “pushState to the Future” from HTML5DevConf available on SpeakerDeck: https://speakerdeck.com/todd/pushstate-to-the- future