Repository: phoenixframework/phoenix (elixir)

Issue:
Race condition when presence is left shortly after presence state retrieval
<!--
### Precheck

* For help and support, use Stack Overflow or the Phoenix Talk mailing list
* For proposing a new feature, start a discussion on the Phoenix Core mailing list
* For bugs, do a quick search and make sure the bug has not yet been reported
* Ensure that this issue is related to the Phoenix library and not one of the dependencies listed in mix.exs (Ecto, Plug, etc.)
* All checked? Be nice and have fun!
-->

### Environment

* Elixir version (elixir -v): Elixir 1.4.0, erts-8.2)
* Phoenix version (mix deps): phoenix 1.3.0-rc.1
* NodeJS version (node -v): v7.7.2
* NPM version (npm -v): 4.4.1
* Operating system: OS X 10.12.3

### Expected behavior

Using the default setup for channels and presence, any leaves happening concurrently with a new channel's connection should result in a client side presence state without the left presence (either through it not being included in `presence_state` or it being removed shortly thereafter through a `presence_diff`.

### Actual behavior

Some presence changes, that are applied after the newly joined channel has retrieved its current presence state, will be sent to the client before the presence_state event is sent. When this happens, any such presence leaves will be ignored by the default js syncDiff. Once the presence_state event is received, the left presences will be kept even though they are not present anymore.

### Easiest way to reproduce locally

1. Introduce a delay in a `Presence.fetch/2` callback, that scales linearly with the number of presences. Example: `def fetch(_topic, presences) do Process.sleep(1000 * Enum.count presences); presences end`
2. Open a few tabs to make sure the presence state contains 5 or more items.
3. Reload a tab, triggering a new channel connection.
4. Within a few seconds, close one or more of the other tabs.
5. Observe that the newly opened tab recevies the leave events before it's presence_state, and that it keeps those left presences until the next reload.

A runnable example of the above is available at https://github.com/alvinlindstam/phoenix_social_presence/tree/race-condition

I understand that the one second delay is an extreme example, but a fetch function that takes longer for many presences than for a small diff is hardly unlikely. I'm not sure if this would be possible to reproduce without a fetch function, but I can't se any reason why a presence change handled just after a presence state fetch by a new channel couldn't be sent to and handled by the transport process before the channel process itself sends the state to the transport.

Pull request:
Resolve presence race and introduce new Presence API
Fixes #2255, for real this time :)

@josevalim and @alvinlindstam I would appreciate thoughts on this new API. It keeps the "lower level" existing API and simplifies the knowledge and code required to handle presence events. At the same time it also resolves the race condition in #2255 by tracking the joinRef or "session id" of the channel connection. The new API looks like this (taken from Alvin's example app):


```javascript
const renderPresences = (presence) => {
  const listed_presences = presence.list((user, {metas: metas}) => {
    return {user: user, ...}
  })
  ...
}

let channel = socket.channel("user:all", {})
let presence = new Presence(channel)

presence.onSync(() => {
  renderPresences(presence)
})

channel.join()...
```

Note: I still need to update the presence.js docs with the new API, but I'm awaiting feedback. Thanks!


Patch:
diff --git a/assets/js/phoenix.js b/assets/js/phoenix.js
index df2f03b42..0eab86314 100644
--- a/assets/js/phoenix.js
+++ b/assets/js/phoenix.js
@@ -109,21 +109,29 @@
  * The `Presence` object provides features for syncing presence information
  * from the server with the client and handling presences joining and leaving.
  *
- * ### Syncing initial state from the server
+ * ### Syncing state from the server
  *
- * `Presence.syncState` is used to sync the list of presences on the server
- * with the client's state. An optional `onJoin` and `onLeave` callback can
- * be provided to react to changes in the client's local presences across
- * disconnects and reconnects with the server.
+ * To sync presence state from the server, first instantiate an object and
+ * pass your channel in to track lifecycle events:
  *
- * `Presence.syncDiff` is used to sync a diff of presence join and leave
- * events from the server, as they happen. Like `syncState`, `syncDiff`
- * accepts optional `onJoin` and `onLeave` callbacks to react to a user
- * joining or leaving from a device.
+ * ```javascript
+ * let channel = new socket.channel("some:topic")
+ * let presence = new Presence(channel)
+ * ```
+ *
+ * Next, use the `presence.onSync` callback to react to state changes
+ * from the server. For example, to render the list of users every time
+ * the list changes, you could write:
+ *
+ * ```javascript
+ * presence.onSync(() => {
+ *   myRenderUsersFunction(presence.list())
+ * })
+ * ```
  *
  * ### Listing Presences
  *
- * `Presence.list` is used to return a list of presence information
+ * `presence.list` is used to return a list of presence information
  * based on the local state of metadata. By default, all presence
  * metadata is returned, but a `listBy` function can be supplied to
  * allow the client to select which metadata to use for a given presence.
@@ -136,45 +144,42 @@
  * they came online from:
  *
  * ```javascript
- * let state = {}
- * state = Presence.syncState(state, stateFromServer)
  * let listBy = (id, {metas: [first, ...rest]}) => {
  *   first.count = rest.length + 1 // count of this user's presences
  *   first.id = id
  *   return first
  * }
- * let onlineUsers = Presence.list(state, listBy)
+ * let onlineUsers = presence.list(listBy)
  * ```
  *
+ * ### Handling individual presence join and leave events
+ *
+ * The `presence.onJoin` and `presence.onLeave` callbacks can be used to
+ * react to individual presences joining and leaving the app. For example:
  *
- * ### Example Usage
  * ```javascript
+ * let presence = new Presence(channel)
+ *
  * // detect if user has joined for the 1st time or from another tab/device
- * let onJoin = (id, current, newPres) => {
+ * presence.onJoin((id, current, newPres) => {
  *   if(!current){
  *     console.log("user has entered for the first time", newPres)
  *   } else {
  *     console.log("user additional presence", newPres)
  *   }
- * }
+ * })
+ *
  * // detect if user has left from all tabs/devices, or is still present
- * let onLeave = (id, current, leftPres) => {
+ * presence.onLeave((id, current, leftPres) => {
  *   if(current.metas.length === 0){
  *     console.log("user has left from all devices", leftPres)
  *   } else {
  *     console.log("user left from a device", leftPres)
  *   }
- * }
- * let presences = {} // client's initial empty presence state
- * // receive initial presence data from server, sent after join
- * myChannel.on("presence_state", state => {
- *   presences = Presence.syncState(presences, state, onJoin, onLeave)
- *   displayUsers(Presence.list(presences))
  * })
- * // receive "presence_diff" from server, containing join/leave events
- * myChannel.on("presence_diff", diff => {
- *   presences = Presence.syncDiff(presences, diff, onJoin, onLeave)
- *   this.setState({users: Presence.list(room.presences, listBy)})
+ * // receive presence data from server
+ * presence.onSync(() => {
+ *   displayUsers(presence.list())
  * })
  * ```
  * @module phoenix
