From dcff1c781051c5db70bfda0ed44ce37236c22466 Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Sat, 10 Jan 2026 20:59:59 -0500 Subject: [PATCH 1/7] Initial implementation of ColocatedCSS --- lib/mix/tasks/compile/phoenix_live_view.ex | 4 +- lib/phoenix_live_view/colocated_css.ex | 237 ++++++++++++++++++ test/e2e/support/colocated_live.ex | 10 + test/e2e/test_helper.exs | 7 +- test/e2e/tests/colocated.spec.js | 10 + test/phoenix_live_view/colocated_css_test.exs | 79 ++++++ 6 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 lib/phoenix_live_view/colocated_css.ex create mode 100644 test/phoenix_live_view/colocated_css_test.exs diff --git a/lib/mix/tasks/compile/phoenix_live_view.ex b/lib/mix/tasks/compile/phoenix_live_view.ex index 957698d013..77de5b8137 100644 --- a/lib/mix/tasks/compile/phoenix_live_view.ex +++ b/lib/mix/tasks/compile/phoenix_live_view.ex @@ -2,8 +2,7 @@ defmodule Mix.Tasks.Compile.PhoenixLiveView do @moduledoc """ A LiveView compiler for HEEx macro components. - Right now, only `Phoenix.LiveView.ColocatedHook` and `Phoenix.LiveView.ColocatedJS` - are handled. + Right now, only `Phoenix.LiveView.ColocatedHook`, `Phoenix.LiveView.ColocatedJS`, and `Phoenix.LiveView.ColocatedCSS` are handled. You must add it to your `mix.exs` as: @@ -30,5 +29,6 @@ defmodule Mix.Tasks.Compile.PhoenixLiveView do defp compile do Phoenix.LiveView.ColocatedJS.compile() + Phoenix.LiveView.ColocatedCSS.compile() end end diff --git a/lib/phoenix_live_view/colocated_css.ex b/lib/phoenix_live_view/colocated_css.ex new file mode 100644 index 0000000000..21e196dc87 --- /dev/null +++ b/lib/phoenix_live_view/colocated_css.ex @@ -0,0 +1,237 @@ +defmodule Phoenix.LiveView.ColocatedCSS do + @moduledoc ~S''' + A special HEEx `:type` that extracts any CSS styles from a colocated + ` + ``` + + > #### A note on dependencies and umbrella projects {: .info} + > + > For each application that uses colocated CSS, a separate directory is created + > inside the `phoenix-colocated` folder. This allows to have clear separation between + > styles of dependencies, but also applications inside umbrella projects. + + ## Internals + + While compiling the template, colocated CSS is extracted into a special folder inside the + `Mix.Project.build_path()`, called `phoenix-colocated-css`. This is customizable, as we'll see below, + but it is important that it is a directory that is not tracked by version control, because the + components are the source of truth for the code. Also, the directory is shared between applications + (this also applies to applications in umbrella projects), so it should typically also be a shared + directory not specific to a single application. + + The colocated CSS directory follows this structure: + + ```text + _build/$MIX_ENV/phoenix-colocated-css/ + _build/$MIX_ENV/phoenix-colocated-css/my_app/ + _build/$MIX_ENV/phoenix-colocated-css/my_app/colocated.css + _build/$MIX_ENV/phoenix-colocated-css/my_app/MyAppWeb.DemoLive/line_HASH.css + _build/$MIX_ENV/phoenix-colocated-css/my_dependency/MyDependency.Module/line_HASH.css + ... + ``` + + Each application has its own folder. Inside, each module also gets its own folder, which allows + us to track and clean up outdated code. + + To use colocated CSS, your bundler needs to be configured to resolve the + `phoenix-colocated-css` folder. For new Phoenix applications, this configuration is already included + in the esbuild configuration inside `config.exs`: + + config :esbuild, + ... + my_app: [ + args: + ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.), + cd: Path.expand("../assets", __DIR__), + env: %{ + "NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()] + } + ] + + The important part here is the `NODE_PATH` environment variable, which tells esbuild to also look + for packages inside the `deps` folder, as well as the `Mix.Project.build_path()`, which resolves to + `_build/$MIX_ENV`. If you use a different bundler, you'll need to configure it accordingly. If it is not + possible to configure the `NODE_PATH`, you can also change the folder to which LiveView writes colocated + CSS by setting the `:target_directory` option in your `config.exs`: + + ```elixir + config :phoenix_live_view, :colocated_css, + target_directory: Path.expand("../assets/css/phoenix-colocated", __DIR__) + ``` + + To bundle and use colocated CSS with esbuild, you can import it like this in your `app.js` file: + + ```javascript + import "phoenix-colocated-css/my_app/colocated.css" + ``` + + Importing CSS in your `app.js` file will cause esbuild to generate a separate `app.css` file. + To load it, simply add a second `` to your `root.html.heex` file, like so: + + ```html + + ``` + + > #### Tip {: .info} + > + > If you remove or modify the contents of the `:target_directory` folder, you can use + > `mix clean --all` and `mix compile` to regenerate all colocated CSS. + + > #### Warning! {: .warning} + > + > LiveView assumes full ownership over the configured `:target_directory`. When + > compiling, it will **delete** any files and folders inside the `:target_directory`, + > that it does not associate with a colocated CSS file. + ''' + + @behaviour Phoenix.Component.MacroComponent + + alias Phoenix.Component.MacroComponent + + @impl true + def transform({"style", _attributes, [text_content], _tag_meta} = _ast, meta) do + validate_phx_version!() + + data = extract(text_content, meta) + + # we always drop colocated CSS from the rendered output + {:ok, "", data} + end + + def transform(_ast, _meta) do + raise ArgumentError, "ColocatedCSS can only be used on style tags" + end + + defp validate_phx_version! do + phoenix_version = to_string(Application.spec(:phoenix, :vsn)) + + if not Version.match?(phoenix_version, "~> 1.8.0-rc.4") do + # TODO: bump message to 1.8 once released to avoid confusion + raise ArgumentError, ~s|ColocatedCSS requires at least {:phoenix, "~> 1.8.0-rc.4"}| + end + end + + @doc false + def extract(text_content, meta) do + # _build/dev/phoenix-colocated-css/otp_app/MyApp.MyComponent/line_no.css + target_path = + target_dir() + |> Path.join(inspect(meta.env.module)) + + hashed_name = + text_content + |> then(&:crypto.hash(:md5, &1)) + |> Base.encode32(case: :lower, padding: false) + + filename = "#{meta.env.line}_#{hashed_name}.css" + + File.mkdir_p!(target_path) + File.write!(Path.join(target_path, filename), text_content) + + filename + end + + @doc false + def compile do + # this step runs after all modules have been compiled + # so we can write the final css manifest file and remove any + # outdated colocated css files + clear_manifest!() + files = clear_outdated_and_get_files!() + write_new_manifest!(files) + end + + defp clear_manifest! do + target_dir() + |> Path.join("*") + |> Path.wildcard() + |> Enum.filter(&File.regular?(&1)) + |> Enum.each(&File.rm!(&1)) + end + + defp clear_outdated_and_get_files! do + target_dir = target_dir() + modules = subdirectories(target_dir) + + Enum.flat_map(modules, fn module_folder -> + module = Module.concat([Path.basename(module_folder)]) + process_module(module_folder, module) + end) + end + + defp process_module(module_folder, module) do + with true <- Code.ensure_loaded?(module), + data when data != [] <- MacroComponent.get_data(module, __MODULE__) do + expected_files = data + files = File.ls!(module_folder) + + outdated_files = files -- expected_files + + for file <- outdated_files do + File.rm!(Path.join(module_folder, file)) + end + + Enum.map(data, fn filename -> + absolute_file_path = Path.join(module_folder, filename) + absolute_file_path + end) + else + _ -> + # either the module does not exist any more or + # does not have any colocated CSS + File.rm_rf!(module_folder) + [] + end + end + + defp write_new_manifest!(files) do + target_dir = target_dir() + manifest = Path.join(target_dir, "colocated.css") + + content = + if files == [] do + # Ensure that the directory exists to write + # an empty manifest file in the case that no colocated css + # files were generated (which would have already created + # the directory) + File.mkdir_p!(target_dir) + + "" + else + Enum.reduce(files, [], fn file, acc -> + line = ~s[@import "./#{Path.relative_to(file, target_dir)}";\n] + [acc | line] + end) + end + + File.write!(manifest, content) + end + + defp target_dir do + default = Path.join(Mix.Project.build_path(), "phoenix-colocated-css") + app = to_string(Mix.Project.config()[:app]) + + global_settings() + |> Keyword.get(:target_directory, default) + |> Path.join(app) + end + + defp global_settings do + Application.get_env(:phoenix_live_view, :colocated_css, []) + end + + defp subdirectories(path) do + Path.wildcard(Path.join(path, "*")) |> Enum.filter(&File.dir?(&1)) + end +end diff --git a/test/e2e/support/colocated_live.ex b/test/e2e/support/colocated_live.ex index 47b493bed7..8c0f8f9e91 100644 --- a/test/e2e/support/colocated_live.ex +++ b/test/e2e/support/colocated_live.ex @@ -69,6 +69,7 @@ defmodule Phoenix.LiveViewTest.E2E.ColocatedLive do // initialize js exec handler from colocated js colocated.js_exec(liveSocket) + {@inner_content} """ @@ -116,6 +117,15 @@ defmodule Phoenix.LiveViewTest.E2E.ColocatedLive do
+
+ + +
+
     defmodule SyntaxHighlight do
       @behaviour Phoenix.Component.MacroComponent
