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
+
+
+ ```
+
> #### 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 = "
+ """
+
+ 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 = """
+
+ """
+ |> 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