@@ -1134,25 +1139,85 @@ export class Ajax {
 
 Ajax.states = {complete: 4}
 
+/**
+ * Initializes the Presence
+ * @param {Channel} channel - The Channel
+ * @param {Object} opts - The options,
+ *        for example `{events: {state: "state", diff: "diff"}}`
+ */
+export class Presence {
+
+  constructor(channel, opts = {}){
+    let events = opts.events || {state: "presence_state", diff: "presence_diff"}
+    this.state = {}
+    this.pendingDiffs = []
+    this.channel = channel
+    this.joinRef = null
+    this.caller = {
+      onJoin: function(){},
+      onLeave: function(){},
+      onSync: function(){}
+    }
+
+    this.channel.on(events.state, newState => {
+      let {onJoin, onLeave, onSync} = this.caller
 
+      this.joinRef = this.channel.joinRef()
+      this.state = Presence.syncState(this.state, newState, onJoin, onLeave)
+
+      this.pendingDiffs.forEach(diff => {
+        this.state = Presence.syncDiff(this.state, diff, onJoin, onLeave)
+      })
+      this.pendingDiffs = []
+      onSync()
+    })
 
-export var Presence = {
+    this.channel.on(events.diff, diff => {
+      let {onJoin, onLeave, onSync} = this.caller
 
-  syncState(currentState, newState, onJoin, onLeave){
+      if(this.inPendingSyncState()){
+        this.pendingDiffs.push(diff)
+      } else {
+        this.state = Presence.syncDiff(this.state, diff, onJoin, onLeave)
+        onSync()
+      }
+    })
+  }
+
+  onJoin(callback){ this.caller.onJoin = callback }
+
+  onLeave(callback){ this.caller.onLeave = callback }
+
+  onSync(callback){ this.caller.onSync = callback }
+
+  list(by){ return Presence.list(this.state, by) }
+
+  inPendingSyncState(){
+    return !this.joinRef || (this.joinRef !== this.channel.joinRef())
+  }
+
+  // lower-level public static API
+
+  /**
+   * Used to sync the list of presences on the server
+   * with the client's state. An optional `onJoin` and `onLeave` callback can
+   * be provided to react to changes in the client's local presences across
+   * disconnects and reconnects with the server.
+   *
+   * @returns {Presence}
+   */
+  static syncState(currentState, newState, onJoin, onLeave){
     let state = this.clone(currentState)
-    state.presences = state.presences || {}
-    state.synced = true
-    let pendingDiffs = state.pendingDiffs || []
     let joins = {}
     let leaves = {}
 
-    this.map(state.presences, (key, presence) => {
+    this.map(state, (key, presence) => {
       if(!newState[key]){
         leaves[key] = presence
       }
     })
     this.map(newState, (key, newPresence) => {
-      let currentPresence = state.presences[key]
+      let currentPresence = state[key]
       if(currentPresence){
         let newRefs = newPresence.metas.map(m => m.phx_ref)
         let curRefs = currentPresence.metas.map(m => m.phx_ref)
@@ -1170,44 +1235,35 @@ export var Presence = {
         joins[key] = newPresence
       }
     })
+    return this.syncDiff(state, {joins: joins, leaves: leaves}, onJoin, onLeave)
+  }
 
-    state = this.syncDiff(state, {joins: joins, leaves: leaves}, onJoin, onLeave)
-
-    pendingDiffs.forEach(diff => {
-      state = this.syncDiff(state, {joins: diff.joins, leaves: diff.leaves},
-        diff.onJoin,
-        diff.onLeave)
-    })
-    state.pendingDiffs = []
-
-    return state
-  },
-
-  syncDiff(currentState, {joins, leaves}, onJoin, onLeave){
+  /**
+   *
+   * Used to sync a diff of presence join and leave
+   * events from the server, as they happen. Like `syncState`, `syncDiff`
+   * accepts optional `onJoin` and `onLeave` callbacks to react to a user
+   * joining or leaving from a device.
+   *
+   * @returns {Presence}
+   */
+  static syncDiff(currentState, {joins, leaves}, onJoin, onLeave){
     let state = this.clone(currentState)
-    state.presences = state.presences || {}
-    state.pendingDiffs = state.pendingDiffs || []
-
-    if(!state.synced){
-      state.pendingDiffs.push({joins, leaves, onJoin, onLeave})
-      return state
-    }
-
     if(!onJoin){ onJoin = function(){} }
     if(!onLeave){ onLeave = function(){} }
 
     this.map(joins, (key, newPresence) => {
-      let currentPresence = state.presences[key]
-      state.presences[key] = newPresence
+      let currentPresence = state[key]
+      state[key] = newPresence
       if(currentPresence){
-        let joinedRefs = state.presences[key].metas.map(m => m.phx_ref)
+        let joinedRefs = state[key].metas.map(m => m.phx_ref)
         let curMetas = currentPresence.metas.filter(m => joinedRefs.indexOf(m.phx_ref) < 0)
-        state.presences[key].metas.unshift(...curMetas);
+        state[key].metas.unshift(...curMetas);
       }
       onJoin(key, currentPresence, newPresence)
     })
     this.map(leaves, (key, leftPresence) => {
-      let currentPresence = state.presences[key]
+      let currentPresence = state[key]
       if(!currentPresence){ return }
       let refsToRemove = leftPresence.metas.map(m => m.phx_ref)
       currentPresence.metas = currentPresence.metas.filter(p => {
@@ -1215,28 +1271,35 @@ export var Presence = {
       })
       onLeave(key, currentPresence, leftPresence)
       if(currentPresence.metas.length === 0){
-        delete state.presences[key]
+        delete state[key]
       }
     })
     return state
-  },
+  }
 
-  list(state, chooser){
-    let presences = state.presences || {}
+  /**
+   * Returns the array of presences, with selected metadata.
+   *
+   * @param {Object} presences
+   * @param {Function} chooser
+   *
+   * @returns {Presence}
+   */
+  static list(presences, chooser){
     if(!chooser){ chooser = function(key, pres){ return pres } }
 
     return this.map(presences, (key, presence) => {
       return chooser(key, presence)
     })
-  },
+  }
 
   // private
 
-  map(obj, func){
+  static map(obj, func){
     return Object.getOwnPropertyNames(obj).map(key => func(key, obj[key]))
-  },
+  }
 
-  clone(obj){ return JSON.parse(JSON.stringify(obj)) }
+  static clone(obj){ return JSON.parse(JSON.stringify(obj)) }
 }
 
 
@@ -1281,4 +1344,4 @@ class Timer {
       this.callback()
     }, this.timerCalc(this.tries + 1))
   }
-}
+}
\ No newline at end of file
diff --git a/guides/docs/controllers.md b/guides/docs/controllers.md
index d4cdd8883..af50b0649 100644
--- a/guides/docs/controllers.md
+++ b/guides/docs/controllers.md
@@ -20,7 +20,7 @@ The first line below the module definition invokes the `__using__/1` macro of th
 
 The `PageController` gives us the `index` action to display the Phoenix welcome page associated with the default route Phoenix defines in the router.
 
-### Actions
+## Actions
 Controller actions are just functions. We can name them anything we like as long as they follow Elixir's naming rules. The only requirement we must fulfill is that the action name matches a route defined in the router.
 
 For example, in `lib/hello_web/router.ex` we could change the action name in the default route that Phoenix gives us in a new app from index:
@@ -83,7 +83,7 @@ Of course, there are many other data access options. [Ets](http://www.erlang.org
 
 The data world is your oyster, but we won't be covering these options in these guides.
 
-### Flash Messages
+## Flash Messages
 
 There are times when we need to communicate with users during the course of an action. Maybe there was an error updating a schema. Maybe we just want to welcome them back to the application. For this, we have flash messages.
 
@@ -118,7 +118,7 @@ When we reload the [Welcome Page](http://localhost:4000/), our messages should a
 
 Besides `put_flash/3` and `get_flash/2`, the `Phoenix.Controller` module has another useful function worth knowing about. `clear_flash/1` takes only `conn` and removes any flash messages which might be stored in the session.
 
-### Rendering
+## Rendering
 
 Controllers have several ways of rendering content. The simplest is to render some plain text using the `text/2` function which Phoenix provides.
 
@@ -457,7 +457,7 @@ def index(conn, _params) do
 end
 ```
 
-### Redirection
+## Redirection
 
 Often, we need to redirect to a new url in the middle of a request. A successful `create` action, for instance, will usually redirect to the `show` action for the schema we just created. Alternately, it could redirect to the `index` action to show all the things of that same type. There are plenty of other cases where redirection is useful as well.
 
@@ -539,7 +539,7 @@ def index(conn, _params) do
 end
 ```
 
-### Action Fallback
+## Action Fallback
 
 Action Fallback allows us to centralize error handling code in plugs which are called when a controller action fails to return a `Plug.Conn.t`. These plugs receive both the conn which was originally passed to the controller action along with the return value of the action.
 
@@ -614,7 +614,7 @@ defmodule HelloWeb.MyController do
 end
 ```
 
-### Halting the Plug Pipeline
+## Halting the Plug Pipeline
 
 As we mentioned - Controllers are plugs.... specifically plugs which are called toward the end of the plug pipeline.  At any step of the pipeline we might have cause to stop processing - typically because we've redirected or rendered a response. `Plug.Conn.t` has a `:halted` key - setting it to true will cause downstream plugs to be skipped. We can do that easily using `Plug.Conn.halt/1`.
 
diff --git a/guides/docs/templates.md b/guides/docs/templates.md
index ccfcf2086..48cee4be6 100644
--- a/guides/docs/templates.md
+++ b/guides/docs/templates.md
@@ -59,7 +59,7 @@ To do that, we need to import the `action_name/1` and `controller_module/1` func
                         namespace: HelloWeb
 
       # Import convenience functions from controllers
-      import Phoenix.Controller, only: [get_flash: 2, view_module: 1,
+      import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1,
                                         action_name: 1, controller_module: 1]
 
       ...
diff --git a/installer/lib/phx_new/ecto.ex b/installer/lib/phx_new/ecto.ex
index 16728ecd2..4c9c82496 100644
--- a/installer/lib/phx_new/ecto.ex
+++ b/installer/lib/phx_new/ecto.ex
@@ -16,7 +16,7 @@ defmodule Phx.New.Ecto do
     {:eex,  "#{@pre}/test/test_helper.exs",        :app, "test/test_helper.exs"},
     {:eex,  "#{@pre}/README.md",                   :app, "README.md"},
     {:eex,  "#{@pre}/mix.exs",                     :app, "mix.exs"},
-    {:text, "phx_assets/bare/gitignore",           :app, ".gitignore"},
+    {:eex,  "#{@pre}/gitignore",                   :app, ".gitignore"},
   ]
 
   template :ecto, [
diff --git a/installer/lib/phx_new/single.ex b/installer/lib/phx_new/single.ex
index 5ceaaaaaf..7b5c6fa4a 100644
--- a/installer/lib/phx_new/single.ex
+++ b/installer/lib/phx_new/single.ex
@@ -20,6 +20,7 @@ defmodule Phx.New.Single do
     {:eex,  "phx_single/lib/app_name_web.ex",           :project, "lib/:lib_web_name.ex"},
     {:eex,  "phx_single/mix.exs",                       :project, "mix.exs"},
     {:eex,  "phx_single/README.md",                     :project, "README.md"},
+    {:eex,  "phx_single/gitignore",                     :project, ".gitignore"},
     {:eex,  "phx_test/support/channel_case.ex",         :project, "test/support/channel_case.ex"},
     {:eex,  "phx_test/support/conn_case.ex",            :project, "test/support/conn_case.ex"},
     {:eex,  "phx_single/test/test_helper.exs",          :project, "test/test_helper.exs"},
@@ -42,7 +43,6 @@ defmodule Phx.New.Single do
   ]
 
   template :brunch, [
-    {:text, "phx_assets/brunch/gitignore",        :project, ".gitignore"},
     {:eex,  "phx_assets/brunch/brunch-config.js", :project, "assets/brunch-config.js"},
     {:text, "phx_assets/app.css",                 :project, "assets/css/app.css"},
     {:text, "phx_assets/phoenix.css",             :project, "assets/css/phoenix.css"},
@@ -64,12 +64,9 @@ defmodule Phx.New.Single do
     {:eex, "phx_test/views/page_view_test.exs",              :project, "test/:lib_web_name/views/page_view_test.exs"},
   ]
 
-  template :bare, [
-    {:text, "phx_assets/bare/gitignore", :project, ".gitignore"},
-  ]
+  template :bare, []
 
   template :static, [
-    {:text,   "phx_assets/bare/gitignore", :project, ".gitignore"},
     {:text,   "phx_assets/app.css",        :project, "priv/static/css/app.css"},
     {:append, "phx_assets/phoenix.css",    :project, "priv/static/css/app.css"},
     {:text,   "phx_assets/bare/app.js",    :project, "priv/static/js/app.js"},
diff --git a/installer/lib/phx_new/web.ex b/installer/lib/phx_new/web.ex
index 9ae97b66e..67228b799 100644
--- a/installer/lib/phx_new/web.ex
+++ b/installer/lib/phx_new/web.ex
@@ -21,6 +21,7 @@ defmodule Phx.New.Web do
     {:eex,  "phx_web/views/error_view.ex",            :web, "lib/:web_app/views/error_view.ex"},
     {:eex,  "#{@pre}/mix.exs",                        :web, "mix.exs"},
     {:eex,  "#{@pre}/README.md",                      :web, "README.md"},
+    {:eex,  "#{@pre}/gitignore",                      :web, ".gitignore"},
     {:keep, "phx_test/channels",                      :web, "test/:web_app/channels"},
     {:keep, "phx_test/controllers",                   :web, "test/:web_app/controllers"},
     {:eex,  "#{@pre}/test/test_helper.exs",           :web, "test/test_helper.exs"},
@@ -36,7 +37,6 @@ defmodule Phx.New.Web do
   ]
 
   template :brunch, [
-    {:text, "phx_assets/brunch/gitignore",        :web, ".gitignore"},
     {:eex,  "phx_assets/brunch/brunch-config.js", :web, "assets/brunch-config.js"},
     {:text, "phx_assets/app.css",                 :web, "assets/css/app.css"},
     {:text, "phx_assets/phoenix.css",             :web, "assets/css/phoenix.css"},
@@ -58,12 +58,9 @@ defmodule Phx.New.Web do
     {:eex,  "phx_test/views/page_view_test.exs",              :web, "test/:web_app/views/page_view_test.exs"},
   ]
 
-  template :bare, [
-    {:text, "phx_assets/bare/gitignore", :web, ".gitignore"},
-  ]
+  template :bare, []
 
   template :static, [
-    {:text,   "phx_assets/bare/gitignore", :web, ".gitignore"},
     {:text,   "phx_assets/app.css",        :web, "priv/static/css/app.css"},
     {:append, "phx_assets/phoenix.css",    :web, "priv/static/css/app.css"},
     {:text,   "phx_assets/bare/app.js",    :web, "priv/static/js/app.js"},
diff --git a/installer/templates/phx_assets/bare/gitignore b/installer/templates/phx_assets/bare/gitignore
deleted file mode 100644
index d26faf840..000000000
--- a/installer/templates/phx_assets/bare/gitignore
+++ /dev/null
@@ -1,16 +0,0 @@
-# App artifacts
-/_build
-/db
-/deps
-/*.ez
-
-# Generated on crash by the VM
-erl_crash.dump
-
-# Files matching config/*.secret.exs pattern contain sensitive
-# data and you should not commit them into version control.
-#
-# Alternatively, you may comment the line below and commit the
-# secrets files as long as you replace their contents by environment
-# variables.
-/config/*.secret.exs
diff --git a/installer/templates/phx_assets/brunch/gitignore b/installer/templates/phx_assets/brunch/gitignore
deleted file mode 100644
index a829c5758..000000000
--- a/installer/templates/phx_assets/brunch/gitignore
+++ /dev/null
@@ -1,27 +0,0 @@
-# App artifacts
-/_build
-/db
-/deps
-/*.ez
-
-# Generated on crash by the VM
-erl_crash.dump
-
-# Generated on crash by NPM
-npm-debug.log
-
-# Static artifacts
-/assets/node_modules
-
-# Since we are building assets from assets/,
-# we ignore priv/static. You may want to comment
-# this depending on your deployment strategy.
-/priv/static/
-
-# Files matching config/*.secret.exs pattern contain sensitive
-# data and you should not commit them into version control.
-#
-# Alternatively, you may comment the line below and commit the
-# secrets files as long as you replace their contents by environment
-# variables.
-/config/*.secret.exs
diff --git a/installer/templates/phx_assets/brunch/package.json b/installer/templates/phx_assets/brunch/package.json
index f6d34c8d6..427c3b7df 100644
--- a/installer/templates/phx_assets/brunch/package.json
+++ b/installer/templates/phx_assets/brunch/package.json
@@ -11,7 +11,7 @@
   },
   "devDependencies": {
     "babel-brunch": "6.1.1",
-    "brunch": "2.10.9",
+    "brunch": "2.10.12",
     "clean-css-brunch": "2.10.0",
     "uglify-js-brunch": "2.10.0"
   }
diff --git a/installer/templates/phx_single/gitignore b/installer/templates/phx_single/gitignore
new file mode 100644
index 000000000..c68ff7882
--- /dev/null
+++ b/installer/templates/phx_single/gitignore
@@ -0,0 +1,42 @@
+# The directory Mix will write compiled artifacts to.
+/_build/
+
+# If you run "mix test --cover", coverage assets end up here.
+/cover/
+
+# The directory Mix downloads your dependencies sources to.
+/deps/
+
+# Where 3rd-party dependencies like ExDoc output generated docs.
+/doc/
+
+# Ignore .fetch files in case you like to edit your project deps locally.
+/.fetch
+
+# If the VM crashes, it generates a dump, let's ignore it too.
+erl_crash.dump
+
+# Also ignore archive artifacts (built via "mix archive.build").
+*.ez
+
+# Ignore package tarball (built via "mix hex.build").
+<%= app_name %>-*.tar
+<%= if brunch do %>
+# If NPM crashes, it generates a log, let's ignore it too.
+npm-debug.log
+
+# The directory NPM downloads your dependencies sources to.
+/assets/node_modules/
+<% end %>
+# Since we are building assets from assets/,
+# we ignore priv/static. You may want to comment
+# this depending on your deployment strategy.
+/priv/static/
+
+# Files matching config/*.secret.exs pattern contain sensitive
+# data and you should not commit them into version control.
+#
+# Alternatively, you may comment the line below and commit the
+# secrets files as long as you replace their contents by environment
+# variables.
+/config/*.secret.exs
diff --git a/installer/templates/phx_single/lib/app_name_web.ex b/installer/templates/phx_single/lib/app_name_web.ex
index 041504a9b..f1fd01856 100644
--- a/installer/templates/phx_single/lib/app_name_web.ex
+++ b/installer/templates/phx_single/lib/app_name_web.ex
@@ -33,7 +33,7 @@ defmodule <%= web_namespace %> do
                         namespace: <%= web_namespace %>
 
       # Import convenience functions from controllers
-      import Phoenix.Controller, only: [get_flash: 2, view_module: 1]<%= if html do %>
+      import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1]<%= if html do %>
 
       # Use all HTML functionality (forms, tags, etc)
       use Phoenix.HTML<% end %>
diff --git a/installer/templates/phx_umbrella/apps/app_name/gitignore b/installer/templates/phx_umbrella/apps/app_name/gitignore
new file mode 100644
index 000000000..8b885a323
--- /dev/null
+++ b/installer/templates/phx_umbrella/apps/app_name/gitignore
@@ -0,0 +1,31 @@
+# The directory Mix will write compiled artifacts to.
+/_build/
+
+# If you run "mix test --cover", coverage assets end up here.
+/cover/
+
+# The directory Mix downloads your dependencies sources to.
+/deps/
+
+# Where 3rd-party dependencies like ExDoc output generated docs.
+/doc/
+
+# Ignore .fetch files in case you like to edit your project deps locally.
+/.fetch
+
+# If the VM crashes, it generates a dump, let's ignore it too.
+erl_crash.dump
+
+# Also ignore archive artifacts (built via "mix archive.build").
+*.ez
+
+# Ignore package tarball (built via "mix hex.build").
+<%= app_name %>-*.tar
+
+# Files matching config/*.secret.exs pattern contain sensitive
+# data and you should not commit them into version control.
+#
+# Alternatively, you may comment the line below and commit the
+# secrets files as long as you replace their contents by environment
+# variables.
+/config/*.secret.exs
diff --git a/installer/templates/phx_umbrella/apps/app_name_web/gitignore b/installer/templates/phx_umbrella/apps/app_name_web/gitignore
index b409b5ace..af11a6568 100644
--- a/installer/templates/phx_umbrella/apps/app_name_web/gitignore
+++ b/installer/templates/phx_umbrella/apps/app_name_web/gitignore
@@ -1,15 +1,33 @@
-# App artifacts
-/_build
-/db
-/deps
-/*.ez
+# The directory Mix will write compiled artifacts to.
+/_build/
 
-# Generate on crash by the VM
+# If you run "mix test --cover", coverage assets end up here.
+/cover/
+
+# The directory Mix downloads your dependencies sources to.
+/deps/
+
+# Where 3rd-party dependencies like ExDoc output generated docs.
+/doc/
+
+# Ignore .fetch files in case you like to edit your project deps locally.
+/.fetch
+
+# If the VM crashes, it generates a dump, let's ignore it too.
 erl_crash.dump
 
-# Static artifacts
-/node_modules
+# Also ignore archive artifacts (built via "mix archive.build").
+*.ez
+
+# Ignore package tarball (built via "mix hex.build").
+<%= web_app_name %>-*.tar
+<%= if brunch do %>
+# If NPM crashes, it generates a log, let's ignore it too.
+npm-debug.log
 
+# The directory NPM downloads your dependencies sources to.
+/assets/node_modules/
+<% end %>
 # Since we are building assets from web/static,
 # we ignore priv/static. You may want to comment
 # this depending on your deployment strategy.
diff --git a/installer/templates/phx_umbrella/apps/app_name_web/lib/app_name.ex b/installer/templates/phx_umbrella/apps/app_name_web/lib/app_name.ex
index ab5de7d5f..c74045d09 100644
--- a/installer/templates/phx_umbrella/apps/app_name_web/lib/app_name.ex
+++ b/installer/templates/phx_umbrella/apps/app_name_web/lib/app_name.ex
@@ -32,7 +32,7 @@ defmodule <%= web_namespace %> do
                         namespace: <%= web_namespace %>
 
       # Import convenience functions from controllers
-      import Phoenix.Controller, only: [get_flash: 2, view_module: 1]<%= if html do %>
+      import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1]<%= if html do %>
 
       # Use all HTML functionality (forms, tags, etc)
       use Phoenix.HTML<% end %>
diff --git a/installer/templates/phx_umbrella/gitignore b/installer/templates/phx_umbrella/gitignore
index 755b60554..12179ea3c 100644
--- a/installer/templates/phx_umbrella/gitignore
+++ b/installer/templates/phx_umbrella/gitignore
@@ -1,5 +1,20 @@
-/_build
-/cover
-/deps
+# The directory Mix will write compiled artifacts to.
+/_build/
+
+# If you run "mix test --cover", coverage assets end up here.
+/cover/
+
+# The directory Mix downloads your dependencies sources to.
+/deps/
+
+# Where 3rd-party dependencies like ExDoc output generated docs.
+/doc/
+
+# Ignore .fetch files in case you like to edit your project deps locally.
+/.fetch
+
+# If the VM crashes, it generates a dump, let's ignore it too.
 erl_crash.dump
+
+# Also ignore archive artifacts (built via "mix archive.build").
 *.ez
diff --git a/lib/phoenix.ex b/lib/phoenix.ex
index 594b4fb9e..606263ffe 100644
--- a/lib/phoenix.ex
+++ b/lib/phoenix.ex
@@ -55,8 +55,8 @@ defmodule Phoenix do
     Supervisor.start_link(children, strategy: :one_for_one, name: Phoenix.Supervisor)
   end
 
-  # TODO: swap Poison default with Jason in 2.0
-  # from there we can ditch explicit config for new projects
+  # TODO v2: swap Poison default with Jason
+  # From there we can ditch explicit config for new projects
   @doc """
   Returns the configured JSON encoding library for Phoenix.
 
diff --git a/lib/phoenix/code_reloader/server.ex b/lib/phoenix/code_reloader/server.ex
index ce24a34cd..46f87ff84 100644
--- a/lib/phoenix/code_reloader/server.ex
+++ b/lib/phoenix/code_reloader/server.ex
@@ -24,7 +24,7 @@ defmodule Phoenix.CodeReloader.Server do
   end
 
   def handle_call(:check_symlinks, _from, checked?) do
-    if not checked? and Code.ensure_loaded?(Mix.Project) do
+    if not checked? and Code.ensure_loaded?(Mix.Project) and not Mix.Project.umbrella? do
       priv_path = "#{Mix.Project.app_path}/priv"
 
       case :file.read_link(priv_path) do
@@ -129,9 +129,6 @@ defmodule Phoenix.CodeReloader.Server do
     end
   end
 
-  # TODO: Remove the function_exported call after 1.3 support is removed
-  # and just use loaded. apply/3 is used to prevent a compilation
-  # warning.
   defp mix_compile({:module, Mix.Task}, compilers) do
     if Mix.Project.umbrella? do
       Enum.each Mix.Dep.Umbrella.cached, fn dep ->
diff --git a/lib/phoenix/endpoint/supervisor.ex b/lib/phoenix/endpoint/supervisor.ex
index 76c4a6293..37229fb6f 100644
--- a/lib/phoenix/endpoint/supervisor.ex
+++ b/lib/phoenix/endpoint/supervisor.ex
@@ -21,10 +21,6 @@ defmodule Phoenix.Endpoint.Supervisor do
   end
 
   @doc false
-  # TODO: Ideally every process would be named based on a `name` option
-  # instead of using the module. However, __phoenix_pubsub__ is highly
-  # dependent on the module name, so we should consider enabling this
-  # once we move to Firenest.
   def init({otp_app, mod}) do
     id = :crypto.strong_rand_bytes(16) |> Base.encode64
 
@@ -65,8 +61,8 @@ defmodule Phoenix.Endpoint.Supervisor do
     end
   end
 
-  # TODO: Handlers -> Phoenix.Server
-  # TODO: Each transport should have its own tree
+  # TODO v1.4: Handlers -> Phoenix.Server
+  # TODO v1.4: Each transport should have its own tree
   defp server_children(mod, conf, server?) do
     if server? do
       server = Module.concat(mod, "Server")
diff --git a/lib/phoenix/token.ex b/lib/phoenix/token.ex
index c8ec277b5..2ec024a0a 100644
--- a/lib/phoenix/token.ex
+++ b/lib/phoenix/token.ex
@@ -223,10 +223,11 @@ defmodule Phoenix.Token do
   defp expired?(_signed, :infinity), do: false
 
   defp expired?(_signed, nil) do
-    # TODO: Default to 86400 on future releases.
+    # TODO v2: Default to 86400 on future releases.
     Logger.warn ":max_age was not set on Phoenix.Token.verify/4. " <>
                 "A max_age is recommended otherwise tokens are forever valid. " <>
-                "Please set it to the amount of seconds the token is valid, such as 86400 (1 day)"
+                "Please set it to the amount of seconds the token is valid, " <>
+                "such as 86400 (1 day), or :infinity if you really want this token to be valid forever"
     false
   end
 
diff --git a/mix.exs b/mix.exs
index 8b9cab9a0..6e52b2537 100644
--- a/mix.exs
+++ b/mix.exs
@@ -55,7 +55,7 @@ defmodule Phoenix.Mixfile do
   defp deps do
     [
       {:cowboy, "~> 1.0 or ~> 2.2.2 or ~> 2.3", optional: true},
-      # TODO: before 1.4 release bump to 1.5.0 stable when it goes out with `init_mode` support
+      # TODO v1.4: release bump to 1.5.0 stable when it goes out with `init_mode` support
       {:plug, "~> 1.5.0-rc2", override: true},
       {:phoenix_pubsub, "~> 1.0"},
       {:jason, "~> 1.0", optional: true},
@@ -66,7 +66,7 @@ defmodule Phoenix.Mixfile do
 
       # Test dependencies
       {:gettext, "~> 0.8", only: :test},
-      # TODO: before phoenix 1.4 release bump to next stable release with relaxed plug dep
+      # TODO v1.4: release bump to next stable release with relaxed plug dep
       {:phoenix_html, "~> 2.10", only: :test},
       {:websocket_client, git: "https://github.com/jeremyong/websocket_client.git", only: :test}
     ]
diff --git a/priv/static/phoenix.js b/priv/static/phoenix.js
index ac70c6e06..ccece3d87 100644
--- a/priv/static/phoenix.js
+++ b/priv/static/phoenix.js
@@ -130,21 +130,29 @@ function _classCallCheck(instance, Constructor) { if (!(instance instanceof Cons
  * The `Presence` object provides features for syncing presence information
  * from the server with the client and handling presences joining and leaving.
  *
- * ### Syncing initial state from the server
+ * ### Syncing state from the server
  *
- * `Presence.syncState` is used to sync the list of presences on the server
- * with the client's state. An optional `onJoin` and `onLeave` callback can
- * be provided to react to changes in the client's local presences across
- * disconnects and reconnects with the server.
+ * To sync presence state from the server, first instantiate an object and
+ * pass your channel in to track lifecycle events:
  *
- * `Presence.syncDiff` is used to sync a diff of presence join and leave
- * events from the server, as they happen. Like `syncState`, `syncDiff`
- * accepts optional `onJoin` and `onLeave` callbacks to react to a user
- * joining or leaving from a device.
+ * ```javascript
+ * let channel = new socket.channel("some:topic")
+ * let presence = new Presence(channel)
+ * ```
+ *
+ * Next, use the `presence.onSync` callback to react to state changes
+ * from the server. For example, to render the list of users every time
+ * the list changes, you could write:
+ *
+ * ```javascript
+ * presence.onSync(() => {
+ *   myRenderUsersFunction(presence.list())
+ * })
+ * ```
  *
  * ### Listing Presences
  *
- * `Presence.list` is used to return a list of presence information
+ * `presence.list` is used to return a list of presence information
  * based on the local state of metadata. By default, all presence
  * metadata is returned, but a `listBy` function can be supplied to
  * allow the client to select which metadata to use for a given presence.
@@ -157,45 +165,42 @@ function _classCallCheck(instance, Constructor) { if (!(instance instanceof Cons
  * they came online from:
  *
  * ```javascript
- * let state = {}
- * state = Presence.syncState(state, stateFromServer)
  * let listBy = (id, {metas: [first, ...rest]}) => {
  *   first.count = rest.length + 1 // count of this user's presences
  *   first.id = id
  *   return first
  * }
- * let onlineUsers = Presence.list(state, listBy)
+ * let onlineUsers = presence.list(listBy)
  * ```
  *
+ * ### Handling individual presence join and leave events
+ *
+ * The `presence.onJoin` and `presence.onLeave` callbacks can be used to
+ * react to individual presences joining and leaving the app. For example:
  *
- * ### Example Usage
  * ```javascript
+ * let presence = new Presence(channel)
+ *
  * // detect if user has joined for the 1st time or from another tab/device
- * let onJoin = (id, current, newPres) => {
+ * presence.onJoin((id, current, newPres) => {
  *   if(!current){
  *     console.log("user has entered for the first time", newPres)
  *   } else {
  *     console.log("user additional presence", newPres)
  *   }
- * }
+ * })
+ *
  * // detect if user has left from all tabs/devices, or is still present
- * let onLeave = (id, current, leftPres) => {
+ * presence.onLeave((id, current, leftPres) => {
  *   if(current.metas.length === 0){
  *     console.log("user has left from all devices", leftPres)
  *   } else {
  *     console.log("user left from a device", leftPres)
  *   }
- * }
- * let presences = {} // client's initial empty presence state
- * // receive initial presence data from server, sent after join
- * myChannel.on("presence_state", state => {
- *   presences = Presence.syncState(presences, state, onJoin, onLeave)
- *   displayUsers(Presence.list(presences))
  * })
- * // receive "presence_diff" from server, containing join/leave events
- * myChannel.on("presence_diff", diff => {
- *   presences = Presence.syncDiff(presences, diff, onJoin, onLeave)
- *   this.setState({users: Presence.list(room.presences, listBy)})
+ * // receive presence data from server
+ * presence.onSync(() => {
+ *   displayUsers(presence.list())
  * })
  * ```
  * @module phoenix
@@ -1544,138 +1549,246 @@ var Ajax = exports.Ajax = function () {
 
 Ajax.states = { complete: 4 };
 
-var Presence = exports.Presence = {
-  syncState: function syncState(currentState, newState, onJoin, onLeave) {
+/**
+ * Initializes the Presence
+ * @param {Channel} channel - The Channel
+ * @param {Object} opts - The options,
+ *        for example `{events: {state: "state", diff: "diff"}}`
+ */
+
+var Presence = exports.Presence = function () {
+  function Presence(channel) {
     var _this14 = this;
 
-    var state = this.clone(currentState);
-    state.presences = state.presences || {};
-    state.synced = true;
-    var pendingDiffs = state.pendingDiffs || [];
-    var joins = {};
-    var leaves = {};
+    var opts = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
 
-    this.map(state.presences, function (key, presence) {
-      if (!newState[key]) {
-        leaves[key] = presence;
-      }
+    _classCallCheck(this, Presence);
+
+    var events = opts.events || { state: "presence_state", diff: "presence_diff" };
+    this.state = {};
+    this.pendingDiffs = [];
+    this.channel = channel;
+    this.joinRef = null;
+    this.caller = {
+      onJoin: function onJoin() {},
+      onLeave: function onLeave() {},
+      onSync: function onSync() {}
+    };
+
+    this.channel.on(events.state, function (newState) {
+      var _caller = _this14.caller,
+          onJoin = _caller.onJoin,
+          onLeave = _caller.onLeave,
+          onSync = _caller.onSync;
+
+
+      _this14.joinRef = _this14.channel.joinRef();
+      _this14.state = Presence.syncState(_this14.state, newState, onJoin, onLeave);
+
+      _this14.pendingDiffs.forEach(function (diff) {
+        _this14.state = Presence.syncDiff(_this14.state, diff, onJoin, onLeave);
+      });
+      _this14.pendingDiffs = [];
+      onSync();
     });
-    this.map(newState, function (key, newPresence) {
-      var currentPresence = state.presences[key];
-      if (currentPresence) {
-        var newRefs = newPresence.metas.map(function (m) {
-          return m.phx_ref;
-        });
-        var curRefs = currentPresence.metas.map(function (m) {
-          return m.phx_ref;
-        });
-        var joinedMetas = newPresence.metas.filter(function (m) {
-          return curRefs.indexOf(m.phx_ref) < 0;
-        });
-        var leftMetas = currentPresence.metas.filter(function (m) {
-          return newRefs.indexOf(m.phx_ref) < 0;
-        });
-        if (joinedMetas.length > 0) {
-          joins[key] = newPresence;
-          joins[key].metas = joinedMetas;
-        }
-        if (leftMetas.length > 0) {
-          leaves[key] = _this14.clone(currentPresence);
-          leaves[key].metas = leftMetas;
-        }
+
+    this.channel.on(events.diff, function (diff) {
+      var _caller2 = _this14.caller,
+          onJoin = _caller2.onJoin,
+          onLeave = _caller2.onLeave,
+          onSync = _caller2.onSync;
+
+
+      if (_this14.inPendingSyncState()) {
+        _this14.pendingDiffs.push(diff);
       } else {
-        joins[key] = newPresence;
+        _this14.state = Presence.syncDiff(_this14.state, diff, onJoin, onLeave);
+        onSync();
       }
     });
+  }
 
-    state = this.syncDiff(state, { joins: joins, leaves: leaves }, onJoin, onLeave);
+  _createClass(Presence, [{
+    key: "onJoin",
+    value: function onJoin(callback) {
+      this.caller.onJoin = callback;
+    }
+  }, {
+    key: "onLeave",
+    value: function onLeave(callback) {
+      this.caller.onLeave = callback;
+    }
+  }, {
+    key: "onSync",
+    value: function onSync(callback) {
+      this.caller.onSync = callback;
+    }
+  }, {
+    key: "list",
+    value: function list(by) {
+      return Presence.list(this.state, by);
+    }
+  }, {
+    key: "inPendingSyncState",
+    value: function inPendingSyncState() {
+      return !this.joinRef || this.joinRef !== this.channel.joinRef();
+    }
 
-    pendingDiffs.forEach(function (diff) {
-      state = _this14.syncDiff(state, { joins: diff.joins, leaves: diff.leaves }, diff.onJoin, diff.onLeave);
-    });
-    state.pendingDiffs = [];
+    // lower-level public static API
 
-    return state;
-  },
-  syncDiff: function syncDiff(currentState, _ref2, onJoin, onLeave) {
-    var joins = _ref2.joins,
-        leaves = _ref2.leaves;
+    /**
+     * Used to sync the list of presences on the server
+     * with the client's state. An optional `onJoin` and `onLeave` callback can
+     * be provided to react to changes in the client's local presences across
+     * disconnects and reconnects with the server.
+     *
+     * @returns {Presence}
+     */
 
-    var state = this.clone(currentState);
-    state.presences = state.presences || {};
-    state.pendingDiffs = state.pendingDiffs || [];
+  }], [{
+    key: "syncState",
+    value: function syncState(currentState, newState, onJoin, onLeave) {
+      var _this15 = this;
 
-    if (!state.synced) {
-      state.pendingDiffs.push({ joins: joins, leaves: leaves, onJoin: onJoin, onLeave: onLeave });
-      return state;
-    }
+      var state = this.clone(currentState);
+      var joins = {};
+      var leaves = {};
 
-    if (!onJoin) {
-      onJoin = function onJoin() {};
-    }
-    if (!onLeave) {
-      onLeave = function onLeave() {};
+      this.map(state, function (key, presence) {
+        if (!newState[key]) {
+          leaves[key] = presence;
+        }
+      });
+      this.map(newState, function (key, newPresence) {
+        var currentPresence = state[key];
+        if (currentPresence) {
+          var newRefs = newPresence.metas.map(function (m) {
+            return m.phx_ref;
+          });
+          var curRefs = currentPresence.metas.map(function (m) {
+            return m.phx_ref;
+          });
+          var joinedMetas = newPresence.metas.filter(function (m) {
+            return curRefs.indexOf(m.phx_ref) < 0;
+          });
+          var leftMetas = currentPresence.metas.filter(function (m) {
+            return newRefs.indexOf(m.phx_ref) < 0;
+          });
+          if (joinedMetas.length > 0) {
+            joins[key] = newPresence;
+            joins[key].metas = joinedMetas;
+          }
+          if (leftMetas.length > 0) {
+            leaves[key] = _this15.clone(currentPresence);
+            leaves[key].metas = leftMetas;
+          }
+        } else {
+          joins[key] = newPresence;
+        }
+      });
+      return this.syncDiff(state, { joins: joins, leaves: leaves }, onJoin, onLeave);
     }
 
-    this.map(joins, function (key, newPresence) {
-      var currentPresence = state.presences[key];
-      state.presences[key] = newPresence;
-      if (currentPresence) {
-        var _state$presences$key$;
+    /**
+     *
+     * Used to sync a diff of presence join and leave
+     * events from the server, as they happen. Like `syncState`, `syncDiff`
+     * accepts optional `onJoin` and `onLeave` callbacks to react to a user
+     * joining or leaving from a device.
+     *
+     * @returns {Presence}
+     */
 
-        var joinedRefs = state.presences[key].metas.map(function (m) {
-          return m.phx_ref;
-        });
-        var curMetas = currentPresence.metas.filter(function (m) {
-          return joinedRefs.indexOf(m.phx_ref) < 0;
-        });
-        (_state$presences$key$ = state.presences[key].metas).unshift.apply(_state$presences$key$, _toConsumableArray(curMetas));
+  }, {
+    key: "syncDiff",
+    value: function syncDiff(currentState, _ref2, onJoin, onLeave) {
+      var joins = _ref2.joins,
+          leaves = _ref2.leaves;
+
+      var state = this.clone(currentState);
+      if (!onJoin) {
+        onJoin = function onJoin() {};
       }
-      onJoin(key, currentPresence, newPresence);
-    });
-    this.map(leaves, function (key, leftPresence) {
-      var currentPresence = state.presences[key];
-      if (!currentPresence) {
-        return;
+      if (!onLeave) {
+        onLeave = function onLeave() {};
       }
-      var refsToRemove = leftPresence.metas.map(function (m) {
-        return m.phx_ref;
+
+      this.map(joins, function (key, newPresence) {
+        var currentPresence = state[key];
+        state[key] = newPresence;
+        if (currentPresence) {
+          var _state$key$metas;
+
+          var joinedRefs = state[key].metas.map(function (m) {
+            return m.phx_ref;
+          });
+          var curMetas = currentPresence.metas.filter(function (m) {
+            return joinedRefs.indexOf(m.phx_ref) < 0;
+          });
+          (_state$key$metas = state[key].metas).unshift.apply(_state$key$metas, _toConsumableArray(curMetas));
+        }
+        onJoin(key, currentPresence, newPresence);
       });
-      currentPresence.metas = currentPresence.metas.filter(function (p) {
-        return refsToRemove.indexOf(p.phx_ref) < 0;
+      this.map(leaves, function (key, leftPresence) {
+        var currentPresence = state[key];
+        if (!currentPresence) {
+          return;
+        }
+        var refsToRemove = leftPresence.metas.map(function (m) {
+          return m.phx_ref;
+        });
+        currentPresence.metas = currentPresence.metas.filter(function (p) {
+          return refsToRemove.indexOf(p.phx_ref) < 0;
+        });
+        onLeave(key, currentPresence, leftPresence);
+        if (currentPresence.metas.length === 0) {
+          delete state[key];
+        }
       });
-      onLeave(key, currentPresence, leftPresence);
-      if (currentPresence.metas.length === 0) {
-        delete state.presences[key];
-      }
-    });
-    return state;
-  },
-  list: function list(state, chooser) {
-    var presences = state.presences || {};
-    if (!chooser) {
-      chooser = function chooser(key, pres) {
-        return pres;
-      };
+      return state;
     }
 
-    return this.map(presences, function (key, presence) {
-      return chooser(key, presence);
-    });
-  },
+    /**
+     * Returns the array of presences, with selected metadata.
+     *
+     * @param {Object} presences
+     * @param {Function} chooser
+     *
+     * @returns {Presence}
+     */
 
+  }, {
+    key: "list",
+    value: function list(presences, chooser) {
+      if (!chooser) {
+        chooser = function chooser(key, pres) {
+          return pres;
+        };
+      }
 
-  // private
+      return this.map(presences, function (key, presence) {
+        return chooser(key, presence);
+      });
+    }
 
-  map: function map(obj, func) {
-    return Object.getOwnPropertyNames(obj).map(function (key) {
-      return func(key, obj[key]);
-    });
-  },
-  clone: function clone(obj) {
-    return JSON.parse(JSON.stringify(obj));
-  }
-};
+    // private
+
+  }, {
+    key: "map",
+    value: function map(obj, func) {
+      return Object.getOwnPropertyNames(obj).map(function (key) {
+        return func(key, obj[key]);
+      });
+    }
+  }, {
+    key: "clone",
+    value: function clone(obj) {
+      return JSON.parse(JSON.stringify(obj));
+    }
+  }]);
+
+  return Presence;
+}();
 
 /**
  *
@@ -1695,6 +1808,7 @@ var Presence = exports.Presence = {
  * @param {Function} timerCalc
  */
 
+
 var Timer = function () {
   function Timer(callback, timerCalc) {
     _classCallCheck(this, Timer);
@@ -1719,13 +1833,13 @@ var Timer = function () {
   }, {
     key: "scheduleTimeout",
     value: function scheduleTimeout() {
-      var _this15 = this;
+      var _this16 = this;
 
       clearTimeout(this.timer);
 
       this.timer = setTimeout(function () {
-        _this15.tries = _this15.tries + 1;
-        _this15.callback();
+        _this16.tries = _this16.tries + 1;
+        _this16.callback();
       }, this.timerCalc(this.tries + 1));
     }
   }]);


Test patch:
diff --git a/assets/test/presence_test.js b/assets/test/presence_test.js
index 4bff6e8c0..22c2d15fc 100644
--- a/assets/test/presence_test.js
+++ b/assets/test/presence_test.js
@@ -20,6 +20,23 @@ let fixtures = {
   }
 }
 
+let channelStub = {
+  ref: 1,
+  events: {},
+
+  on(event, callback){ this.events[event] = callback },
+
+  trigger(event, data){ this.events[event](data) },
+
+  joinRef(){ return `${this.ref}` },
+
+  simulateDisconnectAndReconnect(){
+    this.ref++
+  }
+}
+
+let listByFirst = (id, {metas: [first, ...rest]}) => first
+
 describe("syncState", () => {
   it("syncs empty state", () => {
     let newState = {u1: {metas: [{id: 1, phx_ref: "1"}]}}
@@ -29,13 +46,12 @@ describe("syncState", () => {
     assert.deepEqual(state, stateBefore)
 
     state = Presence.syncState(state, newState)
-    assert.deepEqual(state.presences, newState)
+    assert.deepEqual(state, newState)
   })
 
   it("onJoins new presences and onLeave's left presences", () => {
     let newState = fixtures.state()
-    let state = {}
-    state = Presence.syncState(state, {u4: {metas: [{id: 4, phx_ref: "4"}]}})
+    let state = {u4: {metas: [{id: 4, phx_ref: "4"}]}}
     let joined = {}
     let left = {}
     let onJoin = (key, current, newPres) => {
@@ -44,9 +60,12 @@ describe("syncState", () => {
     let onLeave = (key, current, leftPres) => {
       left[key] = {current: current, leftPres: leftPres}
     }
+    let stateBefore = clone(state)
+    Presence.syncState(state, newState, onJoin, onLeave)
+    assert.deepEqual(state, stateBefore)
 
     state = Presence.syncState(state, newState, onJoin, onLeave)
-    assert.deepEqual(state.presences, newState)
+    assert.deepEqual(state, newState)
     assert.deepEqual(joined, {
       u1: {current: null, newPres: {metas: [{id: 1, phx_ref: "1"}]}},
       u2: {current: null, newPres: {metas: [{id: 2, phx_ref: "2"}]}},
@@ -59,7 +78,7 @@ describe("syncState", () => {
 
   it("onJoins only newly added metas", () => {
     let newState = {u3: {metas: [{id: 3, phx_ref: "3"}, {id: 3, phx_ref: "3.new"}]}}
-    let state = {presences: {u3: {metas: [{id: 3, phx_ref: "3"}]}}}
+    let state = {u3: {metas: [{id: 3, phx_ref: "3"}]}}
     let joined = {}
     let left = {}
     let onJoin = (key, current, newPres) => {
@@ -69,7 +88,7 @@ describe("syncState", () => {
       left[key] = {current: current, leftPres: leftPres}
     }
     state = Presence.syncState(state, newState, onJoin, onLeave)
-    assert.deepEqual(state.presences, newState)
+    assert.deepEqual(state, newState)
     assert.deepEqual(joined, {
       u3: {current: {metas: [{id: 3, phx_ref: "3"}]},
            newPres: {metas: [{id: 3, phx_ref: "3"}, {id: 3, phx_ref: "3.new"}]}}
@@ -82,62 +101,34 @@ describe("syncDiff", () => {
   it("syncs empty state", () => {
     let joins = {u1: {metas: [{id: 1, phx_ref: "1"}]}}
     let state = {}
-    state = Presence.syncState(state, {})
     Presence.syncDiff(state, {joins: joins, leaves: {}})
-    assert.deepEqual(state, {synced: true, presences: {}, pendingDiffs: []})
+    assert.deepEqual(state, {})
 
     state = Presence.syncDiff(state, {
       joins: joins,
       leaves: {}
     })
-    assert.deepEqual(state.presences, joins)
-  })
-
-  it("applies pending diff if state is not yet synced", () => {
-    let newState = {u1: {metas: [{id: 1, phx_ref: "1"}]},
-                    u2: {metas: [{id: 2, phx_ref: "2"}]}}
-    let leaves = {u2: {metas: [{id: 2, phx_ref: "2"}]}}
-    let state = {}
-    state = Presence.syncDiff(state, {joins: {}, leaves: leaves})
-    assert.deepEqual(state, {
-      presences: {},
-      pendingDiffs: [{
-        joins: {}, leaves: leaves, onJoin: undefined, onLeave: undefined
-      }]
-    })
-
-    state = Presence.syncState(state, newState)
-    assert.deepEqual(state, {
-      synced: true,
-      pendingDiffs: [],
-      presences: {u1: {metas: [{id: 1, phx_ref: "1"}]}}
-    })
+    assert.deepEqual(state, joins)
   })
 
   it("removes presence when meta is empty and adds additional meta", () => {
-    let state = {}
-    state = Presence.syncState(state, fixtures.state())
+    let state = fixtures.state()
     state = Presence.syncDiff(state, {joins: fixtures.joins(), leaves: fixtures.leaves()})
 
     assert.deepEqual(state, {
-      pendingDiffs: [],
-      synced: true,
-      presences: {
-        u1: {metas: [{id: 1, phx_ref: "1"}, {id: 1, phx_ref: "1.2"}]},
-        u3: {metas: [{id: 3, phx_ref: "3"}]}
-      }
+      u1: {metas: [{id: 1, phx_ref: "1"}, {id: 1, phx_ref: "1.2"}]},
+      u3: {metas: [{id: 3, phx_ref: "3"}]}
     })
   })
 
   it("removes meta while leaving key if other metas exist", () => {
-    let state = {}
-    state = Presence.syncState(state, {u1: {metas: [{id: 1, phx_ref: "1"}, {id: 1, phx_ref: "1.2"}]}})
+    let state = {
+      u1: {metas: [{id: 1, phx_ref: "1"}, {id: 1, phx_ref: "1.2"}]}
+    }
     state = Presence.syncDiff(state, {joins: {}, leaves: {u1: {metas: [{id: 1, phx_ref: "1"}]}}})
 
     assert.deepEqual(state, {
-      pendingDiffs: [],
-      synced: true,
-      presences: {u1: {metas: [{id: 1, phx_ref: "1.2"}]}},
+      u1: {metas: [{id: 1, phx_ref: "1.2"}]},
     })
   })
 })
@@ -145,9 +136,7 @@ describe("syncDiff", () => {
 
 describe("list", () => {
   it("lists full presence by default", () => {
-    let state = {}
-    state = Presence.syncState(state, fixtures.state())
-
+    let state = fixtures.state()
     assert.deepEqual(Presence.list(state), [
       {metas: [{id: 1, phx_ref: "1"}]},
       {metas: [{id: 2, phx_ref: "2"}]},
@@ -156,16 +145,10 @@ describe("list", () => {
   })
 
   it("lists with custom function", () => {
-    let state = {
-      presences: {
-        u1: {
-          metas: [
-            {id: 1, phx_ref: "1.first"},
-            {id: 1, phx_ref: "1.second"}
-          ]
-        }
-      }
-    }
+    let state = {u1: {metas: [
+      {id: 1, phx_ref: "1.first"},
+      {id: 1, phx_ref: "1.second"}]
+    }}
 
     let listBy = (key, {metas: [first, ...rest]}) => {
       return first
@@ -175,4 +158,82 @@ describe("list", () => {
       {id: 1, phx_ref: "1.first"}
     ])
   })
+})
+
+describe("instance", () => {
+  it("syncs state and diffs", () => {
+    let presence = new Presence(channelStub)
+    let user1 = {metas: [{id: 1, phx_ref: "1"}]}
+    let user2 = {metas: [{id: 2, phx_ref: "2"}]}
+    let newState = {u1: user1, u2: user2}
+
+    channelStub.trigger("presence_state", newState)
+    assert.deepEqual(presence.list(listByFirst), [{id: 1, phx_ref: 1},
+                                                  {id: 2, phx_ref: 2}])
+
+    channelStub.trigger("presence_diff", {joins: {}, leaves: {u1: user1}})
+    assert.deepEqual(presence.list(listByFirst), [{id: 2, phx_ref: 2}])
+  })
+
+
+  it("applies pending diff if state is not yet synced", () => {
+    let presence = new Presence(channelStub)
+    let onJoins = []
+    let onLeaves = []
+
+    presence.onJoin((id, current, newPres) => {
+      onJoins.push({id, current, newPres})
+    })
+    presence.onLeave((id, current, leftPres) => {
+      onLeaves.push({id, current, leftPres})
+    })
+
+    // new connection
+    let user1 = {metas: [{id: 1, phx_ref: "1"}]}
+    let user2 = {metas: [{id: 2, phx_ref: "2"}]}
+    let user3 = {metas: [{id: 3, phx_ref: "3"}]}
+    let newState = {u1: user1, u2: user2}
+    let leaves = {u2: user2}
+
+    channelStub.trigger("presence_diff", {joins: {}, leaves: leaves})
+
+    assert.deepEqual(presence.list(listByFirst), [])
+    assert.deepEqual(presence.pendingDiffs, [{joins: {}, leaves: leaves}])
+
+    channelStub.trigger("presence_state", newState)
+    assert.deepEqual(onLeaves, [
+      {id: "u2", current: {metas: []}, leftPres: {metas: [{id: 2, phx_ref: "2"}]}}
+    ])
+
+    assert.deepEqual(presence.list(listByFirst), [{id: 1, phx_ref: "1"}])
+    assert.deepEqual(presence.pendingDiffs, [])
+    assert.deepEqual(onJoins, [
+      {id: "u1", current: undefined, newPres: {metas: [{id: 1, phx_ref: "1"}]}},
+      {id: "u2", current: undefined, newPres: {metas: [{id: 2, phx_ref: "2"}]}}
+    ])
+
+    // disconnect and reconnect
+    assert.equal(presence.inPendingSyncState(), false)
+    channelStub.simulateDisconnectAndReconnect()
+    assert.equal(presence.inPendingSyncState(), true)
+
+    channelStub.trigger("presence_diff", {joins: {}, leaves: {u1: user1}})
+    assert.deepEqual(presence.list(listByFirst), [{id: 1, phx_ref: "1"}])
+
+    channelStub.trigger("presence_state", {u1: user1, u3: user3})
+    assert.deepEqual(presence.list(listByFirst), [{id: 3, phx_ref: "3"}])
+  })
+
+  it("allows custom channel events", () => {
+    let presence = new Presence(channelStub, {events: {
+      state: "the_state",
+      diff: "the_diff"
+    }})
+
+    let user1 = {metas: [{id: 1, phx_ref: "1"}]}
+    channelStub.trigger("the_state", {user1: user1})
+    assert.deepEqual(presence.list(listByFirst), [{id: 1, phx_ref: "1"}])
+    channelStub.trigger("the_diff", {joins: {}, leaves: {user1: user1}})
+    assert.deepEqual(presence.list(listByFirst), [])
+  })
 })
\ No newline at end of file
diff --git a/guides/docs/testing/testing_controllers.md b/guides/docs/testing/testing_controllers.md
index c51ab1867..405732959 100644
--- a/guides/docs/testing/testing_controllers.md
+++ b/guides/docs/testing/testing_controllers.md
@@ -440,9 +440,9 @@ end
 
 The first branch of the case statement handles the `nil` result case. First, we use the [`put_status/2`](https://hexdocs.pm/plug/Plug.Conn.html#put_status/2) function from `Plug.Conn` to set the desired error status. The complete list of allowed codes can be found in the [Plug.Conn.Status documentation](https://hexdocs.pm/plug/Plug.Conn.Status.html), where we can see that `:not_found` corresponds to our desired "404" status. We then return a text response using [`text/2`](https://hexdocs.pm/phoenix/Phoenix.Controller.html#text/2).
 
-The second branch of the case statement handles the "happy path" we've already covered.
+The second branch of the case statement handles the "happy path" we've already covered. Phoenix also allows us to only implement the "happy path" in our action and use `Phoenix.Controller.action_fallback/1`. This is useful for centralizing your error handling code. You may wish to refactor the show action to use action_fallback as covered in the "Action Fallback" section of the [controllers guide](controllers.html#action-fallback).
 
-With those implemented, our tests pass.
+With those implemented, our tests pass. 
 
 The rest of the controller is left for you to implement as practice. If you are not sure where to begin, it is worth using the Phoenix JSON generator and seeing what tests are automatically generated for you.
 
diff --git a/installer/test/phx_new_test.exs b/installer/test/phx_new_test.exs
index 381f43d8b..0882b0d11 100644
--- a/installer/test/phx_new_test.exs
+++ b/installer/test/phx_new_test.exs
@@ -74,7 +74,8 @@ defmodule Mix.Tasks.Phx.NewTest do
                   "<title>Hello PhxBlog!</title>"
 
       # Brunch
-      assert_file "phx_blog/.gitignore", "/node_modules"
+      assert_file "phx_blog/.gitignore", "/assets/node_modules/"
+      assert_file "phx_blog/.gitignore", "phx_blog-*.tar"
       assert_file "phx_blog/.gitignore", ~r/\n$/
       assert_file "phx_blog/assets/brunch-config.js", ~s("js/app.js": ["js/app"])
       assert_file "phx_blog/config/dev.exs", fn file ->
@@ -146,7 +147,7 @@ defmodule Mix.Tasks.Phx.NewTest do
       Mix.Tasks.Phx.New.run([@app_name, "--no-html", "--no-brunch", "--no-ecto"])
 
       # No Brunch
-      refute File.read!("phx_blog/.gitignore") |> String.contains?("/node_modules")
+      refute File.read!("phx_blog/.gitignore") |> String.contains?("/assets/node_modules/")
       assert_file "phx_blog/.gitignore", ~r/\n$/
       assert_file "phx_blog/config/dev.exs", ~r/watchers: \[\]/
 
diff --git a/installer/test/phx_new_umbrella_test.exs b/installer/test/phx_new_umbrella_test.exs
index 372e4f43c..0c8d16bf4 100644
--- a/installer/test/phx_new_umbrella_test.exs
+++ b/installer/test/phx_new_umbrella_test.exs
@@ -39,6 +39,7 @@ defmodule Mix.Tasks.Phx.New.UmbrellaTest do
 
       assert_file app_path(@app, "README.md")
       assert_file app_path(@app, ".gitignore")
+      assert_file app_path(@app, ".gitignore"), "#{@app}-*.tar"
       assert_file( app_path(@app, ".gitignore"), ~r/\n$/)
       assert_file web_path(@app, "README.md")
       assert_file root_path(@app, "mix.exs"), fn file ->
@@ -111,7 +112,8 @@ defmodule Mix.Tasks.Phx.New.UmbrellaTest do
                   "defmodule PhxUmbWeb.PageViewTest"
 
       # Brunch
-      assert_file web_path(@app, ".gitignore"), "/node_modules"
+      assert_file web_path(@app, ".gitignore"), "/assets/node_modules/"
+      assert_file web_path(@app, ".gitignore"), "#{@app}_web-*.tar"
       assert_file( web_path(@app, ".gitignore"),  ~r/\n$/)
       assert_file web_path(@app, "assets/brunch-config.js"), ~s("js/app.js": ["js/app"])
       assert_file web_path(@app, "config/dev.exs"), fn file ->
@@ -195,7 +197,7 @@ defmodule Mix.Tasks.Phx.New.UmbrellaTest do
       Mix.Tasks.Phx.New.run([@app, "--umbrella", "--no-html", "--no-brunch", "--no-ecto"])
 
       # No Brunch
-      refute File.read!(web_path(@app, ".gitignore")) |> String.contains?("/node_modules")
+      refute File.read!(web_path(@app, ".gitignore")) |> String.contains?("/assets/node_modules/")
       assert_file( web_path(@app, ".gitignore"),  ~r/\n$/)
       assert_file web_path(@app, "config/dev.exs"), ~r/watchers: \[\]/
 
@@ -489,7 +491,7 @@ defmodule Mix.Tasks.Phx.New.UmbrellaTest do
                     "<title>Hello Another!</title>"
 
         # Brunch
-        assert_file "another/.gitignore", "/node_modules"
+        assert_file "another/.gitignore", "/assets/node_modules"
         assert_file "another/.gitignore",  ~r/\n$/
         assert_file "another/assets/brunch-config.js", ~s("js/app.js": ["js/app"])
         assert_file "another/config/dev.exs", "watchers: [node:"