Repository: phoenixframework/phoenix
Pull request #5275 (2023-02-15 07:36:02)

Provide a better error message when getting malformed V1 Socket Payloads
Currently, if when establishing a websocket connect, no `vsn` parameter is given, the socket connection defaults to the V1 serializer for encoding and decoding message. If passed a message that doesn't match what the serializer is expecting, a very cryptic message is raised:

```
Ranch listener TenantWeb.Endpoint.HTTP had connection process started with :cowboy_clear:start_link/4 at #PID<0.30056.42> exit with reason: {:function_clause, [{Phoenix.Socket.Message, :from_map!, [[nil, "1", "phoenix", "heartbeat", %{}]], []}, {Phoenix.Socket, :__in__, 2, [file: 'lib/phoenix/socket.ex', line: 471]}, {Phoenix.Endpoint.Cowboy2Handler, :websocket_handle, 2, [file: 'lib/phoenix/endpoint/cowboy2_handler.ex', line: 145]}, {:cowboy_websocket, :handler_call, 6, [file: '/build/deps/cowboy/src/cowboy_websocket.erl', line: 528]}, {:cowboy_http, :loop, 1, [file: '/build/deps/cowboy/src/cowboy_http.erl', line: 257]}, {:proc_lib, :init_p_do_apply, 3, [file: 'proc_lib.erl', line: 240]}]}
```

This error is not user friendly and it is not immediately obvious what the problem is.

Our application is occassionally hit by scraper/bots/scanners etc who try to send garbage to the websocket, and every time, it takes us some amount of digging before we remember that this is really just a malformed message.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 531f46a3d..b33adce0e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,17 @@ This feature replaces the `Helpers` module generated in your Phoenix router, but
 will continue to work and be generated. You can disable router helpers by passing the
 `helpers: false` option to `use Phoenix.Router`.
 
+## 1.7.0-rc.4
+
+### Bug Fixes
+  * Fix race conditions in the longpoll transport by batching messages
+
+## 1.7.0-rc.3 (2023-02-15)
+
+### Enhancements
+  * Use stream based collections for `phx.gen.live` generators
+  * Update `phx.gen.live` generators to use `Phoenix.Component.to_form`
+
 ## 1.7.0-rc.2 (2023-01-13)
 
 ### Bug Fixes