diff --git a/test/e2e/test_helper.exs b/test/e2e/test_helper.exs
index a282529bda..d52e102846 100644
--- a/test/e2e/test_helper.exs
+++ b/test/e2e/test_helper.exs
@@ -275,6 +275,10 @@ defmodule Phoenix.LiveViewTest.E2E.Endpoint do
     from: Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view"),
     at: "/assets/colocated"
 
+  plug Plug.Static,
+    from: Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view"),
+    at: "/assets/colocated_css"
+
   plug Plug.Static, from: System.tmp_dir!(), at: "/tmp"
 
   plug :health_check
@@ -314,8 +318,9 @@ end
 
 IO.puts("Starting e2e server on port #{Phoenix.LiveViewTest.E2E.Endpoint.config(:http)[:port]}")
 
-# we need to manually compile the colocated hooks / js
+# we need to manually compile the colocated hooks, js, and css
 Phoenix.LiveView.ColocatedJS.compile()
+Phoenix.LiveView.ColocatedCSS.compile()
 
 if not IEx.started?() do
   # when running the test server manually, we halt after
diff --git a/test/e2e/tests/colocated.spec.js b/test/e2e/tests/colocated.spec.js
index b0d6e1c672..0f476a88f8 100644
--- a/test/e2e/tests/colocated.spec.js
+++ b/test/e2e/tests/colocated.spec.js
@@ -30,6 +30,16 @@ test("colocated JS works", async ({ page }) => {
   await expect(page.locator("#hello")).toBeVisible();
 });
 
