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.
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.
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
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
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
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...
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
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
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.
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()
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.
!!(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.
= 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
= 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.
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.
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.
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.
= 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.
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.
(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.
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.
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.
{ 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.
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