diff --git a/assets/js/phoenix/longpoll.js b/assets/js/phoenix/longpoll.js
index 18c15a2fc..ff3c57a9f 100644
--- a/assets/js/phoenix/longpoll.js
+++ b/assets/js/phoenix/longpoll.js
@@ -12,6 +12,10 @@ export default class LongPoll {
     this.token = null
     this.skipHeartbeat = true
     this.reqs = new Set()
+    this.awaitingBatchAck = false
+    this.currentBatch = null
+    this.currentBatchTimer = null
+    this.batchBuffer = []
     this.onopen = function (){ } // noop
     this.onerror = function (){ } // noop
     this.onmessage = function (){ } // noop
@@ -45,7 +49,7 @@ export default class LongPoll {
   isActive(){ return this.readyState === SOCKET_STATES.open || this.readyState === SOCKET_STATES.connecting }
 
   poll(){
-    this.ajax("GET", null, () => this.ontimeout(), resp => {
+    this.ajax("GET", "application/json", null, () => this.ontimeout(), resp => {
       if(resp){
         var {status, token, messages} = resp
         this.token = token
@@ -100,11 +104,33 @@ export default class LongPoll {
     })
   }
 
+  // we collect all pushes within the current event loop by
+  // setTimeout 0, which optimizes back-to-back procedural
+  // pushes against an empty buffer
   send(body){
-    this.ajax("POST", body, () => this.onerror("timeout"), resp => {
+    if(this.currentBatch){
+      this.currentBatch.push(body)
+    } else if(this.awaitingBatchAck){
+      this.batchBuffer.push(body)
+    } else {
+      this.currentBatch = [body]
+      this.currentBatchTimer = setTimeout(() => {
+        this.batchSend(this.currentBatch)
+        this.currentBatch = null
+      }, 0)
+    }
+  }
+
+  batchSend(messages){
+    this.awaitingBatchAck = true
+    this.ajax("POST", "application/x-ndjson", messages.join("\n"), () => this.onerror("timeout"), resp => {
+      this.awaitingBatchAck = false
       if(!resp || resp.status !== 200){
         this.onerror(resp && resp.status)
         this.closeAndRetry(1011, "internal server error", false)
+      } else if(this.batchBuffer.length > 0){
+        this.batchSend(this.batchBuffer)
+        this.batchBuffer = []
       }
     })
   }
@@ -113,6 +139,9 @@ export default class LongPoll {
     for(let req of this.reqs){ req.abort() }
     this.readyState = SOCKET_STATES.closed
     let opts = Object.assign({code: 1000, reason: undefined, wasClean: true}, {code, reason, wasClean})
+    this.batchBuffer = []
+    clearTimeout(this.currentBatchTimer)
+    this.currentBatchTimer = null
     if(typeof(CloseEvent) !== "undefined"){
       this.onclose(new CloseEvent("close", opts))
     } else {
@@ -120,13 +149,13 @@ export default class LongPoll {
     }
   }
 
-  ajax(method, body, onCallerTimeout, callback){
+  ajax(method, contentType, body, onCallerTimeout, callback){
     let req
     let ontimeout = () => {
       this.reqs.delete(req)
       onCallerTimeout()
     }
-    req = Ajax.request(method, this.endpointURL(), "application/json", body, this.timeout, ontimeout, resp => {
+    req = Ajax.request(method, this.endpointURL(), contentType, body, this.timeout, ontimeout, resp => {
       this.reqs.delete(req)
       if(this.isActive()){ callback(resp) }
     })
diff --git a/assets/package-lock.json b/assets/package-lock.json
index 553f86253..ff1a35d60 100644
--- a/assets/package-lock.json
+++ b/assets/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "phoenix",
-  "version": "1.7.0-rc.2",
+  "version": "1.7.0-rc.3",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "phoenix",
-      "version": "1.7.0-rc.2",
+      "version": "1.7.0-rc.3",
       "license": "MIT",
       "devDependencies": {
         "@babel/cli": "7.14.3",
diff --git a/assets/package.json b/assets/package.json
index 59377ecdd..473fa9272 100644
--- a/assets/package.json
+++ b/assets/package.json
@@ -1,6 +1,6 @@
 {
   "name": "phoenix",
-  "version": "1.7.0-rc.2",
+  "version": "1.7.0-rc.3",
   "description": "The official JavaScript client for the Phoenix web framework.",
   "license": "MIT",
   "main": "./assets/js/phoenix/index.js",
diff --git a/guides/authentication/mix_phx_gen_auth.md b/guides/authentication/mix_phx_gen_auth.md
index 7832be7e8..4597b8b87 100644
--- a/guides/authentication/mix_phx_gen_auth.md
+++ b/guides/authentication/mix_phx_gen_auth.md
@@ -10,9 +10,17 @@ Let's start by running the following command from the root of our app (or `apps/
 
 ```console
 $ mix phx.gen.auth Accounts User users
+
+An authentication system can be created in two different ways:
+- Using Phoenix.LiveView (default)
+- Using Phoenix.Controller only
+
+Do you want to create a LiveView based authentication system? [Y/n] Y
 ```
 
-This creates an `Accounts` context with an `Accounts.User` schema module. The final argument is the plural version of the schema module, which is used for generating database table names and route paths. The `mix phx.gen.auth` generator is similar to `mix phx.gen.html` except it does not accept a list of additional fields to add to the schema, and it generates many more context functions.
+The authentication generators support Phoenix LiveView, for enhanced UX, so we'll answer `Y` here. You may also answer `n` for a controller based authentication system.
+
+Either approach will create an `Accounts` context with an `Accounts.User` schema module. The final argument is the plural version of the schema module, which is used for generating database table names and route paths. The `mix phx.gen.auth` generator is similar to `mix phx.gen.html` except it does not accept a list of additional fields to add to the schema, and it generates many more context functions.
 
 Since this generator installed additional dependencies in `mix.exs`, let's fetch those:
 
diff --git a/guides/components.md b/guides/components.md
index 0e97d388c..af07635bc 100644
--- a/guides/components.md
+++ b/guides/components.md
@@ -4,9 +4,9 @@
 
 > **Requirement**: This guide expects that you have gone through the [request life-cycle guide](request_lifecycle.html).
 
-The Phoenix endpoint pipeline takes a request, routes it with a router to a controller, and calls a view module to render a template. The view interface from the controller is simple – the controller calls a view function with the connections assigns, and the functions job is to return a HEEx template. We call functions that accept assigns and return HEEx, *function components*, which are provided by the [`Phoenix.Component`](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) module.
+The Phoenix endpoint pipeline takes a request, routes it to a controller, and calls a view module to render a template. The view interface from the controller is simple – the controller calls a view function with the connections assigns, and the functions job is to return a HEEx template. We call any function that accepts an `assigns` parameter and returns a HEEx template to be a *function component*. Function components are defined with the help of the [`Phoenix.Component`](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) module.
 
-Function components are the essential building block for any kind of markup-based template rendering you'll perform in Phoenix. They served a shared abstraction for the standard MVC controller-based applications, LiveView applications, layouts, and smaller UI definition you'll use throughout other templates.
+Function components are the essential building block for any kind of markup-based template rendering you'll perform in Phoenix. They serve as a shared abstraction for the standard MVC controller-based applications, LiveView applications, layouts, and smaller UI definitions you'll use throughout other templates.
 
 In this chapter, we will recap how components were used in previous chapters and find new use cases for them.
 
@@ -34,7 +34,7 @@ That's simple enough. There's only two lines, `use HelloWeb, :html`. This line c
 
 All of the imports and aliases we make in our module will also be available in our templates. That's because templates are effectively compiled into functions inside their respective module. For example, if you define a function in your module, you will be able to invoke it directly from the template. Let's see this in practice.
 
-Imagine we want to refactor our `show.html.heex` to move the rendering of `<h2>Hello World, from <%= @messenger %>!</h2>` to its own function. We can move it to a function component inside `HelloHTML`:
+Imagine we want to refactor our `show.html.heex` to move the rendering of `<h2>Hello World, from <%= @messenger %>!</h2>` to its own function. We can move it to a function component inside `HelloHTML`, let's do so:
 
 ```elixir
 defmodule HelloWeb.HelloHTML do
@@ -52,7 +52,7 @@ defmodule HelloWeb.HelloHTML do
 end
 ```
 
-We declared the attributes we accept via `attr` provided by `Phoenix.Component`, then we defined our `title/1` function which returns the HEEx template. When we reload our home page, we should see our new title. Since templates are compiled inside the view, we can invoke the view function simply as `<.greet messenger="..." />`, but we can also type `<HelloWeb.HelloHTML.greet messenger="..." />` if the component was defined elsewhere.
+In the example above, we defined a `greet/1` function which returns the HEEx template. Above the function, we called `attr`, provided by `Phoenix.Component`, which defines the attributes/assigns that function expects. Since templates are embedded inside the `HelloHTML` module, we can invoke the our component simply as `<.greet messenger="..." />`, but we can also type `<HelloWeb.HelloHTML.greet messenger="..." />` if the component was defined elsewhere.
 
 By declaring attributes, Phoenix will warn if we call the `<.greet />` component without passing attributes. If an attribute is optional, you can specify the `:default` option with a value:
 
@@ -179,17 +179,17 @@ You may be wondering how the string resulting from a rendered view ends up insid
 <%= @inner_content %>
 ```
 
-In other words, the resulting of rendering your page is placed in the `@inner_content` assign.
+In other words, after rendering your page, the result is placed in the `@inner_content` assign.
 
 Phoenix provides all kinds of conveniences to control which layout should be rendered. For example, the `Phoenix.Controller` module provides the `put_root_layout/2` function for us to switch _root layouts_. This takes `conn` as its first argument and a keyword list of formats and their layouts. You can set it to `false` to disable the layout altogether.
 
-You can edit the `index` action of `PageController` in `lib/hello_web/controllers/page_controller.ex` to look like this.
+You can edit the `home` action of `PageController` in `lib/hello_web/controllers/page_controller.ex` to look like this.
 
 ```elixir
-def index(conn, _params) do
+def home(conn, _params) do
   conn
   |> put_root_layout(html: false)
-  |> render(:index)
+  |> render(:home)
 end
 ```
 
@@ -197,13 +197,13 @@ After reloading [http://localhost:4000/](http://localhost:4000/), we should see
 
 To customize the application layout, we invoke a similar function named `put_layout/2`. Let's actually create another layout and render the index template into it. As an example, let's say we had a different layout for the admin section of our application which didn't have the logo image. To do this, copy the existing `app.html.heex` to a new file `admin.html.heex` in the same directory `lib/hello_web/components/layouts`. Then remove everything inside the `<header>...</header>` tags (or change it to whatever you desire) in the new file.
 
-Now, in the `index` action of the controller of `lib/hello_web/controllers/page_controller.ex`, add the following:
+Now, in the `home` action of the controller of `lib/hello_web/controllers/page_controller.ex`, add the following:
 
 ```elixir
-def index(conn, _params) do
+def home(conn, _params) do
   conn
   |> put_layout(html: :admin)
-  |> render(:index)
+  |> render(:home)
 end
 ```
 
@@ -213,8 +213,6 @@ At this point, you may be wondering, why does Phoenix have two layouts?
 
 First of all, it gives us flexibility. In practice, we will hardly have multiple root layouts, as they often contain only HTML headers. This allows us to focus on different application layouts with only the parts that changes between them. Second of all, Phoenix ships with a feature called LiveView, which allows us to build rich and real-time user experiences with server-rendered HTML. LiveView is capable of dynamically changing the contents of the page, but it only ever changes the app layout, never the root layout. We will learn about LiveView in future guides.
 
-We'll cover function components and HEEx in detail in a moment, but first let's learn how templates are rendered from the endpoint pipeline.
-
 ## CoreComponents
 
 In a new Phoenix application, you will also find a `core_components.ex` module inside the `components` folder. This module is a great example of defining function components to be reused throughout our application. This guarantees that, as our application evolves, our components will look consistent.
diff --git a/guides/contexts.md b/guides/contexts.md
index 98529eed0..50c0cb49b 100644
--- a/guides/contexts.md
+++ b/guides/contexts.md
@@ -137,12 +137,12 @@ defmodule HelloWeb.ProductController do
 
   def index(conn, _params) do
     products = Catalog.list_products()
-    render(conn, "index.html", products: products)
+    render(conn, :index, products: products)
   end
 
   def new(conn, _params) do
     changeset = Catalog.change_product(%Product{})
-    render(conn, "new.html", changeset: changeset)
+    render(conn, :new, changeset: changeset)
   end
 
   def create(conn, %{"product" => product_params}) do
@@ -153,13 +153,13 @@ defmodule HelloWeb.ProductController do
         |> redirect(to: ~p"/products/#{product}")
 
       {:error, %Ecto.Changeset{} = changeset} ->
-        render(conn, "new.html", changeset: changeset)
+        render(conn, :new, changeset: changeset)
     end
   end
 
   def show(conn, %{"id" => id}) do
     product = Catalog.get_product!(id)
-    render(conn, "show.html", product: product)
+    render(conn, :show, product: product)
   end
   ...
 end
@@ -304,7 +304,7 @@ With our context function in place, let's make use of it in our product controll
       |> Catalog.get_product!()
       |> Catalog.inc_page_views()
 
-    render(conn, "show.html", product: product)
+    render(conn, :show, product: product)
   end
 ```
 
@@ -493,6 +493,8 @@ Next, let's expose our new feature to the web by adding the category input to ou
 defmodule HelloWeb.ProductHTML do
   use HelloWeb, :html
 
+  import Phoenix.HTML.Form
+
   def category_select(f, changeset) do
     existing_ids =
       changeset
@@ -508,7 +510,7 @@ defmodule HelloWeb.ProductHTML do
 end
 ```
 
-We added a new `category_select/2` function which uses `Phoenix.HTML`'s `multiple_select/3` to generate a multiple select tag. We calculated the existing category IDs from our changeset, then used those values when we generate the select options for the input tag. We did this by enumerating over all of our categories and returning the appropriate `key`, `value`, and `selected` values. We marked an option as selected if the category ID was found in those category IDs in our changeset.
+We added a new `category_select/2` function which uses `Phoenix.HTML.Form`'s `multiple_select/3` to generate a multiple select tag. We calculated the existing category IDs from our changeset, then used those values when we generate the select options for the input tag. We did this by enumerating over all of our categories and returning the appropriate `key`, `value`, and `selected` values. We marked an option as selected if the category ID was found in those category IDs in our changeset.
 
 With our `category_select` function in place, we can open up `lib/hello_web/controllers/product_html/form.html.heex` and add:
 
@@ -865,7 +867,7 @@ Next, we wrote our new `add_item_to_cart/2` function which accepts a cart struct
 
 Finally, we implemented `remove_item_from_cart/2` where we simply issue a `Repo.delete_all` call with a query to delete the cart item in our cart that matches the product ID. Finally, we reload the cart contents by calling `reload_cart/1`.
 
-With our new cart functions in place, we can now expose the "Add to cart" button on the product catalog show page. Open up your template in `lib/hello_web/templates/product/show.html.heex` and make the following changes:
+With our new cart functions in place, we can now expose the "Add to cart" button on the product catalog show page. Open up your template in `lib/hello_web/controllers/product_html/show.html.heex` and make the following changes:
 
 ```diff
 <h1>Show Product</h1>
@@ -911,7 +913,7 @@ defmodule HelloWeb.CartController do
   alias Hello.ShoppingCart
 
   def show(conn, _params) do
-    render(conn, "show.html", changeset: ShoppingCart.change_cart(conn.assigns.cart))
+    render(conn, :show, changeset: ShoppingCart.change_cart(conn.assigns.cart))
   end
 end
 ```
diff --git a/guides/introduction/community.md b/guides/introduction/community.md
index 10fef641f..1d47514b9 100644
--- a/guides/introduction/community.md
+++ b/guides/introduction/community.md
@@ -36,7 +36,7 @@ There are a number of places to connect with community members at all experience
 
 ## Screencasts/Courses
 
-  * [Phoenix LiveView Free Course (by The Pragmatic Studio - 2020)](https://pragmaticstudio.com/courses/phoenix-liveview)
+  * [Phoenix LiveView Free Course (by The Pragmatic Studio - 2023)](https://pragmaticstudio.com/courses/phoenix-liveview)
 
   * [Groxio LiveView: Self Study Program (by Bruce Tate - 2020)](https://grox.io/language/liveview/course)
 
diff --git a/installer/lib/phx_new/generator.ex b/installer/lib/phx_new/generator.ex
index 015f8c52e..64f201a7a 100644
--- a/installer/lib/phx_new/generator.ex
+++ b/installer/lib/phx_new/generator.ex
@@ -356,7 +356,8 @@ defmodule Phx.New.Generator do
           For example: ecto://USER:PASS@HOST/DATABASE
           \"""
 
-      maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: []
+      maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
+
       """,
       prod_config: """
       # ssl: true,
diff --git a/installer/mix.exs b/installer/mix.exs
index 2112b924c..880d9f045 100644
--- a/installer/mix.exs
+++ b/installer/mix.exs
@@ -6,7 +6,7 @@ end
 defmodule Phx.New.MixProject do
   use Mix.Project
 
-  @version "1.7.0-rc.2"
+  @version "1.7.0-rc.3"
   @scm_url "https://github.com/phoenixframework/phoenix"
 
   # If the elixir requirement is updated, we need to update:
diff --git a/installer/mix.lock b/installer/mix.lock
index 843b4accd..f534fd9d4 100644
--- a/installer/mix.lock
+++ b/installer/mix.lock
@@ -1,9 +1,9 @@
 %{
   "earmark": {:hex, :earmark, "1.4.10", "bddce5e8ea37712a5bfb01541be8ba57d3b171d3fa4f80a0be9bcf1db417bcaf", [:mix], [{:earmark_parser, ">= 1.4.10", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "12dbfa80810478e521d3ffb941ad9fbfcbbd7debe94e1341b4c4a1b2411c1c27"},
-  "earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"},
-  "ex_doc": {:hex, :ex_doc, "0.24.1", "15673de99154f93ca7f05900e4e4155ced1ee0cd34e0caeee567900a616871a4", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "07972f17bdf7dc7b5bd76ec97b556b26178ed3f056e7ec9288eb7cea7f91cce2"},
-  "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"},
-  "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"},
+  "earmark_parser": {:hex, :earmark_parser, "1.4.30", "0b938aa5b9bafd455056440cdaa2a79197ca5e693830b4a982beada840513c5f", [:mix], [], "hexpm", "3b5385c2d36b0473d0b206927b841343d25adb14f95f0110062506b300cd5a1b"},
+  "ex_doc": {:hex, :ex_doc, "0.29.1", "b1c652fa5f92ee9cf15c75271168027f92039b3877094290a75abcaac82a9f77", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "b7745fa6374a36daf484e2a2012274950e084815b936b1319aeebcf7809574f6"},
+  "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"},
+  "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"},
   "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"},
-  "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"},
+  "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"},
 }
diff --git a/installer/templates/phx_assets/app.js b/installer/templates/phx_assets/app.js
index ec7623990..4c1063740 100644
--- a/installer/templates/phx_assets/app.js
+++ b/installer/templates/phx_assets/app.js
@@ -27,7 +27,7 @@ import "phoenix_html"
 
 // Show progress bar on live navigation and form submits
 <%= @live_comment %>topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
-<%= @live_comment %>window.addEventListener("phx:page-loading-start", _info => topbar.show(200))
+<%= @live_comment %>window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
 <%= @live_comment %>window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
 
 // connect if there are any LiveViews on the page
diff --git a/installer/templates/phx_single/mix.exs b/installer/templates/phx_single/mix.exs
index 078dbab7c..0ccf76050 100644
--- a/installer/templates/phx_single/mix.exs
+++ b/installer/templates/phx_single/mix.exs
@@ -42,7 +42,7 @@ defmodule <%= @app_module %>.MixProject do
       {<%= inspect @adapter_app %>, ">= 0.0.0"},<% end %><%= if @html do %>
       {:phoenix_html, "~> 3.3"},
       {:phoenix_live_reload, "~> 1.2", only: :dev},
-      {:phoenix_live_view, "~> 0.18.12"},
+      {:phoenix_live_view, "~> 0.18.14"},
       {:heroicons, "~> 0.5"},
       {:floki, ">= 0.30.0", only: :test},<% end %><%= if @dashboard do %>
       {:phoenix_live_dashboard, "~> 0.7.2"},<% end %><%= if @assets do %>
diff --git a/installer/templates/phx_umbrella/apps/app_name_web/mix.exs b/installer/templates/phx_umbrella/apps/app_name_web/mix.exs
index bf1ef4728..196b3dabe 100644
--- a/installer/templates/phx_umbrella/apps/app_name_web/mix.exs
+++ b/installer/templates/phx_umbrella/apps/app_name_web/mix.exs
@@ -40,7 +40,7 @@ defmodule <%= @web_namespace %>.MixProject do
       {:phoenix_ecto, "~> 4.4"},<% end %><%= if @html do %>
       {:phoenix_html, "~> 3.3"},
       {:phoenix_live_reload, "~> 1.2", only: :dev},
-      {:phoenix_live_view, "~> 0.18.12"},
+      {:phoenix_live_view, "~> 0.18.14"},
       {:heroicons, "~> 0.5"},
       {:floki, ">= 0.30.0", only: :test},<% end %><%= if @dashboard do %>
       {:phoenix_live_dashboard, "~> 0.7.2"},<% end %><%= if @assets do %>
diff --git a/installer/templates/phx_web/components/core_components.ex b/installer/templates/phx_web/components/core_components.ex
index 87d272be9..4dfaaef1f 100644
--- a/installer/templates/phx_web/components/core_components.ex
+++ b/installer/templates/phx_web/components/core_components.ex
@@ -206,7 +206,7 @@ defmodule <%= @web_namespace %>.CoreComponents do
 
   ## Examples
 
-      <.simple_form phx-change="validate" phx-submit="save">
+      <.simple_form for={@form} phx-change="validate" phx-submit="save">
         <.input field={@form[:email]} label="Email"/>
         <.input field={@form[:username]} label="Username" />
         <:actions>
@@ -214,7 +214,7 @@ defmodule <%= @web_namespace %>.CoreComponents do
         </:actions>
       </.simple_form>
   """
-  attr :for, :any, default: nil, doc: "the datastructure for the form"
+  attr :for, :any, required: true, doc: "the datastructure for the form"
   attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
 
   attr :rest, :global,
diff --git a/lib/mix/tasks/phx.gen.release.ex b/lib/mix/tasks/phx.gen.release.ex
index e0cf66944..dc97c567e 100644
--- a/lib/mix/tasks/phx.gen.release.ex
+++ b/lib/mix/tasks/phx.gen.release.ex
@@ -113,7 +113,7 @@ defmodule Mix.Tasks.Phx.Gen.Release do
 
       Add the following to your config/runtime.exs:
 
-          maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: []
+          maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
 
           config :#{app}, #{app_namespace}.Repo,
             ...,
diff --git a/lib/phoenix/controller.ex b/lib/phoenix/controller.ex
index 802a2c915..2b15e8e44 100644
--- a/lib/phoenix/controller.ex
+++ b/lib/phoenix/controller.ex
@@ -690,7 +690,7 @@ defmodule Phoenix.Controller do
 
   Raises `Plug.Conn.AlreadySentError` if `conn` is already sent.
   """
-  @spec put_new_layout(Plug.Conn.t(), [{format :: atom, layout}]) :: Plug.Conn.t()
+  @spec put_new_layout(Plug.Conn.t(), [{format :: atom, layout}] | layout) :: Plug.Conn.t()
   def put_new_layout(%Plug.Conn{state: state} = conn, layout)
       when (is_tuple(layout) and tuple_size(layout) == 2) or is_list(layout) or layout == false do
     unless state in @unsent, do: raise(AlreadySentError)
diff --git a/lib/phoenix/socket.ex b/lib/phoenix/socket.ex
index 796a7cf74..7de5bfa47 100644
--- a/lib/phoenix/socket.ex
+++ b/lib/phoenix/socket.ex
@@ -636,9 +636,24 @@ defmodule Phoenix.Socket do
     handle_in(nil, message, new_state, new_socket)
   end
 
+  defp handle_in({pid, _ref, _status}, %{event: "phx_leave"} = msg, state, socket) do
+    %{topic: topic, join_ref: join_ref} = msg
+
+    case state.channels_inverse do
+      # we need to match on nil to handle v1 protocol
+      %{^pid => {^topic, existing_join_ref}} when existing_join_ref in [join_ref, nil] ->
+        send(pid, msg)
+        {:ok, {update_channel_status(state, pid, topic, :leaving), socket}}
+
+      # the client has raced a server close. No need to reply since we already sent close
+      %{^pid => {^topic, _old_join_ref}} ->
+        {:ok, {state, socket}}
+    end
+  end
+
   defp handle_in({pid, _ref, _status}, message, state, socket) do
     send(pid, message)
-    {:ok, {maybe_put_status(state, pid, message), socket}}
+    {:ok, {state, socket}}
   end
 
   defp handle_in(nil, %{event: "phx_leave", ref: ref, topic: topic, join_ref: join_ref}, state, socket) do
@@ -726,14 +741,6 @@ defmodule Phoenix.Socket do
     end
   end
 
-  defp maybe_put_status(state, pid, %{event: "phx_leave", topic: topic}) do
-    update_channel_status(state, pid, topic, :leaving)
-  end
-
-  defp maybe_put_status(state, _pid, %{} = _msg) do
-    state
-  end
-
   defp update_channel_status(state, pid, topic, status) do
     new_channels = Map.update!(state.channels, topic, fn {^pid, ref, _} -> {pid, ref, status} end)
     %{state | channels: new_channels}
diff --git a/lib/phoenix/socket/serializers/v1_json_serializer.ex b/lib/phoenix/socket/serializers/v1_json_serializer.ex
index 174f70c35..cd96fe8cc 100644
--- a/lib/phoenix/socket/serializers/v1_json_serializer.ex
+++ b/lib/phoenix/socket/serializers/v1_json_serializer.ex
@@ -28,9 +28,15 @@ defmodule Phoenix.Socket.V1.JSONSerializer do
 
   @impl true
   def decode!(message, _opts) do
-    message
-    |> Phoenix.json_library().decode!()
-    |> Phoenix.Socket.Message.from_map!()
+    payload = Phoenix.json_library().decode!(message)
+
+    case payload do
+      %{} ->
+        Phoenix.Socket.Message.from_map!(payload)
+
+      other ->
+        raise "V1 JSON Serializer expected a map, got #{inspect(other)}"
+    end
   end
 
   defp encode_v1_fields_only(%Message{} = msg) do
diff --git a/lib/phoenix/transports/long_poll.ex b/lib/phoenix/transports/long_poll.ex
index 135df8137..6628d6bbf 100644
--- a/lib/phoenix/transports/long_poll.ex
+++ b/lib/phoenix/transports/long_poll.ex
@@ -72,7 +72,21 @@ defmodule Phoenix.Transports.LongPoll do
   defp publish(conn, server_ref, endpoint, opts) do
     case read_body(conn, []) do
       {:ok, body, conn} ->
-        status = transport_dispatch(endpoint, server_ref, body, opts)
+        # we need to match on both v1 and v2 protocol, as well as wrap for backwards compat
+        batch =
+          case get_req_header(conn, "content-type") do
+            ["application/x-ndjson"] -> String.split(body, ["\n", "\r\n"])
+            _ -> [body]
+          end
+
+        {conn, status} =
+          Enum.reduce_while(batch, {conn, nil}, fn msg, {conn, _status} ->
+            case transport_dispatch(endpoint, server_ref, msg, opts) do
+              :ok -> {:cont, {conn, :ok}}
+              :request_timeout = timeout -> {:halt, {conn, timeout}}
+            end
+          end)
+
         conn |> put_status(status) |> status_json()
 
       _ ->
@@ -168,8 +182,12 @@ defmodule Phoenix.Transports.LongPoll do
 
   ## Helpers
 
-  defp server_ref(endpoint_id, id, pid, topic) do
-    if endpoint_id == id and Process.alive?(pid), do: pid, else: topic
+  defp server_ref(endpoint_id, id, pid, topic) when is_pid(pid) do
+    cond do
+      node(pid) in Node.list() -> pid
+      endpoint_id == id and Process.alive?(pid) -> pid
+      true -> topic
+    end
   end
 
   defp client_ref(topic) when is_binary(topic), do: topic
diff --git a/mix.exs b/mix.exs
index 6b9a37754..a9a880e2d 100644
--- a/mix.exs
+++ b/mix.exs
@@ -8,7 +8,7 @@ defmodule Phoenix.MixProject do
     end
   end
 
-  @version "1.7.0-rc.2"
+  @version "1.7.0-rc.3"
   @scm_url "https://github.com/phoenixframework/phoenix"
 
   # If the elixir requirement is updated, we need to make the installer
diff --git a/package.json b/package.json
index 86388e50b..e2a28c5aa 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "phoenix",
-  "version": "1.7.0-rc.2",
+  "version": "1.7.0-rc.3",
   "description": "The official JavaScript client for the Phoenix web framework.",
   "license": "MIT",
   "module": "./priv/static/phoenix.mjs",
diff --git a/priv/templates/phx.gen.auth/auth.ex b/priv/templates/phx.gen.auth/auth.ex
index 6dcf8ff68..9aa6a753e 100644
--- a/priv/templates/phx.gen.auth/auth.ex
+++ b/priv/templates/phx.gen.auth/auth.ex
@@ -173,15 +173,11 @@ defmodule <%= inspect auth_module %> do
   end
 
   defp mount_current_<%= schema.singular %>(session, socket) do
-    case session do
-      %{"<%= schema.singular %>_token" => <%= schema.singular %>_token} ->
-        Phoenix.Component.assign_new(socket, :current_<%= schema.singular %>, fn ->
-          <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(<%= schema.singular %>_token)
-        end)
-
-      %{} ->
-        Phoenix.Component.assign_new(socket, :current_<%= schema.singular %>, fn -> nil end)
-    end
+    Phoenix.Component.assign_new(socket, :current_<%= schema.singular %>, fn ->
+      if <%= schema.singular %>_token = session["<%= schema.singular %>_token"] do
+        <%= inspect context.alias %>.get_<%= schema.singular %>_by_session_token(<%= schema.singular %>_token)
+      end
+    end)
   end
 
   @doc """
diff --git a/priv/templates/phx.gen.auth/confirmation_edit.html.heex b/priv/templates/phx.gen.auth/confirmation_edit.html.heex
index 36e5923be..e52ec2680 100644
--- a/priv/templates/phx.gen.auth/confirmation_edit.html.heex
+++ b/priv/templates/phx.gen.auth/confirmation_edit.html.heex
@@ -1,7 +1,7 @@
 <div class="mx-auto max-w-sm">
   <.header class="text-center">Confirm account</.header>
 
-  <.simple_form :let={_f} as={:<%= schema.singular %>} action={~p"<%= schema.route_prefix %>/confirm/#{@token}"}>
+  <.simple_form for={@conn.params["<%= schema.singular %>"]} as={:<%= schema.singular %>} action={~p"<%= schema.route_prefix %>/confirm/#{@token}"}>
     <:actions>
       <.button class="w-full">Confirm my account</.button>
     </:actions>
diff --git a/priv/templates/phx.gen.auth/confirmation_instructions_live.ex b/priv/templates/phx.gen.auth/confirmation_instructions_live.ex
index 55da944bd..bef5fd6db 100644
--- a/priv/templates/phx.gen.auth/confirmation_instructions_live.ex
+++ b/priv/templates/phx.gen.auth/confirmation_instructions_live.ex
@@ -7,7 +7,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
     ~H"""
     <.header>Resend confirmation instructions</.header>
 
-    <.simple_form id="resend_confirmation_form" phx-submit="send_instructions">
+    <.simple_form for={@form} id="resend_confirmation_form" phx-submit="send_instructions">
       <.input field={@form[:email]} type="email" label="Email" required />
       <:actions>
         <.button phx-disable-with="Sending...">Resend confirmation instructions</.button>
diff --git a/priv/templates/phx.gen.auth/confirmation_live.ex b/priv/templates/phx.gen.auth/confirmation_live.ex
index c316a0cfd..eb6ff4879 100644
--- a/priv/templates/phx.gen.auth/confirmation_live.ex
+++ b/priv/templates/phx.gen.auth/confirmation_live.ex
@@ -8,7 +8,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
     <div class="mx-auto max-w-sm">
       <.header class="text-center">Confirm Account</.header>
 
-      <.simple_form id="confirmation_form" phx-submit="confirm_account">
+      <.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account">
         <.input field={@form[:token]} type="hidden" />
         <:actions>
           <.button phx-disable-with="Confirming..." class="w-full">Confirm my account</.button>
@@ -25,7 +25,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
   end
 
   def mount(%{"token" => token}, _session, socket) do
-    form = to_form(%{"token" => token}, as: "user")
+    form = to_form(%{"token" => token}, as: "<%= schema.singular %>")
     {:ok, assign(socket, form: form), temporary_assigns: [form: nil]}
   end
 
diff --git a/priv/templates/phx.gen.auth/confirmation_new.html.heex b/priv/templates/phx.gen.auth/confirmation_new.html.heex
index ca377cf27..85789ee72 100644
--- a/priv/templates/phx.gen.auth/confirmation_new.html.heex
+++ b/priv/templates/phx.gen.auth/confirmation_new.html.heex
@@ -1,6 +1,6 @@
 <.header>Resend confirmation instructions</.header>
 
-<.simple_form :let={f} as={:<%= schema.singular %>} action={~p"<%= schema.route_prefix %>/confirm"}>
+<.simple_form :let={f} for={@conn.params["<%= schema.singular %>"]} as={:<%= schema.singular %>} action={~p"<%= schema.route_prefix %>/confirm"}>
   <.input field={f[:email]} type="email" label="Email" required />
   <:actions>
     <.button>Resend confirmation instructions</.button>
diff --git a/priv/templates/phx.gen.auth/forgot_password_live.ex b/priv/templates/phx.gen.auth/forgot_password_live.ex
index 87b4102ed..862160186 100644
--- a/priv/templates/phx.gen.auth/forgot_password_live.ex
+++ b/priv/templates/phx.gen.auth/forgot_password_live.ex
@@ -11,7 +11,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
         <:subtitle>We'll send a password reset link to your inbox</:subtitle>
       </.header>
 
-      <.simple_form id="reset_password_form" phx-submit="send_email">
+      <.simple_form for={@form} id="reset_password_form" phx-submit="send_email">
         <.input field={@form[:email]} type="email" placeholder="Email" required />
         <:actions>
           <.button phx-disable-with="Sending..." class="w-full">
diff --git a/priv/templates/phx.gen.auth/login_live.ex b/priv/templates/phx.gen.auth/login_live.ex
index fed7d71a3..187ba426e 100644
--- a/priv/templates/phx.gen.auth/login_live.ex
+++ b/priv/templates/phx.gen.auth/login_live.ex
@@ -15,7 +15,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
         </:subtitle>
       </.header>
 
-      <.simple_form id="login_form" action={~p"<%= schema.route_prefix %>/log_in"} phx-update="ignore">
+      <.simple_form for={@form} id="login_form" action={~p"<%= schema.route_prefix %>/log_in"} phx-update="ignore">
         <.input field={@form[:email]} type="email" label="Email" required />
         <.input field={@form[:password]} type="password" label="Password" required />
 
diff --git a/priv/templates/phx.gen.auth/registration_live.ex b/priv/templates/phx.gen.auth/registration_live.ex
index f439e6a58..98aa6d1f3 100644
--- a/priv/templates/phx.gen.auth/registration_live.ex
+++ b/priv/templates/phx.gen.auth/registration_live.ex
@@ -19,6 +19,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
       </.header>
 
       <.simple_form
+        for={@form}
         id="registration_form"
         phx-submit="save"
         phx-change="validate"
diff --git a/priv/templates/phx.gen.auth/reset_password_live.ex b/priv/templates/phx.gen.auth/reset_password_live.ex
index f35fa7fec..84d1a3f0c 100644
--- a/priv/templates/phx.gen.auth/reset_password_live.ex
+++ b/priv/templates/phx.gen.auth/reset_password_live.ex
@@ -8,7 +8,12 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
     <div class="mx-auto max-w-sm">
       <.header class="text-center">Reset Password</.header>
 
-      <.simple_form id="reset_password_form" phx-submit="reset_password" phx-change="validate">
+      <.simple_form
+        for={@form}
+        id="reset_password_form"
+        phx-submit="reset_password"
+        phx-change="validate"
+      >
         <.error :if={@form.errors != []}>
           Oops, something went wrong! Please check the errors below.
         </.error>
diff --git a/priv/templates/phx.gen.auth/reset_password_new.html.heex b/priv/templates/phx.gen.auth/reset_password_new.html.heex
index 3f17fc684..ee1a2ff34 100644
--- a/priv/templates/phx.gen.auth/reset_password_new.html.heex
+++ b/priv/templates/phx.gen.auth/reset_password_new.html.heex
@@ -4,7 +4,7 @@
     <:subtitle>We'll send a password reset link to your inbox</:subtitle>
   </.header>
 
-  <.simple_form :let={f} as={:<%= schema.singular %>} action={~p"<%= schema.route_prefix %>/reset_password"}>
+  <.simple_form :let={f} for={@conn.params["<%= schema.singular %>"]} as={:<%= schema.singular %>} action={~p"<%= schema.route_prefix %>/reset_password"}>
     <.input field={f[:email]} type="email" placeholder="Email" required />
     <:actions>
       <.button phx-disable-with="Sending..." class="w-full">
diff --git a/priv/templates/phx.gen.auth/schema.ex b/priv/templates/phx.gen.auth/schema.ex
index 81a33e83a..2624e96f3 100644
--- a/priv/templates/phx.gen.auth/schema.ex
+++ b/priv/templates/phx.gen.auth/schema.ex
@@ -54,6 +54,7 @@ defmodule <%= inspect schema.module %> do
     changeset
     |> validate_required([:password])
     |> validate_length(:password, min: 12, max: 72)
+    # Examples of additional password validation:
     # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
     # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
     # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
@@ -68,6 +69,8 @@ defmodule <%= inspect schema.module %> do
       changeset<%= if hashing_library.name == :bcrypt do %>
       # If using Bcrypt, then further validate it is at most 72 bytes long
       |> validate_length(:password, max: 72, count: :bytes)<% end %>
+      # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
+      # would keep the database transaction open longer and hurt performance.
       |> put_change(:hashed_password, <%= inspect hashing_library.module %>.hash_pwd_salt(password))
       |> delete_change(:password)
     else
diff --git a/priv/templates/phx.gen.auth/session_new.html.heex b/priv/templates/phx.gen.auth/session_new.html.heex
index 98f6c5f4e..e2be9d413 100644
--- a/priv/templates/phx.gen.auth/session_new.html.heex
+++ b/priv/templates/phx.gen.auth/session_new.html.heex
@@ -10,7 +10,7 @@
     </:subtitle>
   </.header>
 
-  <.simple_form :let={f} for={@conn} action={~p"<%= schema.route_prefix %>/log_in"} as={:<%= schema.singular %>}>
+  <.simple_form :let={f} for={@conn.params["<%= schema.singular %>"]} as={:<%= schema.singular %>} action={~p"<%= schema.route_prefix %>/log_in"}>
     <.error :if={@error_message}><%%= @error_message %></.error>
 
     <.input field={f[:email]} type="email" label="Email" required />
diff --git a/priv/templates/phx.gen.auth/settings_live.ex b/priv/templates/phx.gen.auth/settings_live.ex
index 093f1b9a5..e59b7f67e 100644
--- a/priv/templates/phx.gen.auth/settings_live.ex
+++ b/priv/templates/phx.gen.auth/settings_live.ex
@@ -7,7 +7,12 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
     ~H"""
     <.header>Change Email</.header>
 
-    <.simple_form id="email_form" phx-submit="update_email" phx-change="validate_email">
+    <.simple_form
+      for={@email_form}
+      id="email_form"
+      phx-submit="update_email"
+      phx-change="validate_email"
+    >
       <.input field={@email_form[:email]} type="email" label="Email" required />
       <.input
         field={@email_form[:current_password]}
@@ -26,6 +31,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
     <.header>Change Password</.header>
 
     <.simple_form
+      for={@password_form}
       id="password_form"
       action={~p"<%= schema.route_prefix %>/log_in?_action=password_updated"}
       method="post"
@@ -35,7 +41,11 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
     >
       <.input field={@password_form[:email]} type="hidden" value={@current_email} />
       <.input field={@password_form[:password]} type="password" label="New password" required />
-      <.input field={@password_form[:password_confirmation]} type="password" label="Confirm new password" />
+      <.input
+        field={@password_form[:password_confirmation]}
+        type="password"
+        label="Confirm new password"
+      />
       <.input
         field={@password_form[:current_password]}
         name="current_password"
@@ -84,6 +94,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
 
   def handle_event("validate_email", params, socket) do
     %{"current_password" => password, "<%= schema.singular %>" => <%= schema.singular %>_params} = params
+
     email_form =
       socket.assigns.current_<%= schema.singular %>
       |> <%= inspect context.alias %>.change_<%= schema.singular %>_email(<%= schema.singular %>_params)
@@ -115,6 +126,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
 
   def handle_event("validate_password", params, socket) do
     %{"current_password" => password, "<%= schema.singular %>" => <%= schema.singular %>_params} = params
+
     password_form =
       socket.assigns.current_<%= schema.singular %>
       |> <%= inspect context.alias %>.change_<%= schema.singular %>_password(<%= schema.singular %>_params)
diff --git a/priv/templates/phx.gen.live/core_components.ex b/priv/templates/phx.gen.live/core_components.ex
index 87d272be9..4dfaaef1f 100644
--- a/priv/templates/phx.gen.live/core_components.ex
+++ b/priv/templates/phx.gen.live/core_components.ex
@@ -206,7 +206,7 @@ defmodule <%= @web_namespace %>.CoreComponents do
 
   ## Examples
 
-      <.simple_form phx-change="validate" phx-submit="save">
+      <.simple_form for={@form} phx-change="validate" phx-submit="save">
         <.input field={@form[:email]} label="Email"/>
         <.input field={@form[:username]} label="Username" />
         <:actions>
@@ -214,7 +214,7 @@ defmodule <%= @web_namespace %>.CoreComponents do
         </:actions>
       </.simple_form>
   """
-  attr :for, :any, default: nil, doc: "the datastructure for the form"
+  attr :for, :any, required: true, doc: "the datastructure for the form"
   attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
 
   attr :rest, :global,
diff --git a/priv/templates/phx.gen.live/form_component.ex b/priv/templates/phx.gen.live/form_component.ex
index c61d6f54e..52cf6e85b 100644
--- a/priv/templates/phx.gen.live/form_component.ex
+++ b/priv/templates/phx.gen.live/form_component.ex
@@ -13,6 +13,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
       </.header>
 
       <.simple_form
+        for={@form}
         id="<%= schema.singular %>-form"
         phx-target={@myself}
         phx-change="validate"


Test patch:
diff --git a/guides/testing/testing_channels.md b/guides/testing/testing_channels.md
index 6ab05c441..592b88b69 100644
--- a/guides/testing/testing_channels.md
+++ b/guides/testing/testing_channels.md
@@ -65,6 +65,7 @@ defmodule HelloWeb.ChannelCase do
   end
 
   setup _tags do
+    Hello.DataCase.setup_sandbox(tags)
     :ok
   end
 end
diff --git a/installer/test/phx_new_test.exs b/installer/test/phx_new_test.exs
index f73d11fe9..9600d3320 100644
--- a/installer/test/phx_new_test.exs
+++ b/installer/test/phx_new_test.exs
@@ -178,7 +178,10 @@ defmodule Mix.Tasks.Phx.NewTest do
 
       assert_file("phx_blog/config/runtime.exs", fn file ->
         assert file =~ config
-        assert file =~ ~S|maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: []|
+
+        assert file =~
+                 ~S|maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []|
+
         assert file =~ ~S|socket_options: maybe_ipv6|
 
         assert file =~ """
diff --git a/integration_test/mix.exs b/integration_test/mix.exs
index 2a5c44b40..2e53707aa 100644
--- a/integration_test/mix.exs
+++ b/integration_test/mix.exs
@@ -41,7 +41,7 @@ defmodule Phoenix.Integration.MixProject do
       {:tds, ">= 0.0.0"},
       {:ecto_sqlite3, ">= 0.0.0"},
       {:phoenix_html, "~> 3.3"},
-      {:phoenix_live_view, "~> 0.18.12"},
+      {:phoenix_live_view, "~> 0.18.14"},
       {:floki, ">= 0.30.0"},
       {:phoenix_live_reload, "~> 1.2"},
       {:phoenix_live_dashboard, "~> 0.7.2"},
diff --git a/integration_test/mix.lock b/integration_test/mix.lock
index d7e6c8cd1..ea9dcb21d 100644
--- a/integration_test/mix.lock
+++ b/integration_test/mix.lock
@@ -33,7 +33,7 @@
   "phoenix_html": {:hex, :phoenix_html, "3.3.0", "bf451c71ebdaac8d2f40d3b703435e819ccfbb9ff243140ca3bd10c155f134cc", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "272c5c1533499f0132309936c619186480bafcc2246588f99a69ce85095556ef"},
   "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.7.2", "97cc4ff2dba1ebe504db72cb45098cb8e91f11160528b980bd282cc45c73b29c", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.3", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "0e5fdf063c7a3b620c566a30fcf68b7ee02e5e46fe48ee46a6ec3ba382dc05b7"},
   "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.0", "4fe222c0be55fdc3f9c711e24955fc42a7cd9b7a2f5f406f2580a567c335a573", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "bebf0fc2d2113b61cb5968f585367234b7b4c21d963d691de7b4b2dc6cdaae6f"},
-  "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.12", "08356107d3d35eb3bbab4dfc7a7f8a51800eef9779956b573e11e36c5a389732", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc690050299611a56155c3b4c0d920ec229d00eaff6fe41145700c06acad32c1"},
+  "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.14", "349b491496baa964711bba676f542ba1cf379af28ed1a0b0e74d0448be888609", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4bee150229d0169baae3fb0dfa98fe2cebf46b4db478836b3a3352bdf042e18b"},
   "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"},
   "phoenix_template": {:hex, :phoenix_template, "1.0.1", "85f79e3ad1b0180abb43f9725973e3b8c2c3354a87245f91431eec60553ed3ef", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "157dc078f6226334c91cb32c1865bf3911686f8bcd6bcff86736f6253e6993ee"},
   "plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"},
diff --git a/test/mix/tasks/phx.gen.auth_test.exs b/test/mix/tasks/phx.gen.auth_test.exs
index 3d90b050d..ba8c7c2b3 100644
--- a/test/mix/tasks/phx.gen.auth_test.exs
+++ b/test/mix/tasks/phx.gen.auth_test.exs
@@ -525,7 +525,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do
 
       assert_file("lib/my_app_web/controllers/warehouse/user_confirmation_html/new.html.heex", fn file ->
         assert file =~
-                 ~S|<.simple_form :let={f} as={:user} action={~p"/warehouse/users/confirm"}>|
+                 ~S(<.simple_form :let={f} for={@conn.params["user"]} as={:user} action={~p"/warehouse/users/confirm"}>)
 
         assert file =~
                  ~S|<.link href={~p"/warehouse/users/register"}>Register</.link>|
@@ -599,7 +599,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do
         "lib/my_app_web/controllers/warehouse/user_reset_password_html/new.html.heex",
         fn file ->
           assert file =~
-                   ~S|<.simple_form :let={f} as={:user} action={~p"/warehouse/users/reset_password"}>|
+                   ~S(<.simple_form :let={f} for={@conn.params["user"]} as={:user} action={~p"/warehouse/users/reset_password"}>)
         end
       )
 
@@ -620,7 +620,7 @@ defmodule Mix.Tasks.Phx.Gen.AuthTest do
 
       assert_file("lib/my_app_web/controllers/warehouse/user_session_html/new.html.heex", fn file ->
         assert file =~
-                 ~S|<.simple_form :let={f} for={@conn} action={~p"/warehouse/users/log_in"} as={:user}>|
+                 ~S|<.simple_form :let={f} for={@conn.params["user"]} as={:user} action={~p"/warehouse/users/log_in"}>|
 
         assert file =~
                  ~S|<.link navigate={~p"/warehouse/users/register"}|
diff --git a/test/phoenix/integration/long_poll_channels_test.exs b/test/phoenix/integration/long_poll_channels_test.exs
index 5c1104460..92e5e35d2 100644
--- a/test/phoenix/integration/long_poll_channels_test.exs
+++ b/test/phoenix/integration/long_poll_channels_test.exs
@@ -158,8 +158,14 @@ defmodule Phoenix.Integration.LongPollChannelsTest do
   Returns a response with body decoded into JSON map.
   """
   def poll(method, path, vsn, params, json \\ nil, headers \\ %{}) do
-    serializer = serializer(vsn)
-    headers = Map.merge(%{"content-type" => "application/json"}, headers)
+    {serializer, json} = serializer(vsn, json)
+    headers =
+      if is_list(json) do
+        Map.merge(%{"content-type" => "application/x-ndjson"}, headers)
+      else
+        Map.merge(%{"content-type" => "application/json"}, headers)
+      end
+
     body = encode(serializer, json)
     query_string = params |> Map.put("vsn", vsn) |> URI.encode_query()
     url = "http://127.0.0.1:#{@port}#{path}/longpoll?" <> query_string
@@ -167,8 +173,14 @@ defmodule Phoenix.Integration.LongPollChannelsTest do
     decode_body(serializer, resp)
   end
 
-  defp serializer("2." <> _), do: V2.JSONSerializer
-  defp serializer(_), do: V1.JSONSerializer
+  defp serializer("2." <> _, json), do: {V2.JSONSerializer, json}
+  defp serializer(_, nil), do: {V1.JSONSerializer, nil}
+  defp serializer(_, batch) when is_list(batch) do
+    {V1.JSONSerializer, for(msg <- batch, do: Map.delete(msg, "join_ref"))}
+  end
+  defp serializer(_, %{} = json) do
+    {V1.JSONSerializer, json}
+  end
 
   defp decode_body(serializer, %{} = resp) do
     resp
@@ -182,13 +194,19 @@ defmodule Phoenix.Integration.LongPollChannelsTest do
 
   defp encode(_vsn, nil), do: ""
 
-  defp encode(V2.JSONSerializer, map) do
+  defp encode(V2.JSONSerializer = serializer, batch) when is_list(batch) do
+    batch
+    |> Enum.map(&encode(serializer, &1))
+    |> Enum.join("\n")
+  end
+
+  defp encode(V2.JSONSerializer, %{} = map) do
     Phoenix.json_library().encode!(
       [map["join_ref"], map["ref"], map["topic"], map["event"], map["payload"]]
     )
   end
 
-  defp encode(V1.JSONSerializer, map), do: Phoenix.json_library().encode!(map)
+  defp encode(V1.JSONSerializer, %{} = map), do: Phoenix.json_library().encode!(map)
 
   @doc """
   Joins a long poll socket.
@@ -199,9 +217,9 @@ defmodule Phoenix.Integration.LongPollChannelsTest do
   process. If the mode is pubsub, the session will use the
   pubsub system.
   """
-  def join(path, topic, vsn, mode \\ :local, payload \\ %{}, params \\ %{}, headers \\ %{})
+  def join(path, topic, vsn, join_ref, mode \\ :local, payload \\ %{}, params \\ %{}, headers \\ %{})
 
-  def join(path, topic, vsn, :local, payload, params, headers) do
+  def join(path, topic, vsn, join_ref, :local, payload, params, headers) do
     resp = poll :get, path, vsn, params, %{}, headers
     assert resp.body["token"]
     assert resp.body["status"] == 410
@@ -212,7 +230,7 @@ defmodule Phoenix.Integration.LongPollChannelsTest do
       "topic" => topic,
       "event" => "phx_join",
       "ref" => "1",
-      "join_ref" => "1",
+      "join_ref" => join_ref,
       "payload" => payload
     }, headers
 
@@ -220,8 +238,8 @@ defmodule Phoenix.Integration.LongPollChannelsTest do
     session
   end
 
-  def join(path, topic, vsn, :pubsub, payload, params, headers) do
-    session = join(path, topic, vsn, :local, payload, params, headers)
+  def join(path, topic, vsn, join_ref, :pubsub, payload, params, headers) do
+    session = join(path, topic, vsn, join_ref, :local, payload, params, headers)
 
     {:ok, {:v1, _id, pid, topic}} =
       Phoenix.Token.verify(Endpoint, Atom.to_string(__MODULE__), session["token"])
@@ -235,7 +253,7 @@ defmodule Phoenix.Integration.LongPollChannelsTest do
     @vsn "1.0.0"
 
     test "#{@mode}: joins and poll messages" do
-      session = join("/ws", "room:lobby", @vsn, @mode)
+      session = join("/ws", "room:lobby", @vsn, "1", @mode)
 
       # pull messages
       resp = poll(:get, "/ws", @vsn, session)
@@ -272,7 +290,7 @@ defmodule Phoenix.Integration.LongPollChannelsTest do
     end
 
     test "#{@mode}: transport x_headers are extracted to the socket connect_info" do
-      session = join("/ws/connect_info", "room:lobby", @vsn, @mode, %{}, %{}, %{"x-application" => "Phoenix"})
+      session = join("/ws/connect_info", "room:lobby", @vsn, "1", @mode, %{}, %{}, %{"x-application" => "Phoenix"})
 
       # pull messages
       resp = poll(:get, "/ws/connect_info", @vsn, session)
@@ -289,7 +307,7 @@ defmodule Phoenix.Integration.LongPollChannelsTest do
       ctx_headers =
         %{"traceparent" => "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
         "tracestate" => "congo=t61rcWkgMz"}
-      session = join("/ws/connect_info", "room:lobby", @vsn, @mode, %{}, %{}, ctx_headers)
+      session = join("/ws/connect_info", "room:lobby", @vsn, "1", @mode, %{}, %{}, ctx_headers)
 
       # pull messages
       resp = poll(:get, "/ws/connect_info", @vsn, session)
@@ -303,7 +321,7 @@ defmodule Phoenix.Integration.LongPollChannelsTest do
     end
 
     test "#{@mode}: transport peer_data is extracted to the socket connect_info" do
-      session = join("/ws/connect_info", "room:lobby", @vsn, @mode, %{}, %{}, %{"x-application" => "Phoenix"})
+      session = join("/ws/connect_info", "room:lobby", @vsn, "1", @mode, %{}, %{}, %{"x-application" => "Phoenix"})
 
       # pull messages
       resp = poll(:get, "/ws/connect_info", @vsn, session)
@@ -317,7 +335,7 @@ defmodule Phoenix.Integration.LongPollChannelsTest do
     end
 
     test "#{@mode}: transport uri is extracted to the socket connect_info" do
-      session = join("/ws/connect_info", "room:lobby", @vsn, @mode, %{}, %{}, %{"x-application" => "Phoenix"})
+      session = join("/ws/connect_info", "room:lobby", @vsn, "1", @mode, %{}, %{}, %{"x-application" => "Phoenix"})
 
       # pull messages
       resp = poll(:get, "/ws/connect_info", @vsn, session)
@@ -335,7 +353,7 @@ defmodule Phoenix.Integration.LongPollChannelsTest do
 
     test "#{@mode}: publishing events" do
       Phoenix.PubSub.subscribe(__MODULE__, "room:lobby")
-      session = join("/ws", "room:lobby", @vsn, @mode)
+      session = join("/ws", "room:lobby", @vsn, "1", @mode)
 
       # Publish successfully
       resp = poll :post, "/ws", @vsn, session, %{
@@ -383,8 +401,61 @@ defmodule Phoenix.Integration.LongPollChannelsTest do
       end
     end
 
+    test "#{@mode}: lonpoll publishing batch events on v2 protocol" do
+      vsn = "2.0.0"
+      Phoenix.PubSub.subscribe(__MODULE__, "room:lobby")
+      session = join("/ws", "room:lobby", vsn, "1", @mode)
+      # Publish successfully
+      resp =
+        poll(:post, "/ws", vsn, session, [
+          %{
+            "topic" => "room:lobby",
+            "event" => "new_msg",
+            "ref" => "2",
+            "join_ref" => "1",
+            "payload" => %{"body" => "hi1"}
+          },
+          %{
+            "topic" => "room:lobby",
+            "event" => "new_msg",
+            "ref" => "3",
+            "join_ref" => "1",
+            "payload" => %{"body" => "hi2"}
+          }
+        ])
+
+
+      assert resp.body["status"] == 200
+      assert_receive %Broadcast{event: "new_msg", payload: %{"body" => "hi1"}}
+      assert_receive %Broadcast{event: "new_msg", payload: %{"body" => "hi2"}}
+
+      # Get published message
+      resp = poll(:get, "/ws", vsn, session)
+      assert resp.body["status"] == 200
+
+      assert [
+               _phx_reply,
+               _user_entered,
+               _joined,
+               %Message{
+                 topic: "room:lobby",
+                 event: "new_msg",
+                 payload: %{"body" => "hi1", "transport" => ":longpoll"},
+                 ref: nil,
+                 join_ref: "1"
+               },
+               %Message{
+                 topic: "room:lobby",
+                 event: "new_msg",
+                 payload: %{"body" => "hi2", "transport" => ":longpoll"},
+                 ref: nil,
+                 join_ref: "1"
+               }
+             ] = resp.body["messages"]
+    end
+
     test "#{@mode}: shuts down after timeout" do
-      session = join("/ws", "room:lobby", @vsn, @mode)
+      session = join("/ws", "room:lobby", @vsn, "1", @mode)
 
       channel = Process.whereis(:"room:lobby")
       assert channel
@@ -421,13 +492,13 @@ defmodule Phoenix.Integration.LongPollChannelsTest do
 
       test "filter params on join" do
         log = capture_log fn ->
-          join("/ws", "room:lobby", @vsn, :local, %{"foo" => "bar", "password" => "shouldnotshow"}, %{"logging" => "enabled"})
+          join("/ws", "room:lobby", @vsn, @join_ref, :local, %{"foo" => "bar", "password" => "shouldnotshow"}, %{"logging" => "enabled"})
         end
         assert log =~ "Parameters: %{\"foo\" => \"bar\", \"password\" => \"[FILTERED]\"}"
       end
 
       test "sends phx_error if a channel server abnormally exits", %{topic: topic} do
-        session = join("/ws", topic, @vsn)
+        session = join("/ws", topic, @vsn, @join_ref)
 
         capture_log fn ->
           resp = poll :post, "/ws", @vsn, session, %{
@@ -449,20 +520,23 @@ defmodule Phoenix.Integration.LongPollChannelsTest do
           event: "phx_error",
           payload: %{},
           topic: topic,
-          ref: "1",
+          ref: @join_ref,
           join_ref: @join_ref
         }
       end
 
       test "sends phx_close if a channel server normally exits" do
-        session = join("/ws", "room:lobby", @vsn)
+        session = join("/ws", "room:lobby", @vsn, @join_ref)
+
+        resp =
+          poll :post, "/ws", @vsn, session, %{
+            "topic" => "room:lobby",
+            "event" => "phx_leave",
+            "join_ref" => @join_ref,
+            "ref" => "2",
+            "payload" => %{}
+          }
 
-        resp = poll :post, "/ws", @vsn, session, %{
-          "topic" => "room:lobby",
-          "event" => "phx_leave",
-          "ref" => "2",
-          "payload" => %{}
-        }
         assert resp.body["status"] == 200
         assert resp.status == 200
 
@@ -472,7 +546,7 @@ defmodule Phoenix.Integration.LongPollChannelsTest do
         assert phx_close == %Message{
           event: "phx_close",
           payload: %{},
-          ref: "1",
+          ref: @join_ref,
           join_ref: @join_ref,
           topic: "room:lobby"
         }
@@ -516,12 +590,13 @@ defmodule Phoenix.Integration.LongPollChannelsTest do
       end
 
       test "forces application/json content-type" do
-        session = join("/ws", "room:lobby", @vsn)
+        session = join("/ws", "room:lobby", @vsn, @join_ref)
 
         resp = poll :post, "/ws", @vsn, session, %{
           "topic" => "room:lobby",
           "event" => "phx_leave",
           "ref" => "2",
+          "join_ref" => @join_ref,
           "payload" => %{}
         }, %{"content-type" => ""}
         assert resp.body["status"] == 200
diff --git a/test/phoenix/socket/v1_json_serializer_test.exs b/test/phoenix/socket/v1_json_serializer_test.exs
index 9e25aabaf..7f0cfc9eb 100644
--- a/test/phoenix/socket/v1_json_serializer_test.exs
+++ b/test/phoenix/socket/v1_json_serializer_test.exs
@@ -6,6 +6,7 @@ defmodule Phoenix.Socket.V1.JSONSerializerTest do
   # v1 responses must not contain join_ref
   @serializer V1.JSONSerializer
   @v1_msg_json "{\"event\":\"e\",\"payload\":\"m\",\"ref\":null,\"topic\":\"t\"}"
+  @v1_bad_json "[null,null,\"t\",\"e\",{\"m\":1}]"
   @v1_reply_json "{\"event\":\"phx_reply\",\"payload\":{\"response\":null,\"status\":null},\"ref\":\"null\",\"topic\":\"t\"}"
   @v1_fastlane_json "{\"event\":\"e\",\"payload\":\"m\",\"ref\":null,\"topic\":\"t\"}"
 
@@ -33,7 +34,15 @@ defmodule Phoenix.Socket.V1.JSONSerializerTest do
 
   test "decode!/2 decodes `Phoenix.Socket.Message` from JSON" do
     assert %Message{topic: "t", event: "e", payload: "m"} ==
-      decode!(@serializer, @v1_msg_json, opcode: :text)
+             decode!(@serializer, @v1_msg_json, opcode: :text)
+  end
+
+  test "decode!/2 raise a PayloadFormatException if the JSON doesn't contain a map" do
+    assert_raise(
+      RuntimeError,
+      "V1 JSON Serializer expected a map, got [nil, nil, \"t\", \"e\", %{\"m\" => 1}]",
+      fn -> decode!(@serializer, @v1_bad_json, opcode: :text) end
+    )
   end
 
   test "fastlane!/1 encodes a broadcast into a message as JSON" do