+test("colocated CSS works", async ({ page }) => {
+  await page.goto("/colocated");
+  await syncLV(page);
+
+  await expect(page.locator(".test-colocated-css")).toHaveCSS(
+    "background-color",
+    "rgb(102, 51, 153)",
+  );
+});
+
 test("custom macro component works (syntax highlighting)", async ({ page }) => {
   await page.goto("/colocated");
   await syncLV(page);
diff --git a/test/phoenix_live_view/colocated_css_test.exs b/test/phoenix_live_view/colocated_css_test.exs
new file mode 100644
index 0000000000..0da63d7af4
--- /dev/null
+++ b/test/phoenix_live_view/colocated_css_test.exs
@@ -0,0 +1,79 @@
+defmodule Phoenix.LiveView.ColocatedCSSTest do
+  # we set async: false because we call the colocated CSS compiler
+  # and it reads / writes to a shared folder
+  use ExUnit.Case, async: false
+
+  test "simple style is extracted and available under manifest import" do
+    defmodule TestComponent do
+      use Phoenix.Component
+      alias Phoenix.LiveView.ColocatedCSS, as: Colo
+
+      def fun(assigns) do
+        ~H"""
+        
+        """
+      end
+    end
+
+    assert module_folders =
+             File.ls!(
+               Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view")
+             )
+
+    assert folder =
+             Enum.find(module_folders, fn folder ->
+               folder =~ ~r/#{inspect(__MODULE__)}\.TestComponent$/
+             end)
+
+    assert [style] =
+             Path.wildcard(
+               Path.join(
+                 Mix.Project.build_path(),
+                 "phoenix-colocated-css/phoenix_live_view/#{folder}/*.css"
+               )
+             )
+
+    assert File.read!(style) == """
+
+             .sample-class {
+                 background-color: #FFFFFF;
+             }
+           """
+
+    # now write the manifest manually as we are in a test
+    Phoenix.LiveView.ColocatedCSS.compile()
+
+    assert manifest =
+             File.read!(
+               Path.join(
+                 Mix.Project.build_path(),
+                 "phoenix-colocated-css/phoenix_live_view/colocated.css"
+               )
+             )
+
+    path =
+      Path.relative_to(
+        style,
+        Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view/")
+      )
+
+    # style is in manifest
+    assert manifest =~ ~s[@import "./#{path}";\n]
+  after
+    :code.delete(__MODULE__.TestComponent)
+    :code.purge(__MODULE__.TestComponent)
+  end
+
+  test "writes empty colocated.css when no colocated styles exist" do
+    manifest =
+      Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view/colocated.css")
+
+    Phoenix.LiveView.ColocatedCSS.compile()
+    assert File.exists?(manifest)
+    assert File.read!(manifest) == ""
+  end
+end

From 499969b7ea8671fe713245564aeb474854317a78 Mon Sep 17 00:00:00 2001
From: David Green <134172184+green-david@users.noreply.github.com>
Date: Sun, 11 Jan 2026 17:12:51 -0500
Subject: [PATCH 2/7] Scope Colocated CSS by default

---
 lib/phoenix_component/macro_component.ex      |  2 +-
 lib/phoenix_live_view/colocated_css.ex        | 58 ++++++++++++-
 lib/phoenix_live_view/tag_engine.ex           | 44 +++++++---
 test/e2e/support/colocated_live.ex            | 19 ++++-
 test/e2e/tests/colocated.spec.js              | 28 ++++++-
 test/phoenix_live_view/colocated_css_test.exs | 83 ++++++++++++++++---
 6 files changed, 205 insertions(+), 29 deletions(-)

diff --git a/lib/phoenix_component/macro_component.ex b/lib/phoenix_component/macro_component.ex
index 5c93cb00c8..9cad4ceda7 100644
--- a/lib/phoenix_component/macro_component.ex
+++ b/lib/phoenix_component/macro_component.ex
@@ -127,7 +127,7 @@ defmodule Phoenix.Component.MacroComponent do
   @type children :: [heex_ast()]
   @type tag_meta :: %{closing: :self | :void}
   @type heex_ast :: {tag(), attributes(), children(), tag_meta()} | binary()
-  @type transform_meta :: %{env: Macro.Env.t()}
+  @type transform_meta :: %{scope: String.t(), env: Macro.Env.t()}
 
   @callback transform(heex_ast :: heex_ast(), meta :: transform_meta()) ::
               {:ok, heex_ast()} | {:ok, heex_ast(), data :: term()}
diff --git a/lib/phoenix_live_view/colocated_css.ex b/lib/phoenix_live_view/colocated_css.ex
index 21e196dc87..5206f327af 100644
--- a/lib/phoenix_live_view/colocated_css.ex
+++ b/lib/phoenix_live_view/colocated_css.ex
@@ -21,6 +21,41 @@ defmodule Phoenix.LiveView.ColocatedCSS do
   > inside the `phoenix-colocated` folder. This allows to have clear separation between
   > styles of dependencies, but also applications inside umbrella projects.
 
+  ## Scoped CSS
+
+  By default, Colocated CSS styles are scoped at compile time to the template in which they are defined.
+  This provides style encapsulation preventing CSS rules within a component from unintentionally applying
+  to elements in other nested components. Scoping is performed via the use of the `@scope` CSS at-rule.
+  For more information, see [the docs on MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope).
+
+  To prevent Colocated CSS styles from being scoped to the current template you can provide the `global`
+  attribute, for example:
+
+  ```heex
+  
+  ```
+
+  > #### Warning! {: .warning}
+  >
+  > The `@scope` CSS at-rule is Baseline available as of the end of 2025. To ensure that Scoped CSS will
+  > work on the browsers you need, be sure to check [Can I Use?](https://caniuse.com/css-cascade-scope) for
+  > browser compatibility.
+
+  > #### Tip {: .info}
+  >
+  > When Colocated CSS is scoped via the `@scope` rule, the scoping root is set to the outermost elements
+  > of the given template. For selectors in your Colocated CSS to target the scoping root, you will need to
+  > specify the scoping root in the selector via the use of the `:scope` pseudo-selector. You can also use
+  > `:where(:scope)` instead to not increase specificity.
+  >
+  > For example, the use of the `.sample-class` selector in Scoped Colocated CSS will only style descendant
+  > elements with the `.sample-class` class, not the scoping root elements. You can use multiple selectors such as
+  > `.sample_class, .sample-class:where(:scope)` to target all elements with the `.sample-class` class in the template.
+
   ## Internals
 
   While compiling the template, colocated CSS is extracted into a special folder inside the
@@ -93,6 +128,15 @@ defmodule Phoenix.LiveView.ColocatedCSS do
   > LiveView assumes full ownership over the configured `:target_directory`. When
   > compiling, it will **delete** any files and folders inside the `:target_directory`,
   > that it does not associate with a colocated CSS file.
+
+  ## Options
+
+  Colocated CSS can be configured through the attributes of the `
 
+    <.scoped_css_component />
+
     
@@ -169,6 +171,17 @@ defmodule Phoenix.LiveViewTest.E2E.ColocatedLive do
     push_event(socket, "js:exec", %{cmd: Phoenix.json_library().encode!(ops)})
   end
 
+  defp scoped_css_component(assigns) do
+    ~H"""
+    
+    
Scoped CSS Div
+ """ + end + defp lv_code_sample(assigns) do ~H'''
diff --git a/test/e2e/tests/colocated.spec.js b/test/e2e/tests/colocated.spec.js
index 0f476a88f8..5e82c27ba6 100644
--- a/test/e2e/tests/colocated.spec.js
+++ b/test/e2e/tests/colocated.spec.js
@@ -30,14 +30,38 @@ test("colocated JS works", async ({ page }) => {
   await expect(page.locator("#hello")).toBeVisible();
 });
 
-test("colocated CSS works", async ({ page }) => {
+test("global colocated CSS works", async ({ page }) => {
   await page.goto("/colocated");
   await syncLV(page);
 
-  await expect(page.locator(".test-colocated-css")).toHaveCSS(
+  // the colocated CSS should apply to both elements regardless of the fact
+  // that they are not in the sample template
+  await expect(page.locator(".test-in-page.test-colocated-css")).toHaveCSS(
     "background-color",
     "rgb(102, 51, 153)",
   );
+
+  await expect(page.locator(".test-in-component.test-colocated-css")).toHaveCSS(
+    "background-color",
+    "rgb(102, 51, 153)",
+  );
+});
+
+test("scoped colocated CSS works", async ({ page }) => {
+  await page.goto("/colocated");
+  await syncLV(page);
+
+  // the colocated CSS should only to the element in the component it is
+  // scoped to
+  await expect(page.locator(".test-in-page.test-colocated-css")).not.toHaveCSS(
+    "width",
+    "175px",
+  );
+
+  await expect(page.locator(".test-in-component.test-colocated-css")).toHaveCSS(
+    "width",
+    "175px",
+  );
 });
 
 test("custom macro component works (syntax highlighting)", async ({ page }) => {
diff --git a/test/phoenix_live_view/colocated_css_test.exs b/test/phoenix_live_view/colocated_css_test.exs
index 0da63d7af4..d3a88e6ced 100644
--- a/test/phoenix_live_view/colocated_css_test.exs
+++ b/test/phoenix_live_view/colocated_css_test.exs
@@ -3,8 +3,68 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do
   # and it reads / writes to a shared folder
   use ExUnit.Case, async: false
 
-  test "simple style is extracted and available under manifest import" do
-    defmodule TestComponent do
+  test "simple global style is extracted and available under manifest import" do
+    defmodule TestGlobalComponent do
+      use Phoenix.Component
+      alias Phoenix.LiveView.ColocatedCSS, as: Colo
+
+      def fun(assigns) do
+        ~H"""
+        
+        """
+      end
+    end
+
+    assert module_folders =
+             File.ls!(
+               Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view")
+             )
+
+    assert folder =
+             Enum.find(module_folders, fn folder ->
+               folder =~ ~r/#{inspect(__MODULE__)}\.TestGlobalComponent$/
+             end)
+
+    assert [style] =
+             Path.wildcard(
+               Path.join(
+                 Mix.Project.build_path(),
+                 "phoenix-colocated-css/phoenix_live_view/#{folder}/*.css"
+               )
+             )
+
+    assert File.read!(style) == "\n  .sample-class {\n      background-color: #FFFFFF;\n  }\n"
+
+    # now write the manifest manually as we are in a test
+    Phoenix.LiveView.ColocatedCSS.compile()
+
+    assert manifest =
+             File.read!(
+               Path.join(
+                 Mix.Project.build_path(),
+                 "phoenix-colocated-css/phoenix_live_view/colocated.css"
+               )
+             )
+
+    path =
+      Path.relative_to(
+        style,
+        Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view/")
+      )
+
+    # style is in manifest
+    assert manifest =~ ~s[@import "./#{path}";\n]
+  after
+    :code.delete(__MODULE__.TestGlobalComponent)
+    :code.purge(__MODULE__.TestGlobalComponent)
+  end
+
+  test "simple scoped style is extracted and available under manifest import" do
+    defmodule TestScopedComponent do
       use Phoenix.Component
       alias Phoenix.LiveView.ColocatedCSS, as: Colo
 
@@ -26,7 +86,7 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do
 
     assert folder =
              Enum.find(module_folders, fn folder ->
-               folder =~ ~r/#{inspect(__MODULE__)}\.TestComponent$/
+               folder =~ ~r/#{inspect(__MODULE__)}\.TestScopedComponent$/
              end)
 
     assert [style] =
@@ -37,12 +97,15 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do
                )
              )
 
-    assert File.read!(style) == """
+    file_contents = File.read!(style)
+
+    file_contents =
+      Regex.replace(~r/data-phx-css=".+"/, file_contents, "data-phx-css=\"SCOPE_HERE\"")
 
-             .sample-class {
-                 background-color: #FFFFFF;
-             }
-           """
+    # The scope is a generated value, so for testing reliability we just replace it with a known
+    # value to assert against.
+    assert file_contents ==
+             "@scope ([data-phx-css=\"SCOPE_HERE\"]) to ([data-phx-css]) { \n  .sample-class {\n      background-color: #FFFFFF;\n  }\n  }"
 
     # now write the manifest manually as we are in a test
     Phoenix.LiveView.ColocatedCSS.compile()
@@ -64,8 +127,8 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do
     # style is in manifest
     assert manifest =~ ~s[@import "./#{path}";\n]
   after
-    :code.delete(__MODULE__.TestComponent)
-    :code.purge(__MODULE__.TestComponent)
+    :code.delete(__MODULE__.TestScopedComponent)
+    :code.purge(__MODULE__.TestScopedComponent)
   end
 
   test "writes empty colocated.css when no colocated styles exist" do

From e8f868a8084d4fd9d133a7c986c76937fbe1977a Mon Sep 17 00:00:00 2001
From: David Green <134172184+green-david@users.noreply.github.com>
Date: Sun, 11 Jan 2026 17:13:19 -0500
Subject: [PATCH 3/7] Upgrade playwright to get newer browser versions for
 @scope

Note: The version of Firefox used by playwright is still to old to
support @scope
---
 package-lock.json | 24 ++++++++++++------------
 package.json      |  2 +-
 2 files changed, 13 insertions(+), 13 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 71848c55b8..0efdd6f888 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,7 +17,7 @@
         "@babel/preset-env": "7.27.2",
         "@babel/preset-typescript": "^7.27.1",
         "@eslint/js": "^9.29.0",
-        "@playwright/test": "^1.56.1",
+        "@playwright/test": "^1.57.0",
         "@types/jest": "^30.0.0",
         "@types/phoenix": "^1.6.6",
         "css.escape": "^1.5.1",
@@ -2966,13 +2966,13 @@
       }
     },
     "node_modules/@playwright/test": {
-      "version": "1.56.1",
-      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
-      "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
+      "version": "1.57.0",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
+      "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
-        "playwright": "1.56.1"
+        "playwright": "1.57.0"
       },
       "bin": {
         "playwright": "cli.js"
@@ -7156,13 +7156,13 @@
       }
     },
     "node_modules/playwright": {
-      "version": "1.56.1",
-      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
-      "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
+      "version": "1.57.0",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
+      "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
-        "playwright-core": "1.56.1"
+        "playwright-core": "1.57.0"
       },
       "bin": {
         "playwright": "cli.js"
@@ -7175,9 +7175,9 @@
       }
     },
     "node_modules/playwright-core": {
-      "version": "1.56.1",
-      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
-      "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
+      "version": "1.57.0",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
+      "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
       "dev": true,
       "license": "Apache-2.0",
       "bin": {
diff --git a/package.json b/package.json
index ad1f7d15dc..dcb94a9439 100644
--- a/package.json
+++ b/package.json
@@ -37,7 +37,7 @@
     "@babel/preset-env": "7.27.2",
     "@babel/preset-typescript": "^7.27.1",
     "@eslint/js": "^9.29.0",
-    "@playwright/test": "^1.56.1",
+    "@playwright/test": "^1.57.0",
     "@types/jest": "^30.0.0",
     "@types/phoenix": "^1.6.6",
     "css.escape": "^1.5.1",

From 52d7caf8c8cc664bce979c81f9e18a65915009db Mon Sep 17 00:00:00 2001
From: David Green <134172184+green-david@users.noreply.github.com>
Date: Mon, 12 Jan 2026 21:17:43 -0500
Subject: [PATCH 4/7] Update docs

---
 lib/phoenix_live_view/colocated_css.ex | 38 ++++++++++++++++++++------
 1 file changed, 29 insertions(+), 9 deletions(-)

diff --git a/lib/phoenix_live_view/colocated_css.ex b/lib/phoenix_live_view/colocated_css.ex
index 5206f327af..99948d87c3 100644
--- a/lib/phoenix_live_view/colocated_css.ex
+++ b/lib/phoenix_live_view/colocated_css.ex
@@ -18,7 +18,7 @@ defmodule Phoenix.LiveView.ColocatedCSS do
   > #### A note on dependencies and umbrella projects {: .info}
   >
   > For each application that uses colocated CSS, a separate directory is created
-  > inside the `phoenix-colocated` folder. This allows to have clear separation between
+  > inside the `phoenix-colocated-css` folder. This allows to have clear separation between
   > styles of dependencies, but also applications inside umbrella projects.
 
   ## Scoped CSS
@@ -32,13 +32,37 @@ defmodule Phoenix.LiveView.ColocatedCSS do
   attribute, for example:
 
   ```heex
-  
   ```
 
+  **Note:** When using Scoped Colocated CSS with implicit `inner_block` slots or named slots, the content
+  provided will be scoped to the parent template which is providing the content, not the component which
+  defines the slot. For example, in the following snippet the elements within [`intersperse/1`](`Phoenix.Component.intersperse/1`)'s
+  `inner_block` and `separator` slots will both be styled by the `.sample-class` rule, not any rules defined within the
+  [`intersperse/1`](`Phoenix.Component.intersperse/1`) component itself:
+
+  ```heex
+  
+  
+ <.intersperse :let={item} enum={[1, 2, 3]}> + <:separator> + | + +
+

Item {item}

+
+ +
+ ``` + > #### Warning! {: .warning} > > The `@scope` CSS at-rule is Baseline available as of the end of 2025. To ensure that Scoped CSS will @@ -49,12 +73,8 @@ defmodule Phoenix.LiveView.ColocatedCSS do > > When Colocated CSS is scoped via the `@scope` rule, the scoping root is set to the outermost elements > of the given template. For selectors in your Colocated CSS to target the scoping root, you will need to - > specify the scoping root in the selector via the use of the `:scope` pseudo-selector. You can also use - > `:where(:scope)` instead to not increase specificity. - > - > For example, the use of the `.sample-class` selector in Scoped Colocated CSS will only style descendant - > elements with the `.sample-class` class, not the scoping root elements. You can use multiple selectors such as - > `.sample_class, .sample-class:where(:scope)` to target all elements with the `.sample-class` class in the template. + > specify the scoping root in the selector via the use of the `:scope` pseudo-selector. For more details, + > see [the docs on MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope#scope_pseudo-class_within_scope_blocks). ## Internals @@ -102,7 +122,7 @@ defmodule Phoenix.LiveView.ColocatedCSS do ```elixir config :phoenix_live_view, :colocated_css, - target_directory: Path.expand("../assets/css/phoenix-colocated", __DIR__) + target_directory: Path.expand("../assets/css/phoenix-colocated-css", __DIR__) ``` To bundle and use colocated CSS with esbuild, you can import it like this in your `app.js` file: From 5494fe7c5bf195a8687b2ba367789dd118fc94cc Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Mon, 12 Jan 2026 21:19:40 -0500 Subject: [PATCH 5/7] Fix existing tests which don't expect the data-phx-css attribute --- config/test.exs | 5 +++ lib/phoenix_live_view/tag_engine.ex | 57 ++++++++++++++++------------- 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/config/test.exs b/config/test.exs index 48793de45e..ad76167336 100644 --- a/config/test.exs +++ b/config/test.exs @@ -4,3 +4,8 @@ config :logger, :level, :debug config :logger, :default_handler, false config :phoenix_live_view, enable_expensive_runtime_checks: true + +# Disable applying the data-phx-css attribute so that tests that check +# against rendered output that are completely irrelevant to the data-phx-css +# attribute are not polluted by it. +config :phoenix_live_view, apply_css_scope_attribute: false diff --git a/lib/phoenix_live_view/tag_engine.ex b/lib/phoenix_live_view/tag_engine.ex index 923de234ab..bb21471be1 100644 --- a/lib/phoenix_live_view/tag_engine.ex +++ b/lib/phoenix_live_view/tag_engine.ex @@ -215,16 +215,11 @@ defmodule Phoenix.LiveView.TagEngine do def handle_body(state) do %{tokens: tokens, file: file, cont: cont, source: source, caller: caller} = state - hash = - :md5 - |> :crypto.hash(source) - |> Base.encode32(case: :lower, padding: false) - tokens = Tokenizer.finalize(tokens, file, cont, source) token_state = state - |> token_state(nil, hash) + |> token_state(nil, generate_scope(source)) |> continue(tokens) |> validate_unclosed_tags!("template") @@ -245,6 +240,13 @@ defmodule Phoenix.LiveView.TagEngine do end end + @doc false + def generate_scope(source) do + :md5 + |> :crypto.hash(source) + |> Base.encode32(case: :lower, padding: false) + end + defp has_tags?([{:text, _, _} | tokens]), do: has_tags?(tokens) defp has_tags?([{:expr, _, _} | tokens]), do: has_tags?(tokens) defp has_tags?([{:body_expr, _, _} | tokens]), do: has_tags?(tokens) @@ -1064,26 +1066,10 @@ defmodule Phoenix.LiveView.TagEngine do ## handle_tag_and_attrs defp handle_tag_and_attrs(state, name, attrs, suffix, meta, previous_tag) do - scope_root? = - case previous_tag do - {:macro_tag, _tag} -> false - {:tag, _name, _attrs, _meta} -> false - _tag -> true - end - text = - if Application.get_env(:phoenix_live_view, :debug_attributes, false) do - "<#{name} data-phx-loc=\"#{meta[:line]}\"" - else - "<#{name}" - end - - text = - if state.scope && scope_root? do - "#{text} data-phx-css=\"#{state.scope}\"" - else - text - end + name + |> maybe_add_debug_loc_text(meta) + |> maybe_add_scope_text(state.scope, previous_tag) state |> update_subengine(:handle_text, [meta, text]) @@ -1091,6 +1077,27 @@ defmodule Phoenix.LiveView.TagEngine do |> update_subengine(:handle_text, [meta, suffix]) end + defp maybe_add_debug_loc_text(text, meta) do + if Application.get_env(:phoenix_live_view, :debug_attributes, false) do + "<#{text} data-phx-loc=\"#{meta[:line]}\"" + else + "<#{text}" + end + end + + defp maybe_add_scope_text(text, scope, previous_tag) do + scope_root? = not match?({:tag, _, _, _}, previous_tag) + + apply_css_scope_attribute? = + Application.get_env(:phoenix_live_view, :apply_css_scope_attribute, true) + + if scope && apply_css_scope_attribute? && scope_root? do + "#{text} data-phx-css=\"#{scope}\"" + else + text + end + end + defp handle_tag_attrs(state, meta, attrs) do Enum.reduce(attrs, state, fn {:root, {:expr, _, _} = expr, _attr_meta}, state -> From 298bed85b632056f3c6c310bf14787917c0087c4 Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Mon, 12 Jan 2026 21:36:49 -0500 Subject: [PATCH 6/7] Add tests for applying the data-phx-css attribute for ColocatedCSS scoping --- test/phoenix_live_view/html_engine_test.exs | 435 ++++++++++++++++++++ 1 file changed, 435 insertions(+) diff --git a/test/phoenix_live_view/html_engine_test.exs b/test/phoenix_live_view/html_engine_test.exs index ce9bbbf50a..def553282c 100644 --- a/test/phoenix_live_view/html_engine_test.exs +++ b/test/phoenix_live_view/html_engine_test.exs @@ -1,4 +1,6 @@ defmodule Phoenix.LiveView.HTMLEngineTest do + # async: false due to manipulation of the Application + # env for :apply_css_scope_attribute use ExUnit.Case, async: true import ExUnit.CaptureIO @@ -2290,4 +2292,437 @@ defmodule Phoenix.LiveView.HTMLEngineTest do assert meta[:column] == 10 end end + + describe "data-phx-css attribute" do + test "is correctly applied to a single self-closing tag" do + enable_apply_css_scope_attribute() + + source = "
" + scope = Phoenix.LiveView.TagEngine.generate_scope(source) + + rendered = + source + |> render() + |> normalize_whitespace() + + expected = ~s(
) + + assert rendered == normalize_whitespace(expected) + after + disable_apply_css_scope_attribute() + end + + test "is correctly applied to a single tag with body" do + enable_apply_css_scope_attribute() + + source = "
Test
" + scope = Phoenix.LiveView.TagEngine.generate_scope(source) + + rendered = + source + |> render() + |> normalize_whitespace() + + expected = ~s(
Test
) + + assert rendered == normalize_whitespace(expected) + after + disable_apply_css_scope_attribute() + end + + test "is correctly applied to multiple self-closing tags" do + enable_apply_css_scope_attribute() + + source = """ +
+
+
+ """ + + scope = Phoenix.LiveView.TagEngine.generate_scope(source) + + rendered = + source + |> render() + |> normalize_whitespace() + + expected = """ +
+
+
+ """ + + assert rendered == normalize_whitespace(expected) + after + disable_apply_css_scope_attribute() + end + + test "is correctly applied to multiple tags with bodies" do + enable_apply_css_scope_attribute() + + source = """ +
Test1
+
Test2
+
Test3
+ """ + + scope = Phoenix.LiveView.TagEngine.generate_scope(source) + + rendered = + source + |> render() + |> normalize_whitespace() + + expected = """ +
Test1
+
Test2
+
Test3
+ """ + + assert rendered == normalize_whitespace(expected) + after + disable_apply_css_scope_attribute() + end + + test "is correctly applied to all outermost tags, but not nested tags" do + enable_apply_css_scope_attribute() + + source = """ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """ + + scope = Phoenix.LiveView.TagEngine.generate_scope(source) + + rendered = + source + |> render() + |> normalize_whitespace() + + expected = """ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """ + + assert rendered == normalize_whitespace(expected) + after + disable_apply_css_scope_attribute() + end + + test "is correctly applied to contents of component inner_blocks" do + enable_apply_css_scope_attribute() + + source = """ +
+
+ +
+
+ Inner Block 1 +
+
+
+ +
+
+ Inner Block 2 +
+
+
+
+
+ """ + + scope = Phoenix.LiveView.TagEngine.generate_scope(source) + + pattern = + """ +
+
+
+
+
+ Inner Block 1 +
+
+
+
+
+
+ Inner Block 2 +
+
+
+
+
+ """ + |> normalize_whitespace() + |> Regex.compile!() + + rendered = + source + |> render() + |> normalize_whitespace() + + [inner_block_and_slot_scope, inner_block_and_slot_scope] = + Regex.run(pattern, rendered, capture: :all_but_first) + + refute scope == inner_block_and_slot_scope + after + disable_apply_css_scope_attribute() + end + + test "is correctly applied to contents of component named slots" do + enable_apply_css_scope_attribute() + + source = """ +
+
+ + <:test> +
+
+ Inner Block 1 +
+
+ +
+ + <:test> +
+
+ Inner Block 2 +
+
+ +
+
+
+ """ + + scope = Phoenix.LiveView.TagEngine.generate_scope(source) + + pattern = + """ +
+
+
+ +
+
+ +
+
+
+ """ + |> normalize_whitespace() + |> Regex.compile!() + + rendered = + source + |> render() + |> normalize_whitespace() + + assert Regex.match?(pattern, rendered) + + [inner_block_and_slot_scope, inner_block_and_slot_scope] = + Regex.run(pattern, rendered, capture: :all_but_first) + + refute scope == inner_block_and_slot_scope + after + disable_apply_css_scope_attribute() + end + + test "is correctly applied to nested component calls with inner_blocks and slots" do + enable_apply_css_scope_attribute() + + source = """ +
+
+ +
+ +
+ +
+ <:test> +
+ +
+ +
+
+ <:test> +
+ +
+ +
+ <:test> +
+ +
+ +
+
+ +
+
+
+ """ + + scope = Phoenix.LiveView.TagEngine.generate_scope(source) + + pattern = + """ +
+
+
+
+
+
+

Simple

+
+ +
+
+ +
+
+
+ """ + |> normalize_whitespace() + |> Regex.compile!() + + rendered = + source + |> render() + |> normalize_whitespace() + + assert Regex.match?(pattern, rendered) + + [ + inner_block_and_slot_scope, + inner_block_and_slot_scope, + simple_scope, + simple_scope, + inner_block_and_slot_scope, + simple_scope, + simple_scope + ] = Regex.run(pattern, rendered, capture: :all_but_first) + + refute scope == inner_block_and_slot_scope + refute scope == simple_scope + refute inner_block_and_slot_scope == simple_scope + after + disable_apply_css_scope_attribute() + end + end + + defp normalize_whitespace(string) do + # Eliminate all newlines and space between tags to make + # assertions more resilient against irrelevant whitespace differences + string + |> String.replace("\n", "") + |> String.replace(~r/> +<") + end + + defp enable_apply_css_scope_attribute() do + Application.put_env(:phoenix_live_view, :apply_css_scope_attribute, true) + + # Important that this happens after the Application env + # is updated so that the contents of this file are also + # scoped + # Code.require_file("test/support/live_views/css_scope.exs") + + defmodule CSSScope do + use Phoenix.Component + + slot :inner_block, required: true + slot :test + + def inner_block_and_slot(assigns) do + ~H""" +
+ {render_slot(@inner_block)} + +
+ """ + end + + def simple(assigns) do + ~H""" +

Simple

+ """ + end + end + end + + defp disable_apply_css_scope_attribute() do + Application.put_env(:phoenix_live_view, :apply_css_scope_attribute, false) + + :code.delete(__MODULE__.CSSScope) + :code.purge(__MODULE__.CSSScope) + end end From 5095ec2d6e03f9f4de1b95e01bba413d80c69870 Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Tue, 13 Jan 2026 10:27:23 -0500 Subject: [PATCH 7/7] Remove extraneous assertions and update incorrect comment --- test/phoenix_live_view/html_engine_test.exs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/test/phoenix_live_view/html_engine_test.exs b/test/phoenix_live_view/html_engine_test.exs index def553282c..81d730090b 100644 --- a/test/phoenix_live_view/html_engine_test.exs +++ b/test/phoenix_live_view/html_engine_test.exs @@ -2565,8 +2565,6 @@ defmodule Phoenix.LiveView.HTMLEngineTest do |> render() |> normalize_whitespace() - assert Regex.match?(pattern, rendered) - [inner_block_and_slot_scope, inner_block_and_slot_scope] = Regex.run(pattern, rendered, capture: :all_but_first) @@ -2658,8 +2656,6 @@ defmodule Phoenix.LiveView.HTMLEngineTest do |> render() |> normalize_whitespace() - assert Regex.match?(pattern, rendered) - [ inner_block_and_slot_scope, inner_block_and_slot_scope, @@ -2689,11 +2685,9 @@ defmodule Phoenix.LiveView.HTMLEngineTest do defp enable_apply_css_scope_attribute() do Application.put_env(:phoenix_live_view, :apply_css_scope_attribute, true) - # Important that this happens after the Application env - # is updated so that the contents of this file are also - # scoped - # Code.require_file("test/support/live_views/css_scope.exs") - + # It is mportant that this module is defined and compiled + # after the Application env is updated so that the contents + # of this module are also scoped defmodule CSSScope do use Phoenix.Component