From 3a762183c8e391262e30287abb138ddaf899f5c2 Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Sun, 25 Jan 2026 19:09:11 -0500 Subject: [PATCH 01/11] Add ColocatedCSS with Scoped CSS Support --- config/e2e.exs | 2 + lib/mix/tasks/compile/phoenix_live_view.ex | 5 +- lib/phoenix_live_view/colocated_css.ex | 397 ++++++++++++++++++ test/e2e/support/colocated_live.ex | 159 +++++++ test/e2e/test_helper.exs | 7 +- test/e2e/tests/colocated.spec.js | 82 ++++ test/phoenix_live_view/colocated_css_test.exs | 284 +++++++++++++ 7 files changed, 933 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/config/e2e.exs b/config/e2e.exs index 33e9a3fe20..349e351e7e 100644 --- a/config/e2e.exs +++ b/config/e2e.exs @@ -1,3 +1,5 @@ import Config config :logger, :level, :error + +config :phoenix_live_view, :root_tag_attribute, "phx-r" diff --git a/lib/mix/tasks/compile/phoenix_live_view.ex b/lib/mix/tasks/compile/phoenix_live_view.ex index 957698d013..a2a8683b19 100644 --- a/lib/mix/tasks/compile/phoenix_live_view.ex +++ b/lib/mix/tasks/compile/phoenix_live_view.ex @@ -2,8 +2,8 @@ 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 +30,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..1933604fd5 --- /dev/null +++ b/lib/phoenix_live_view/colocated_css.ex @@ -0,0 +1,397 @@ +defmodule Phoenix.LiveView.ColocatedCSS do + @moduledoc ~S''' + A special HEEx `:type` that extracts any CSS styles from a colocated ` + ``` + + ## 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 + + ``` + + **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 + > 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, all "local root" elements in the given template serve as scoping roots. + > "Local root" elements are the outermost elements of the template itself and the outermost elements of any content passed to + > child components' slots. 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. 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 + + 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. + + > #### A note on dependencies and umbrella projects {: .info} + > + > For each application that uses colocated CSS, a separate directory is created + > inside the `phoenix-colocated-css` folder. This allows to have clear separation between + > styles of dependencies, but also applications inside umbrella projects. + + 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-css", __DIR__) + ``` + + > #### 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. + + 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 + + ``` + + ## Options + + Colocated CSS can be configured through the attributes of the ` + """ + end + + defp scoped_colocated_css(assigns) do + ~H""" + +
+ Should have blue background + <.scoped_css_inner_block_one> + + Should have no background (scope root) + + Should have blue background + + + + <.scoped_css_inner_block_one> + + Should have no background (scope root) + + Should have blue background + + + + <.scoped_css_inner_block_two> + + Should have no background (scope root) + + Should have blue background + + + + <.scoped_css_slot_one> + <:test> + + Should have no background (scope root) + + Should have blue background + + + + + <.scoped_css_slot_two> + <:test> + + Should have no background (scope root) + + Should have blue background + + + + +
+ """ + end + + slot :inner_block, required: true + + defp scoped_css_inner_block_one(assigns) do + ~H""" + {render_slot(@inner_block)} + """ + end + + slot :inner_block, required: true + + defp scoped_css_inner_block_two(assigns) do + ~H""" + +
+ Should have yellow background + {render_slot(@inner_block)} +
+ """ + end + + slot :test, required: true + + defp scoped_css_slot_one(assigns) do + ~H""" + {render_slot(@test)} + """ + end + + slot :test, required: true + + defp scoped_css_slot_two(assigns) do + ~H""" + +
+ Should have green background + {render_slot(@test)} +
+ """ + end + + defp scoped_exclusive_lower_bound_colocated_css(assigns) do + ~H""" + +
+ <.flex_items should_flex?={false} /> +
+ """ + end + + defp scoped_inclusive_lower_bound_colocated_css(assigns) do + ~H""" + +
+ <.flex_items should_flex?={true} /> +
+ """ + end + + attr :should_flex?, :boolean, required: true + + defp flex_items(assigns) do + ~H""" +

+ {if @should_flex?, do: "Should", else: "Shouldn't"} Flex {x} +

+ """ + end end diff --git a/test/e2e/test_helper.exs b/test/e2e/test_helper.exs index e7fd9346f5..1eef0e04f1 100644 --- a/test/e2e/test_helper.exs +++ b/test/e2e/test_helper.exs @@ -283,6 +283,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 @@ -322,8 +326,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..2adfdd2daf 100644 --- a/test/e2e/tests/colocated.spec.js +++ b/test/e2e/tests/colocated.spec.js @@ -41,3 +41,85 @@ test("custom macro component works (syntax highlighting)", async ({ page }) => { page.locator("pre").nth(1).getByText("@temperature"), ).toHaveClass("na"); }); + +test("global colocated css works", async ({ page }) => { + await page.goto("/colocated"); + await syncLV(page); + + await expect(page.locator('[data-test="global"]')).toHaveCSS( + "background-color", + "rgb(255, 0, 0)", + ); +}); + +test("scoped colocated css works", async ({ page }) => { + await page.goto("/colocated"); + await syncLV(page); + + await expect(page.locator('[data-test="scoped"]')).toHaveCSS( + "background-color", + "rgba(0, 0, 0, 0)", + ); + + const blueLocator = page.locator('[data-test-scoped="blue"]'); + + await expect(blueLocator).toHaveCount(6); + + for (const shouldBeBlue in blueLocator.all()) { + await expect(shouldBeBlue).toHaveCSS("background-color", "rgb(0, 0, 255)"); + } + + const noneLocator = page.locator('[data-test-scoped="none"]'); + + await expect(noneLocator).toHaveCount(5); + + for (const shouldBeTransparent in noneLocator.all()) { + await expect(shouldBeTransparent).toHaveCSS( + "background-color", + "rgba(0, 0, 0, 0)", + ); + } + + await expect(page.locator('[data-test-scoped="yellow"]')).toHaveCSS( + "background-color", + "rgb(255, 255, 0)", + ); + + await expect(page.locator('[data-test-scoped="green"]')).toHaveCSS( + "background-color", + "rgb(0, 255, 0)", + ); +}); + +test("scoped colocated css lower bound inclusive/exclusive works", async ({ + page, +}) => { + await page.goto("/colocated"); + await syncLV(page); + + const lowerBoundContainerLocator = page.locator( + "[data-test-lower-bound-container]", + ); + + await expect(lowerBoundContainerLocator).toHaveCount(2); + + for (const shouldBeFlex in lowerBoundContainerLocator.all()) { + await expect(shouldBeFlex).toHaveCSS("display", "flex"); + } + + const inclusiveFlexItemsLocator = page.locator('[data-test-inclusive="yes"]'); + + await expect(inclusiveFlexItemsLocator).toHaveCount(3); + + for (const shouldFlex in inclusiveFlexItemsLocator.all()) { + await expect(shouldFlex).toHaveCSS("flex", "1"); + } + + const exclusiveFlexItemsLocator = page.locator('[data-test-inclusive="yes"]'); + + await expect(exclusiveFlexItemsLocator).toHaveCount(3); + + for (const shouldntFlex in exclusiveFlexItemsLocator.all()) { + await expect(shouldntFlex).not().toHaveCSS("flex", "1"); + } +}); 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..edb9fb8fd3 --- /dev/null +++ b/test/phoenix_live_view/colocated_css_test.exs @@ -0,0 +1,284 @@ +defmodule Phoenix.LiveView.ColocatedCSSTest do + # we set async: false because we call the colocated CSS compiler + # and it reads / writes to a shared folder, and also because + # we manipulate the Application env for :root_tag_attribute + use ExUnit.Case, async: false + + alias Phoenix.LiveView.TagEngine.Tokenizer.ParseError + + setup do + Application.put_env(:phoenix_live_view, :root_tag_attribute, "phx-r") + on_exit(fn -> Application.delete_env(:phoenix_live_view, :root_tag_attribute) end) + end + + describe "global styles" do + test "are extracted and available under manifest import" do + defmodule TestGlobalComponent do + use Phoenix.Component + + 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 { background-color: #FFFFFF; }\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 "raises for invalid global attribute value" do + message = ~r/expected nil or true for the `global` attribute of colocated css, got: "bad"/ + + assert_raise ParseError, + message, + fn -> + defmodule TestBadGlobalAttrComponent do + use Phoenix.Component + + def fun(assigns) do + ~H""" + + """ + end + end + end + after + :code.delete(__MODULE__.TestBadGlobalAttrComponent) + :code.purge(__MODULE__.TestBadGlobalAttrComponent) + end + + test "raises if scoped css specific options are provided" do + message = + ~r/colocated css must be scoped to use the `lower-bound` attribute, but `global` attribute was provided/ + + assert_raise ParseError, + message, + fn -> + defmodule TestScopedAttrWhileGlobalComponent do + use Phoenix.Component + + def fun(assigns) do + ~H""" + + """ + end + end + end + after + :code.delete(__MODULE__.TestScopedAttrWhileGlobalComponent) + :code.purge(__MODULE__.TestScopedAttrWhileGlobalComponent) + end + end + + describe "scoped styles" do + test "with exclusive (default) lower-bound is extracted and available under manifest import" do + defmodule TestScopedExclusiveComponent do + use Phoenix.Component + + 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__)}\.TestScopedExclusiveComponent$/ + end) + + assert [style] = + Path.wildcard( + Path.join( + Mix.Project.build_path(), + "phoenix-colocated-css/phoenix_live_view/#{folder}/*.css" + ) + ) + + file_contents = File.read!(style) + + file_contents = + Regex.replace(~r/phx-css=".+?"/, file_contents, ~s|phx-css="SCOPE_HERE"|) + + # The scope is a generated value, so for testing reliability we just replace it with a known + # value to assert against. + assert file_contents == + ~s|@scope ([phx-css="SCOPE_HERE"]) to ([phx-r]) { \n .sample-class { background-color: #FFFFFF; }\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__.TestScopedExclusiveComponent) + :code.purge(__MODULE__.TestScopedExclusiveComponent) + end + + test "with inclusive lower-bound is extracted and available under manifest import" do + defmodule TestScopedInclusiveComponent do + use Phoenix.Component + + 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__)}\.TestScopedInclusiveComponent$/ + end) + + assert [style] = + Path.wildcard( + Path.join( + Mix.Project.build_path(), + "phoenix-colocated-css/phoenix_live_view/#{folder}/*.css" + ) + ) + + file_contents = File.read!(style) + + file_contents = + Regex.replace(~r/phx-css=".+?"/, file_contents, ~s|phx-css="SCOPE_HERE"|) + + # The scope is a generated value, so for testing reliability we just replace it with a known + # value to assert against. + assert file_contents == + ~s|@scope ([phx-css="SCOPE_HERE"]) to ([phx-r] > *) { \n .sample-class { background-color: #FFFFFF; }\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__.TestScopedInclusiveComponent) + :code.purge(__MODULE__.TestScopedInclusiveComponent) + end + + test "raises for invalid lower-bound attribute value" do + message = + ~r/expected "inclusive" or "exclusive" for the `lower-bound` attribute of colocated css, got: "unknown"/ + + assert_raise ParseError, + message, + fn -> + defmodule TestBadLowerBoundAttrComponent do + use Phoenix.Component + + def fun(assigns) do + ~H""" + + """ + end + end + end + after + :code.delete(__MODULE__.TestBadLowerBoundAttrComponent) + :code.purge(__MODULE__.TestBadLowerBoundAttrComponent) + end + end + + test "writes empty manifest 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 767954b28cb16170ddc4ab57211ffa54d76af4cc Mon Sep 17 00:00:00 2001 From: David Green <134172184+green-david@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:09:16 -0500 Subject: [PATCH 02/11] Address review comments --- lib/phoenix_live_view/colocated_css.ex | 19 +++++++++++-------- test/phoenix_live_view/colocated_css_test.exs | 8 ++++---- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/lib/phoenix_live_view/colocated_css.ex b/lib/phoenix_live_view/colocated_css.ex index 1933604fd5..67e4b6da13 100644 --- a/lib/phoenix_live_view/colocated_css.ex +++ b/lib/phoenix_live_view/colocated_css.ex @@ -182,7 +182,7 @@ defmodule Phoenix.LiveView.ColocatedCSS do {scope, data} = extract(opts, text_content, meta) # we always drop colocated CSS from the rendered output - {:ok, "", data, [root_tag_attribute: {"phx-css", scope}]} + {:ok, "", data, [root_tag_attribute: {"phx-css-#{scope}", true}]} end def transform(_ast, _meta) do @@ -192,9 +192,8 @@ defmodule Phoenix.LiveView.ColocatedCSS do 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"}| + if not Version.match?(phoenix_version, "~> 1.8.0") do + raise ArgumentError, ~s|ColocatedCSS requires at least {:phoenix, "~> 1.8.0"}| end end @@ -234,10 +233,10 @@ defmodule Phoenix.LiveView.ColocatedCSS do # _build/dev/phoenix-colocated-css/otp_app/MyApp.MyComponent/line_no.css target_path = Path.join(target_dir(), inspect(meta.env.module)) - scope = scope(meta) + scope = scope(text_content, meta) root_tag_attribute = root_tag_attribute() - upper_bound_selector = ~s|[phx-css="#{scope}"]| + upper_bound_selector = ~s|[phx-css-#{scope}]| lower_bound_selector = ~s|[#{root_tag_attribute}]| lower_bound_selector = @@ -266,11 +265,15 @@ defmodule Phoenix.LiveView.ColocatedCSS do {scope, filename} end - defp scope(meta) do - hash("#{meta.env.module}_#{meta.env.line}") + defp scope(text_content, meta) do + hash("#{meta.env.module}_#{meta.env.line}: #{text_content}") end defp hash(string) do + # It is important that we do not pad + # the Base32 encoded value as we use it in + # an HTML attribute name and = (the padding character) + # is not valid. string |> then(&:crypto.hash(:md5, &1)) |> Base.encode32(case: :lower, padding: false) diff --git a/test/phoenix_live_view/colocated_css_test.exs b/test/phoenix_live_view/colocated_css_test.exs index edb9fb8fd3..912b7de13c 100644 --- a/test/phoenix_live_view/colocated_css_test.exs +++ b/test/phoenix_live_view/colocated_css_test.exs @@ -152,12 +152,12 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do file_contents = File.read!(style) file_contents = - Regex.replace(~r/phx-css=".+?"/, file_contents, ~s|phx-css="SCOPE_HERE"|) + Regex.replace(~r/\[phx-css-.+?\]/, file_contents, ~s|[phx-css-SCOPE_HERE]|) # The scope is a generated value, so for testing reliability we just replace it with a known # value to assert against. assert file_contents == - ~s|@scope ([phx-css="SCOPE_HERE"]) to ([phx-r]) { \n .sample-class { background-color: #FFFFFF; }\n }| + ~s|@scope ([phx-css-SCOPE_HERE]) to ([phx-r]) { \n .sample-class { background-color: #FFFFFF; }\n }| # now write the manifest manually as we are in a test Phoenix.LiveView.ColocatedCSS.compile() @@ -217,12 +217,12 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do file_contents = File.read!(style) file_contents = - Regex.replace(~r/phx-css=".+?"/, file_contents, ~s|phx-css="SCOPE_HERE"|) + Regex.replace(~r/\[phx-css-.+?\]/, file_contents, ~s|[phx-css-SCOPE_HERE]|) # The scope is a generated value, so for testing reliability we just replace it with a known # value to assert against. assert file_contents == - ~s|@scope ([phx-css="SCOPE_HERE"]) to ([phx-r] > *) { \n .sample-class { background-color: #FFFFFF; }\n }| + ~s|@scope ([phx-css-SCOPE_HERE]) to ([phx-r] > *) { \n .sample-class { background-color: #FFFFFF; }\n }| # now write the manifest manually as we are in a test Phoenix.LiveView.ColocatedCSS.compile() From dc7e988c65d9a367243da6ab909aa0f8744e08d5 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Fri, 30 Jan 2026 10:12:48 +0100 Subject: [PATCH 03/11] colocated abstraction --- lib/mix/tasks/compile/phoenix_live_view.ex | 3 +- lib/phoenix_component/macro_component.ex | 13 +- lib/phoenix_live_view/colocated_assets.ex | 277 ++++++++++++++++++ lib/phoenix_live_view/colocated_css.ex | 187 ++---------- lib/phoenix_live_view/colocated_js.ex | 191 ++---------- test/e2e/support/colocated_live.ex | 2 +- test/e2e/test_helper.exs | 7 +- .../macro_component_integration_test.exs | 2 +- .../macro_component_test.exs | 10 +- test/phoenix_live_view/colocated_css_test.exs | 34 +-- .../phoenix_live_view/colocated_hook_test.exs | 2 +- test/phoenix_live_view/colocated_js_test.exs | 10 +- 12 files changed, 359 insertions(+), 379 deletions(-) create mode 100644 lib/phoenix_live_view/colocated_assets.ex diff --git a/lib/mix/tasks/compile/phoenix_live_view.ex b/lib/mix/tasks/compile/phoenix_live_view.ex index a2a8683b19..1269de6d40 100644 --- a/lib/mix/tasks/compile/phoenix_live_view.ex +++ b/lib/mix/tasks/compile/phoenix_live_view.ex @@ -29,7 +29,6 @@ defmodule Mix.Tasks.Compile.PhoenixLiveView do end defp compile do - Phoenix.LiveView.ColocatedJS.compile() - Phoenix.LiveView.ColocatedCSS.compile() + Phoenix.LiveView.ColocatedAssets.compile() end end diff --git a/lib/phoenix_component/macro_component.ex b/lib/phoenix_component/macro_component.ex index a7cf70ec59..5a04f601c0 100644 --- a/lib/phoenix_component/macro_component.ex +++ b/lib/phoenix_component/macro_component.ex @@ -162,18 +162,19 @@ defmodule Phoenix.Component.MacroComponent do @doc """ Returns the stored data from macro components that returned `{:ok, ast, data}`. - As one macro component can be used multiple times in one module, the result is a list of all data values. + As one macro component can be used multiple times in one module, the result is a map of format - If the component module does not have any macro components defined, an empty list is returned. + %{module => list(data)} + + If the component module does not have any macro components defined, an empty map is returned. """ - @spec get_data(module(), module()) :: [term()] | nil - def get_data(component_module, macro_component) do + @spec get_data(module()) :: map() + def get_data(component_module) do if Code.ensure_loaded?(component_module) and function_exported?(component_module, :__phoenix_macro_components__, 0) do component_module.__phoenix_macro_components__() - |> Map.get(macro_component, []) else - [] + %{} end end diff --git a/lib/phoenix_live_view/colocated_assets.ex b/lib/phoenix_live_view/colocated_assets.ex new file mode 100644 index 0000000000..0679c1986e --- /dev/null +++ b/lib/phoenix_live_view/colocated_assets.ex @@ -0,0 +1,277 @@ +defmodule Phoenix.LiveView.ColocatedAssets do + @moduledoc false + + defstruct [:relative_path, :data] + + @type t() :: %__MODULE__{ + relative_path: String.t(), + data: term() + } + + defmodule Entry do + @moduledoc false + defstruct [:filename, :data, :callback, :component] + end + + @callback build_manifests(colocated :: t()) :: list({binary(), binary()}) + @callback finalize(target_directory :: String.t()) :: :ok + + @optional_callbacks [finalize: 1] + + @doc """ + Extracts content into the colocated directory. + + Returns an opaque struct that is stored as macro component data + for manifest generation. + + The flow is: + + 1. MacroComponent transform callback is called. + 2. The transform callback invokes ColocatedAssets.extract/5, + which writes the content to the target directory. + 3. LiveView compiler invokes ColocatedAssets.compile/0. + 4. ColocatedAssets builds a list of `%ColocatedAssets{}` structs + grouped by callback module and invokes the callback's + `build_manifests/1` function. + + """ + def extract(callback_module, module, filename, text, data) do + # _build/dev/phoenix-colocated/otp_app/MyApp.MyComponent/filename + target_path = + target_dir() + |> Path.join(inspect(module)) + + File.mkdir_p!(target_path) + File.write!(Path.join(target_path, filename), text) + + %Entry{filename: filename, data: data, callback: callback_module} + end + + @doc false + def compile do + # this step runs after all modules have been compiled + # so we can write the final manifests and remove outdated files + clear_manifests!() + callback_colocated_map = clear_outdated_and_get_files!() + File.mkdir_p!(target_dir()) + + warn_for_outdated_config!() + + Enum.each(configured_callbacks(), fn callback_module -> + true = Code.ensure_loaded?(callback_module) + + files = + case callback_colocated_map do + %{^callback_module => files} -> + files + + _ -> + [] + end + + for {name, content} <- callback_module.build_manifests(files) do + File.write!(Path.join(target_dir(), name), content) + end + end) + + maybe_link_node_modules!() + end + + defp clear_manifests! do + target_dir = target_dir() + + manifests = + Path.wildcard(Path.join(target_dir, "*")) + |> Enum.filter(&File.regular?(&1)) + + for manifest <- manifests, do: File.rm!(manifest) + 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) + |> Enum.group_by(fn {callback, _file} -> callback end, fn {_callback, file} -> file end) + |> Map.new() + end + + defp process_module(module_folder, module) do + with true <- Code.ensure_loaded?(module), + data when data != %{} <- Phoenix.Component.MacroComponent.get_data(module), + colocated when colocated != [] <- filter_colocated(data) do + expected_files = Enum.map(colocated, fn %{filename: filename} -> filename end) + 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(colocated, fn %Entry{} = e -> + absolute_path = Path.join(module_folder, e.filename) + + {e.callback, + %__MODULE__{relative_path: Path.relative_to(absolute_path, target_dir()), data: e.data}} + end) + else + _ -> + # either the module does not exist any more or + # does not have any colocated assets + File.rm_rf!(module_folder) + [] + end + end + + defp filter_colocated(data) do + for {macro_component, entries} <- data do + Enum.flat_map(entries, fn data -> + case data do + %Entry{} = d -> [%{d | component: macro_component}] + _ -> [] + end + end) + end + |> List.flatten() + end + + defp maybe_link_node_modules! do + settings = project_settings() + + case Keyword.get(settings, :node_modules_path, {:fallback, "assets/node_modules"}) do + {:fallback, rel_path} -> + location = Path.absname(rel_path) + do_symlink(location, true) + + path when is_binary(path) -> + location = Path.absname(path) + do_symlink(location, false) + end + end + + defp relative_to_target(location) do + if function_exported?(Path, :relative_to, 3) do + apply(Path, :relative_to, [location, target_dir(), [force: true]]) + else + Path.relative_to(location, target_dir()) + end + end + + defp do_symlink(node_modules_path, is_fallback) do + relative_node_modules_path = relative_to_target(node_modules_path) + + with {:error, reason} when reason != :eexist <- + File.ln_s(relative_node_modules_path, Path.join(target_dir(), "node_modules")), + false <- Keyword.get(global_settings(), :disable_symlink_warning, false) do + disable_hint = """ + If you don't use colocated hooks / js or you don't need to import files from "assets/node_modules" + in your hooks, you can simply disable this warning by setting + + config :phoenix_live_view, :colocated_assets, + disable_symlink_warning: true + """ + + IO.warn(""" + Failed to symlink node_modules folder for Phoenix.LiveView.ColocatedJS: #{inspect(reason)} + + On Windows, you can address this issue by starting your Windows terminal at least once + with "Run as Administrator" and then running your Phoenix application.#{is_fallback && "\n\n" <> disable_hint} + """) + end + end + + defp configured_callbacks do + [ + # Hardcoded for now + Phoenix.LiveView.ColocatedJS, + Phoenix.LiveView.ColocatedCSS + ] + end + + defp global_settings do + Application.get_env( + :phoenix_live_view, + :colocated_assets, + Application.get_env(:phoenix_live_view, :colocated_js, []) + ) + end + + defp project_settings do + lv_config = + Mix.Project.config() + |> Keyword.get(:phoenix_live_view, []) + + Keyword.get_lazy(lv_config, :colocated_assets, fn -> + Keyword.get(lv_config, :colocated_js, []) + end) + end + + defp target_dir do + app = to_string(Mix.Project.config()[:app]) + default = Path.join(Mix.Project.build_path(), "phoenix-colocated") + + global_settings() + |> Keyword.get(:target_directory, default) + |> Path.join(app) + end + + defp subdirectories(path) do + Path.wildcard(Path.join(path, "*")) |> Enum.filter(&File.dir?(&1)) + end + + defp warn_for_outdated_config! do + case Application.get_env(:phoenix_live_view, :colocated_js) do + nil -> + :ok + + _ -> + IO.warn(""" + The :colocated_js configuration option is deprecated! + + Instead of + + config :phoenix_live_view, :colocated_js, ... + + use + + config :phoenix_live_view, :colocated_assets, ... + + instead. + """) + end + + lv_config = + Mix.Project.config() + |> Keyword.get(:phoenix_live_view, []) + + case Keyword.get(lv_config, :colocated_js) do + nil -> + :ok + + _ -> + IO.warn(""" + The :colocated_js configuration option is deprecated! + + Instead of + + [ + ..., + phoenix_live_view: [colocated_js: ...] + ] + + use + + [ + ..., + phoenix_live_view: [colocated_assets: ...] + ] + + in your mix.exs instead. + """) + end + end +end diff --git a/lib/phoenix_live_view/colocated_css.ex b/lib/phoenix_live_view/colocated_css.ex index 67e4b6da13..e447c1f5c4 100644 --- a/lib/phoenix_live_view/colocated_css.ex +++ b/lib/phoenix_live_view/colocated_css.ex @@ -72,76 +72,12 @@ defmodule Phoenix.LiveView.ColocatedCSS do > 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 - - 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. - - > #### A note on dependencies and umbrella projects {: .info} - > - > For each application that uses colocated CSS, a separate directory is created - > inside the `phoenix-colocated-css` folder. This allows to have clear separation between - > styles of dependencies, but also applications inside umbrella projects. - - 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-css", __DIR__) - ``` - - > #### 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. + Colocated CSS uses the same folder structures as Colocated JS. See `Phoenix.LiveView.ColocatedJS` for more information. 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" + import "phoenix-colocated/my_app/colocated.css" ``` Importing CSS in your `app.js` file will cause esbuild to generate a separate `app.css` file. @@ -168,8 +104,7 @@ defmodule Phoenix.LiveView.ColocatedCSS do ''' @behaviour Phoenix.Component.MacroComponent - - alias Phoenix.Component.MacroComponent + @behaviour Phoenix.LiveView.ColocatedAssets @impl true def transform({"style", attributes, [text_content], _tag_meta} = _ast, meta) do @@ -230,9 +165,6 @@ defmodule Phoenix.LiveView.ColocatedCSS do @doc false def extract(opts, text_content, meta) do - # _build/dev/phoenix-colocated-css/otp_app/MyApp.MyComponent/line_no.css - target_path = Path.join(target_dir(), inspect(meta.env.module)) - scope = scope(text_content, meta) root_tag_attribute = root_tag_attribute() @@ -256,13 +188,10 @@ defmodule Phoenix.LiveView.ColocatedCSS do filename = "#{meta.env.line}_#{hash(styles)}.css" - File.mkdir_p!(target_path) + data = + Phoenix.LiveView.ColocatedAssets.extract(__MODULE__, meta.env.module, filename, styles, nil) - target_path - |> Path.join(filename) - |> File.write!(styles) - - {scope, filename} + {scope, data} end defp scope(text_content, meta) do @@ -301,100 +230,18 @@ defmodule Phoenix.LiveView.ColocatedCSS do end 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 - module_folder - |> Path.join(file) - |> File.rm!() - end - - Enum.map(data, fn filename -> - _absolute_file_path = Path.join(module_folder, filename) - end) + @impl Phoenix.LiveView.ColocatedAssets + def build_manifests(files) do + if files == [] do + [{"colocated.css", ""}] else - _ -> - # either the module does not exist any more or - # does not have any colocated CSS - File.rm_rf!(module_folder) - [] + [ + {"colocated.css", + Enum.reduce(files, [], fn %{relative_path: file}, acc -> + line = ~s[@import "./#{file}";\n] + [acc | line] + end)} + ] 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 - |> Path.join("*") - |> Path.wildcard() - |> Enum.filter(&File.dir?(&1)) - end end diff --git a/lib/phoenix_live_view/colocated_js.ex b/lib/phoenix_live_view/colocated_js.ex index 5f06ea1cdf..81fffd2cd6 100644 --- a/lib/phoenix_live_view/colocated_js.ex +++ b/lib/phoenix_live_view/colocated_js.ex @@ -180,10 +180,9 @@ defmodule Phoenix.LiveView.ColocatedJS do ''' @behaviour Phoenix.Component.MacroComponent + @behaviour Phoenix.LiveView.ColocatedAssets - alias Phoenix.Component.MacroComponent - - @impl true + @impl Phoenix.Component.MacroComponent def transform({"script", attributes, [text_content], _tag_meta} = _ast, meta) do validate_phx_version!() @@ -227,11 +226,6 @@ defmodule Phoenix.LiveView.ColocatedJS do @doc false def extract(opts, text_content, meta) do - # _build/dev/phoenix-colocated/otp_app/MyApp.MyComponent/line_no.js - target_path = - target_dir() - |> Path.join(inspect(meta.env.module)) - filename_opts = %{name: opts["name"]} |> maybe_put_opt(opts, "key", :key) @@ -244,10 +238,13 @@ defmodule Phoenix.LiveView.ColocatedJS do filename = "#{meta.env.line}_#{hashed_name}.#{opts["extension"] || "js"}" - File.mkdir_p!(target_path) - File.write!(Path.join(target_path, filename), text_content) - - {filename, filename_opts} + Phoenix.LiveView.ColocatedAssets.extract( + __MODULE__, + meta.env.module, + filename, + text_content, + filename_opts + ) end defp maybe_put_opt(map, opts, opts_key, target_key) do @@ -260,109 +257,41 @@ defmodule Phoenix.LiveView.ColocatedJS do end end - @doc false - def compile do - # this step runs after all modules have been compiled - # so we can write the final manifests and remove outdated hooks - clear_manifests!() - files = clear_outdated_and_get_files!() - write_new_manifests!(files) - maybe_link_node_modules!() - end - - defp clear_manifests! do - target_dir = target_dir() - - manifests = - Path.wildcard(Path.join(target_dir, "*")) - |> Enum.filter(&File.regular?(&1)) - - for manifest <- manifests, do: File.rm!(manifest) - 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 != [] <- get_data(module) do - expected_files = Enum.map(data, fn {filename, _opts} -> filename end) - 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, config} -> - absolute_file_path = Path.join(module_folder, filename) - {absolute_file_path, config} - end) - else - _ -> - # either the module does not exist any more or - # does not have any colocated hooks / JS - File.rm_rf!(module_folder) - [] - end - end - - defp get_data(module) do - hooks_data = MacroComponent.get_data(module, Phoenix.LiveView.ColocatedHook) - js_data = MacroComponent.get_data(module, Phoenix.LiveView.ColocatedJS) - - hooks_data ++ js_data - end - - defp write_new_manifests!(files) do + @impl Phoenix.LiveView.ColocatedAssets + def build_manifests(files) do if files == [] do - File.mkdir_p!(target_dir()) - - File.write!( - Path.join(target_dir(), "index.js"), - "export const hooks = {};\nexport default {};" - ) + [{"index.js", "export const hooks = {};\nexport default {};"}] else files - |> Enum.group_by(fn {_file, config} -> + |> Enum.group_by(fn %Phoenix.LiveView.ColocatedAssets{data: config} -> config[:manifest] || "index.js" end) - |> Enum.each(fn {manifest, entries} -> - write_manifest(manifest, entries) + |> Enum.map(fn {manifest, entries} -> + build_manifest(manifest, entries) end) end end - defp write_manifest(manifest, entries) do - target_dir = target_dir() - + defp build_manifest(manifest, entries) do content = entries - |> Enum.group_by(fn {_file, config} -> config[:key] || :default end) + |> Enum.group_by(fn %{data: config} -> config[:key] || :default end) |> Enum.reduce(["const js = {}; export default js;\n"], fn group, acc -> case group do {:default, entries} -> [ acc, Enum.map(entries, fn - {file, %{name: nil}} -> - ~s[import "./#{Path.relative_to(file, target_dir)}";\n] + %{relative_path: file, data: %{name: nil}} -> + ~s[import "./#{file}";\n] - {file, %{name: name}} -> + %{relative_path: file, data: %{name: name}} -> import_name = "js_" <> Base.encode32(:crypto.hash(:md5, file), case: :lower, padding: false) escaped_name = Phoenix.HTML.javascript_escape(name) - ~s + ~s end) ] @@ -373,89 +302,21 @@ defmodule Phoenix.LiveView.ColocatedJS do acc, ~s, Enum.map(entries, fn - {file, %{name: nil}} -> - ~s[import "./#{Path.relative_to(file, target_dir)}";\n] + %{relative_path: file, data: %{name: nil}} -> + ~s[import "./#{file}";\n] - {file, %{name: name}} -> + %{relative_path: file, data: %{name: name}} -> import_name = "js_" <> Base.encode32(:crypto.hash(:md5, file), case: :lower, padding: false) escaped_name = Phoenix.HTML.javascript_escape(name) - ~s + ~s end) ] end end) - File.write!(Path.join(target_dir, manifest), content) - end - - defp maybe_link_node_modules! do - settings = project_settings() - - case Keyword.get(settings, :node_modules_path, {:fallback, "assets/node_modules"}) do - {:fallback, rel_path} -> - location = Path.absname(rel_path) - do_symlink(location, true) - - path when is_binary(path) -> - location = Path.absname(path) - do_symlink(location, false) - end - end - - defp relative_to_target(location) do - if function_exported?(Path, :relative_to, 3) do - apply(Path, :relative_to, [location, target_dir(), [force: true]]) - else - Path.relative_to(location, target_dir()) - end - end - - defp do_symlink(node_modules_path, is_fallback) do - relative_node_modules_path = relative_to_target(node_modules_path) - - with {:error, reason} when reason != :eexist <- - File.ln_s(relative_node_modules_path, Path.join(target_dir(), "node_modules")), - false <- Keyword.get(global_settings(), :disable_symlink_warning, false) do - disable_hint = """ - If you don't use colocated hooks / js or you don't need to import files from "assets/node_modules" - in your hooks, you can simply disable this warning by setting - - config :phoenix_live_view, :colocated_js, - disable_symlink_warning: true - """ - - IO.warn(""" - Failed to symlink node_modules folder for Phoenix.LiveView.ColocatedJS: #{inspect(reason)} - - On Windows, you can address this issue by starting your Windows terminal at least once - with "Run as Administrator" and then running your Phoenix application.#{is_fallback && "\n\n" <> disable_hint} - """) - end - end - - defp global_settings do - Application.get_env(:phoenix_live_view, :colocated_js, []) - end - - defp project_settings do - Mix.Project.config() - |> Keyword.get(:phoenix_live_view, []) - |> Keyword.get(:colocated_js, []) - end - - defp target_dir do - default = Path.join(Mix.Project.build_path(), "phoenix-colocated") - app = to_string(Mix.Project.config()[:app]) - - global_settings() - |> Keyword.get(:target_directory, default) - |> Path.join(app) - end - - defp subdirectories(path) do - Path.wildcard(Path.join(path, "*")) |> Enum.filter(&File.dir?(&1)) + {manifest, content} end end diff --git a/test/e2e/support/colocated_live.ex b/test/e2e/support/colocated_live.ex index a0ccf267ff..c494453b3e 100644 --- a/test/e2e/support/colocated_live.ex +++ b/test/e2e/support/colocated_live.ex @@ -71,7 +71,7 @@ defmodule Phoenix.LiveViewTest.E2E.ColocatedLive do // initialize js exec handler from colocated js colocated.js_exec(liveSocket); - + {@inner_content} """ diff --git a/test/e2e/test_helper.exs b/test/e2e/test_helper.exs index 1eef0e04f1..704db593d2 100644 --- a/test/e2e/test_helper.exs +++ b/test/e2e/test_helper.exs @@ -283,10 +283,6 @@ 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 @@ -327,8 +323,7 @@ end IO.puts("Starting e2e server on port #{Phoenix.LiveViewTest.E2E.Endpoint.config(:http)[:port]}") # we need to manually compile the colocated hooks / js and css -Phoenix.LiveView.ColocatedJS.compile() -Phoenix.LiveView.ColocatedCSS.compile() +Phoenix.LiveView.ColocatedAssets.compile() if not IEx.started?() do # when running the test server manually, we halt after diff --git a/test/phoenix_component/macro_component_integration_test.exs b/test/phoenix_component/macro_component_integration_test.exs index 3752bd43d5..2253ac404d 100644 --- a/test/phoenix_component/macro_component_integration_test.exs +++ b/test/phoenix_component/macro_component_integration_test.exs @@ -340,7 +340,7 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do end end - assert data = MacroComponent.get_data(TestComponentWithData1, MyMacroComponent) + assert %{MyMacroComponent => data} = MacroComponent.get_data(TestComponentWithData1) assert length(data) == 2 assert Enum.find(data, fn %{opts: opts} -> opts == %{"baz" => nil, "foo" => "bar"} end) diff --git a/test/phoenix_component/macro_component_test.exs b/test/phoenix_component/macro_component_test.exs index 80bd9cd77b..d5dbba887b 100644 --- a/test/phoenix_component/macro_component_test.exs +++ b/test/phoenix_component/macro_component_test.exs @@ -63,19 +63,19 @@ defmodule Phoenix.Component.MacroComponentTest do end end - describe "get_data/2" do - test "returns an empty list if the component module does not exist" do - assert MacroComponent.get_data(IDoNotExist, MyMacroComponent) == [] + describe "get_data/1" do + test "returns an empty map if the component module does not exist" do + assert MacroComponent.get_data(IDoNotExist) == %{} end - test "returns an empty list if the component does not define any macro components" do + test "returns an empty map if the component does not define any macro components" do defmodule MyComponent do use Phoenix.Component def render(assigns), do: ~H"" end - assert MacroComponent.get_data(MyComponent, MyMacroComponent) == [] + assert MacroComponent.get_data(MyComponent) == %{} end end end diff --git a/test/phoenix_live_view/colocated_css_test.exs b/test/phoenix_live_view/colocated_css_test.exs index 912b7de13c..6f1e00006d 100644 --- a/test/phoenix_live_view/colocated_css_test.exs +++ b/test/phoenix_live_view/colocated_css_test.exs @@ -27,7 +27,7 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do assert module_folders = File.ls!( - Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view") + Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view") ) assert folder = @@ -39,27 +39,27 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do Path.wildcard( Path.join( Mix.Project.build_path(), - "phoenix-colocated-css/phoenix_live_view/#{folder}/*.css" + "phoenix-colocated/phoenix_live_view/#{folder}/*.css" ) ) assert File.read!(style) == "\n .sample-class { background-color: #FFFFFF; }\n" # now write the manifest manually as we are in a test - Phoenix.LiveView.ColocatedCSS.compile() + Phoenix.LiveView.ColocatedAssets.compile() assert manifest = File.read!( Path.join( Mix.Project.build_path(), - "phoenix-colocated-css/phoenix_live_view/colocated.css" + "phoenix-colocated/phoenix_live_view/colocated.css" ) ) path = Path.relative_to( style, - Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view/") + Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view/") ) # style is in manifest @@ -133,7 +133,7 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do assert module_folders = File.ls!( - Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view") + Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view") ) assert folder = @@ -145,7 +145,7 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do Path.wildcard( Path.join( Mix.Project.build_path(), - "phoenix-colocated-css/phoenix_live_view/#{folder}/*.css" + "phoenix-colocated/phoenix_live_view/#{folder}/*.css" ) ) @@ -160,20 +160,20 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do ~s|@scope ([phx-css-SCOPE_HERE]) to ([phx-r]) { \n .sample-class { background-color: #FFFFFF; }\n }| # now write the manifest manually as we are in a test - Phoenix.LiveView.ColocatedCSS.compile() + Phoenix.LiveView.ColocatedAssets.compile() assert manifest = File.read!( Path.join( Mix.Project.build_path(), - "phoenix-colocated-css/phoenix_live_view/colocated.css" + "phoenix-colocated/phoenix_live_view/colocated.css" ) ) path = Path.relative_to( style, - Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view/") + Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view/") ) # style is in manifest @@ -198,7 +198,7 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do assert module_folders = File.ls!( - Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view") + Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view") ) assert folder = @@ -210,7 +210,7 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do Path.wildcard( Path.join( Mix.Project.build_path(), - "phoenix-colocated-css/phoenix_live_view/#{folder}/*.css" + "phoenix-colocated/phoenix_live_view/#{folder}/*.css" ) ) @@ -225,20 +225,20 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do ~s|@scope ([phx-css-SCOPE_HERE]) to ([phx-r] > *) { \n .sample-class { background-color: #FFFFFF; }\n }| # now write the manifest manually as we are in a test - Phoenix.LiveView.ColocatedCSS.compile() + Phoenix.LiveView.ColocatedAssets.compile() assert manifest = File.read!( Path.join( Mix.Project.build_path(), - "phoenix-colocated-css/phoenix_live_view/colocated.css" + "phoenix-colocated/phoenix_live_view/colocated.css" ) ) path = Path.relative_to( style, - Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view/") + Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view/") ) # style is in manifest @@ -275,9 +275,9 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do test "writes empty manifest when no colocated styles exist" do manifest = - Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view/colocated.css") + Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view/colocated.css") - Phoenix.LiveView.ColocatedCSS.compile() + Phoenix.LiveView.ColocatedAssets.compile() assert File.exists?(manifest) assert File.read!(manifest) == "" end diff --git a/test/phoenix_live_view/colocated_hook_test.exs b/test/phoenix_live_view/colocated_hook_test.exs index 18aa814905..191d3e6611 100644 --- a/test/phoenix_live_view/colocated_hook_test.exs +++ b/test/phoenix_live_view/colocated_hook_test.exs @@ -42,7 +42,7 @@ defmodule Phoenix.LiveView.ColocatedHookTest do assert File.read!(script) =~ "Hello, world!" # now write the manifest manually as we are in a test - Phoenix.LiveView.ColocatedJS.compile() + Phoenix.LiveView.ColocatedAssets.compile() assert manifest = File.read!( diff --git a/test/phoenix_live_view/colocated_js_test.exs b/test/phoenix_live_view/colocated_js_test.exs index 61e124ec1e..bf85a82752 100644 --- a/test/phoenix_live_view/colocated_js_test.exs +++ b/test/phoenix_live_view/colocated_js_test.exs @@ -43,7 +43,7 @@ defmodule Phoenix.LiveView.ColocatedJSTest do """ # now write the manifest manually as we are in a test - Phoenix.LiveView.ColocatedJS.compile() + Phoenix.LiveView.ColocatedAssets.compile() assert manifest = File.read!( @@ -104,7 +104,7 @@ defmodule Phoenix.LiveView.ColocatedJSTest do """ # now write the manifest manually as we are in a test - Phoenix.LiveView.ColocatedJS.compile() + Phoenix.LiveView.ColocatedAssets.compile() relative_script_path = Path.relative_to( @@ -168,7 +168,7 @@ defmodule Phoenix.LiveView.ColocatedJSTest do """ # now write the manifest manually as we are in a test - Phoenix.LiveView.ColocatedJS.compile() + Phoenix.LiveView.ColocatedAssets.compile() relative_script_path = Path.relative_to( @@ -214,7 +214,7 @@ defmodule Phoenix.LiveView.ColocatedJSTest do test "writes empty index.js when no colocated scripts exist" do manifest = Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view/index.js") - Phoenix.LiveView.ColocatedJS.compile() + Phoenix.LiveView.ColocatedAssets.compile() assert File.exists?(manifest) assert File.read!(manifest) == "export const hooks = {};\nexport default {};" end @@ -227,7 +227,7 @@ defmodule Phoenix.LiveView.ColocatedJSTest do end File.mkdir_p!(Path.join(node_path, "foo")) - Phoenix.LiveView.ColocatedJS.compile() + Phoenix.LiveView.ColocatedAssets.compile() symlink = Path.join( From a1dc5dee60470ac356fbea4df19893a9fce22a47 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Fri, 30 Jan 2026 16:18:21 +0100 Subject: [PATCH 04/11] Apply suggestion from @SteffenDE --- lib/phoenix_live_view/colocated_css.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view/colocated_css.ex b/lib/phoenix_live_view/colocated_css.ex index e447c1f5c4..a6b2557afe 100644 --- a/lib/phoenix_live_view/colocated_css.ex +++ b/lib/phoenix_live_view/colocated_css.ex @@ -11,7 +11,7 @@ defmodule Phoenix.LiveView.ColocatedCSS do ```heex ``` From ab9c245ac13a3566d83d92677567bc04762b07d9 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Fri, 30 Jan 2026 16:19:06 +0100 Subject: [PATCH 05/11] Apply suggestions from code review --- lib/phoenix_live_view/colocated_css.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/phoenix_live_view/colocated_css.ex b/lib/phoenix_live_view/colocated_css.ex index a6b2557afe..13f2c3d978 100644 --- a/lib/phoenix_live_view/colocated_css.ex +++ b/lib/phoenix_live_view/colocated_css.ex @@ -29,7 +29,7 @@ defmodule Phoenix.LiveView.ColocatedCSS do ```heex ``` @@ -43,7 +43,7 @@ defmodule Phoenix.LiveView.ColocatedCSS do ```heex
From a92a86af652b0d2ca9b1a0988d75733faf7f2275 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Wed, 18 Mar 2026 14:48:38 +0100 Subject: [PATCH 06/11] scoper behaviour for colocated css (#4149) * scoper behaviour for colocated css * don't provide any default implemenation * skip firefox in e2e test for now * update docs --- lib/phoenix_live_view/colocated_css.ex | 387 ++++++++++-------- lib/phoenix_live_view/tag_engine/compiler.ex | 18 + lib/phoenix_live_view/tag_engine/parser.ex | 11 +- test/e2e/support/colocated_live.ex | 14 +- test/e2e/tests/colocated.spec.js | 8 +- test/phoenix_live_view/colocated_css_test.exs | 55 +-- test/support/colocated_css.ex | 87 ++++ 7 files changed, 358 insertions(+), 222 deletions(-) create mode 100644 test/support/colocated_css.ex diff --git a/lib/phoenix_live_view/colocated_css.ex b/lib/phoenix_live_view/colocated_css.ex index 13f2c3d978..7f9fea242b 100644 --- a/lib/phoenix_live_view/colocated_css.ex +++ b/lib/phoenix_live_view/colocated_css.ex @@ -1,126 +1,256 @@ defmodule Phoenix.LiveView.ColocatedCSS do @moduledoc ~S''' - A special HEEx `:type` that extracts any CSS styles from a colocated ` + To bundle and use colocated CSS with esbuild, you can import it like this in your `app.js` file: + + ```javascript + import "phoenix-colocated/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 + + ``` + + ## Global CSS + + If all you need is global CSS, which is extracted as is, you can define your ColocatedCSS module like this: + + ```elixir + defmodule MyAppWeb.ColocatedCSS do + use Phoenix.LiveView.ColocatedCSS + + @impl true + def transform("style", _attrs, css, _meta) do + {:ok, css, []} + end + end ``` ## 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). + The idea behind scoped CSS is to restrict the elements that CSS rules apply to + to only the elements of the current template / component. + + One way to scope CSS is to use [CSS `@scope` rules](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope). + A scoped `ColocatedCSS` module using CSS `@scope` can be implemented like this: - To prevent Colocated CSS styles from being scoped to the current template you can provide the `global` - attribute, for example: + ```elixir + defmodule MyAppWeb.ColocatedScopedCSS do + use Phoenix.LiveView.ColocatedCSS + + @impl true + def transform("style", attrs, css, meta) do + validate_opts!(attrs) + + {scope, css} = do_scope(css, attrs, meta) + + {:ok, css, [root_tag_attribute: {"phx-css-#{scope}", true}]} + end + + defp validate_opts!(opts) do + Enum.each(opts, fn {key, val} -> validate_opt!({key, val}, Map.delete(opts, key)) end) + end + + defp validate_opt!({"lower-bound", val}, _other_opts) when val in ["inclusive", "exclusive"] do + :ok + end + + defp validate_opt!({"lower-bound", val}, _other_opts) do + raise ArgumentError, + ~s|expected "inclusive" or "exclusive" for the `lower-bound` attribute of colocated css, got: #{inspect(val)}| + end + + defp validate_opt!(_opt, _other_opts), do: :ok + + defp do_scope(css, opts, meta) do + scope = hash("#{meta.module}_#{meta.line}: #{css}") + + root_tag_attribute = root_tag_attribute() + + upper_bound_selector = ~s|[phx-css-#{scope}]| + lower_bound_selector = ~s|[#{root_tag_attribute}]| + + lower_bound_selector = + case opts do + %{"lower-bound" => "inclusive"} -> lower_bound_selector <> " > *" + _ -> lower_bound_selector + end + + css = "@scope (#{upper_bound_selector}) to (#{lower_bound_selector}) { #{css} }" + + {scope, css} + end + + defp hash(string) do + # It is important that we do not pad + # the Base32 encoded value as we use it in + # an HTML attribute name and = (the padding character) + # is not valid. + string + |> then(&:crypto.hash(:md5, &1)) + |> Base.encode32(case: :lower, padding: false) + end + + defp root_tag_attribute() do + case Application.get_env(:phoenix_live_view, :root_tag_attribute) do + configured_attribute when is_binary(configured_attribute) -> + configured_attribute + + configured_attribute -> + message = """ + a global :root_tag_attribute must be configured to use scoped css + + Expected global :root_tag_attribute to be a string, got: #{inspect(configured_attribute)} + + The global :root_tag_attribute is usually configured to `"phx-r"`, but it needs to be explicitly enabled in your configuration: + + config :phoenix_live_view, root_tag_attribute: "phx-r" + + You can also use a different value than `"phx-r"`. + """ + + raise ArgumentError, message + end + end + end + ``` + + This module transforms a given style tag like ```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: + into + + ```css + @scope ([phx-css-abc123]) to ([phx-r]) { + .my-class { color: red; } + } + ``` + + and if `lower-bound` is set to `inclusive`, it transforms it into + + ```css + @scope ([phx-css-abc123]) to ([phx-r] > *) { + .my-class { color: red; } + } + ``` + + This applies any styles defined in the colocated CSS block to any element between a local root and a component. + It relies on LiveView's global `:root_tag_attribute`, which is an attribute that LiveView adds to all root tags, + no matter if colocated CSS is used or not. When the browser encounters a `phx-r` attribute, which in this case + is assumed to be the configured global `:root_tag_attribute`, it stops the scoped CSS rule. + + Another way to implement scoped CSS could be to use PostCSS and apply an attribute to all tags in a template. + ''' + + @doc """ + Callback invoked for each colocated CSS tag. + + The callback receives the tag name, the string attributes and a map of metadata. + + For example, for the following tag: ```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 - > work on the browsers you need, be sure to check [Can I Use?](https://caniuse.com/css-cascade-scope) for - > browser compatibility. + The callback would receive the following arguments: - > #### Tip {: .info} - > - > When Colocated CSS is scoped via the `@scope` rule, all "local root" elements in the given template serve as scoping roots. - > "Local root" elements are the outermost elements of the template itself and the outermost elements of any content passed to - > child components' slots. 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. 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). + * tag_name: `"style"` + * attrs: %{"data-scope" => "my-scope"} + * meta: `%{file: "path/to/file.ex", module: MyApp.MyModule, line: 10}` - Colocated CSS uses the same folder structures as Colocated JS. See `Phoenix.LiveView.ColocatedJS` for more information. + The callback must return either `{:ok, scoped_css, directives}` or `{:error, reason}`. + If an error is returned, it will be logged and the CSS will not be extracted. - To bundle and use colocated CSS with esbuild, you can import it like this in your `app.js` file: + The `directives` needs to be a keyword list that supports the following options: - ```javascript - import "phoenix-colocated/my_app/colocated.css" - ``` + * `root_tag_attribute`: A `{key, value}` tuple that will be added a + an attribute to all "root tags" of the template defining the scoped CSS tag. + See the section on root tags below for more information. + * `tag_attribute`: A `{key, value}` tuple that will be added as an attribute to + all HTML tags in the template defining the scoped CSS tag. - 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: + ## Root tags - ```html - - ``` + In a HEEx template, all outermost tags are considered "root tags" and are + affected by the `root_tag_attribute` directive. If a template uses components, + the slots of those components are considered as root tags as well. - ## Options + Here's an example showing which elements would be considered root tags: - Colocated CSS can be configured through the attributes of the ` """ @@ -209,7 +211,7 @@ defmodule Phoenix.LiveViewTest.E2E.ColocatedLive do defp scoped_colocated_css(assigns) do ~H""" -
@@ -274,7 +276,7 @@ defmodule Phoenix.LiveViewTest.E2E.ColocatedLive do defp scoped_css_inner_block_two(assigns) do ~H""" -
@@ -296,7 +298,7 @@ defmodule Phoenix.LiveViewTest.E2E.ColocatedLive do defp scoped_css_slot_two(assigns) do ~H""" -
@@ -308,7 +310,7 @@ defmodule Phoenix.LiveViewTest.E2E.ColocatedLive do defp scoped_exclusive_lower_bound_colocated_css(assigns) do ~H""" - """ @@ -68,53 +68,6 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do :code.delete(__MODULE__.TestGlobalComponent) :code.purge(__MODULE__.TestGlobalComponent) end - - test "raises for invalid global attribute value" do - message = ~r/expected nil or true for the `global` attribute of colocated css, got: "bad"/ - - assert_raise ParseError, - message, - fn -> - defmodule TestBadGlobalAttrComponent do - use Phoenix.Component - - def fun(assigns) do - ~H""" - - """ - end - end - end - after - :code.delete(__MODULE__.TestBadGlobalAttrComponent) - :code.purge(__MODULE__.TestBadGlobalAttrComponent) - end - - test "raises if scoped css specific options are provided" do - message = - ~r/colocated css must be scoped to use the `lower-bound` attribute, but `global` attribute was provided/ - - assert_raise ParseError, - message, - fn -> - defmodule TestScopedAttrWhileGlobalComponent do - use Phoenix.Component - - def fun(assigns) do - ~H""" - - """ - end - end - end - after - :code.delete(__MODULE__.TestScopedAttrWhileGlobalComponent) - :code.purge(__MODULE__.TestScopedAttrWhileGlobalComponent) - end end describe "scoped styles" do @@ -124,7 +77,7 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do def fun(assigns) do ~H""" - """ @@ -189,7 +142,7 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do def fun(assigns) do ~H""" - """ @@ -260,7 +213,7 @@ defmodule Phoenix.LiveView.ColocatedCSSTest do def fun(assigns) do ~H""" - """ diff --git a/test/support/colocated_css.ex b/test/support/colocated_css.ex new file mode 100644 index 0000000000..686b320c88 --- /dev/null +++ b/test/support/colocated_css.ex @@ -0,0 +1,87 @@ +defmodule Phoenix.LiveViewTest.Support.ColocatedScopedCSS do + use Phoenix.LiveView.ColocatedCSS + + @impl true + def transform("style", attrs, css, meta) do + validate_opts!(attrs) + + {scope, css} = do_scope(css, attrs, meta) + + {:ok, css, [root_tag_attribute: {"phx-css-#{scope}", true}]} + end + + defp validate_opts!(opts) do + Enum.each(opts, fn {key, val} -> validate_opt!({key, val}, Map.delete(opts, key)) end) + end + + defp validate_opt!({"lower-bound", val}, _other_opts) when val in ["inclusive", "exclusive"] do + :ok + end + + defp validate_opt!({"lower-bound", val}, _other_opts) do + raise ArgumentError, + ~s|expected "inclusive" or "exclusive" for the `lower-bound` attribute of colocated css, got: #{inspect(val)}| + end + + defp validate_opt!(_opt, _other_opts), do: :ok + + defp do_scope(css, opts, meta) do + scope = hash("#{meta.module}_#{meta.line}: #{css}") + + root_tag_attribute = root_tag_attribute() + + upper_bound_selector = ~s|[phx-css-#{scope}]| + lower_bound_selector = ~s|[#{root_tag_attribute}]| + + lower_bound_selector = + case opts do + %{"lower-bound" => "inclusive"} -> lower_bound_selector <> " > *" + _ -> lower_bound_selector + end + + css = "@scope (#{upper_bound_selector}) to (#{lower_bound_selector}) { #{css} }" + + {scope, css} + end + + defp hash(string) do + # It is important that we do not pad + # the Base32 encoded value as we use it in + # an HTML attribute name and = (the padding character) + # is not valid. + string + |> then(&:crypto.hash(:md5, &1)) + |> Base.encode32(case: :lower, padding: false) + end + + defp root_tag_attribute() do + case Application.get_env(:phoenix_live_view, :root_tag_attribute) do + configured_attribute when is_binary(configured_attribute) -> + configured_attribute + + configured_attribute -> + message = """ + a global :root_tag_attribute must be configured to use scoped css + + Expected global :root_tag_attribute to be a string, got: #{inspect(configured_attribute)} + + The global :root_tag_attribute is usually configured to `"phx-r"`, but it needs to be explicitly enabled in your configuration: + + config :phoenix_live_view, root_tag_attribute: "phx-r" + + You can also use a different value than `"phx-r"`. + """ + + raise ArgumentError, message + end + end +end + +defmodule Phoenix.LiveViewTest.Support.ColocatedGlobalCSS do + use Phoenix.LiveView.ColocatedCSS + + @impl true + def transform("style", _attrs, css, _meta) do + {:ok, css, []} + end +end From ef9bd8231edc570ebbb50dde0aa6d21ebc4b33fb Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Fri, 27 Mar 2026 12:04:56 +0100 Subject: [PATCH 07/11] fixup --- lib/phoenix_live_view/colocated_assets.ex | 9 ++-- lib/phoenix_live_view/colocated_css.ex | 51 +++++++++++++--------- lib/phoenix_live_view/tag_engine/parser.ex | 2 +- test/e2e/tests/colocated.spec.js | 12 ++--- 4 files changed, 41 insertions(+), 33 deletions(-) diff --git a/lib/phoenix_live_view/colocated_assets.ex b/lib/phoenix_live_view/colocated_assets.ex index 0679c1986e..5e50da8af1 100644 --- a/lib/phoenix_live_view/colocated_assets.ex +++ b/lib/phoenix_live_view/colocated_assets.ex @@ -13,10 +13,7 @@ defmodule Phoenix.LiveView.ColocatedAssets do defstruct [:filename, :data, :callback, :component] end - @callback build_manifests(colocated :: t()) :: list({binary(), binary()}) - @callback finalize(target_directory :: String.t()) :: :ok - - @optional_callbacks [finalize: 1] + @callback build_manifests(colocated :: list(t())) :: list({binary(), binary()}) @doc """ Extracts content into the colocated directory. @@ -91,12 +88,12 @@ defmodule Phoenix.LiveView.ColocatedAssets do target_dir = target_dir() modules = subdirectories(target_dir) - Enum.flat_map(modules, fn module_folder -> + modules + |> Enum.flat_map(fn module_folder -> module = Module.concat([Path.basename(module_folder)]) process_module(module_folder, module) end) |> Enum.group_by(fn {callback, _file} -> callback end, fn {_callback, file} -> file end) - |> Map.new() end defp process_module(module_folder, module) do diff --git a/lib/phoenix_live_view/colocated_css.ex b/lib/phoenix_live_view/colocated_css.ex index 7f9fea242b..c53724c6c9 100644 --- a/lib/phoenix_live_view/colocated_css.ex +++ b/lib/phoenix_live_view/colocated_css.ex @@ -221,7 +221,7 @@ defmodule Phoenix.LiveView.ColocatedCSS do ``` """ - @callback transform(tag_name :: binary(), attrs :: map(), css :: binary(), meta :: keyword()) :: + @callback transform(tag_name :: binary(), attrs :: map(), css :: binary(), meta :: map()) :: {:ok, binary(), keyword()} | {:error, term()} defmacro __using__(_) do @@ -244,13 +244,18 @@ defmodule Phoenix.LiveView.ColocatedCSS do validate_phx_version!() opts = Map.new(attributes) - {data, directives} = extract(opts, text_content, meta, module) - # we always drop colocated CSS from the rendered output - {:ok, "", data, directives} + case extract(opts, text_content, meta, module) do + {data, directives} -> + # we always drop colocated CSS from the rendered output + {:ok, "", data, directives} + + nil -> + {:ok, ""} + end end - def __transform__(_ast, _meta) do + def __transform__(_ast, _meta, _module) do raise ArgumentError, "ColocatedCSS can only be used on style tags" end @@ -269,26 +274,32 @@ defmodule Phoenix.LiveView.ColocatedCSS do line: meta.env.line } - {styles, directives} = - case module.transform("style", opts, text_content, transform_meta) do - {:ok, scoped_css, directives} when is_binary(scoped_css) and is_list(directives) -> - {scoped_css, directives} + case module.transform("style", opts, text_content, transform_meta) do + {:ok, styles, directives} when is_binary(styles) and is_list(directives) -> + filename = "#{meta.env.line}_#{hash(styles)}.css" - {:error, reason} -> - raise ArgumentError, - "#{inspect(module)} returned an error: #{inspect(reason)}" + data = + Phoenix.LiveView.ColocatedAssets.extract( + __MODULE__, + meta.env.module, + filename, + styles, + nil + ) - other -> - raise ArgumentError, - "expected the ColocatedCSS implementation to return {:ok, scoped_css, directives} or {:error, term}, got: #{inspect(other)}" - end + {data, directives} - filename = "#{meta.env.line}_#{hash(styles)}.css" + {:error, reason} -> + IO.warn( + "ColocatedCSS module #{inspect(module)} returned an error, skipping: #{inspect(reason)}" + ) - data = - Phoenix.LiveView.ColocatedAssets.extract(__MODULE__, meta.env.module, filename, styles, nil) + nil - {data, directives} + other -> + raise ArgumentError, + "expected the ColocatedCSS implementation to return {:ok, scoped_css, directives} or {:error, term}, got: #{inspect(other)}" + end end defp hash(string) do diff --git a/lib/phoenix_live_view/tag_engine/parser.ex b/lib/phoenix_live_view/tag_engine/parser.ex index fd7cfe71cb..fb692899e9 100644 --- a/lib/phoenix_live_view/tag_engine/parser.ex +++ b/lib/phoenix_live_view/tag_engine/parser.ex @@ -707,7 +707,7 @@ defmodule Phoenix.LiveView.TagEngine.Parser do when type in [:tag_attribute, :root_tag_attribute] do throw_syntax_error!( """ - expected {name, value} for :root_tag_attribute directive from macro component #{inspect(module)}, got: #{inspect(other)} + expected {name, value} for :#{type} directive from macro component #{inspect(module)}, got: #{inspect(other)} name must be a compile-time string, and value must be a compile-time string or true """, diff --git a/test/e2e/tests/colocated.spec.js b/test/e2e/tests/colocated.spec.js index f619e914b9..bbe0f7dbd2 100644 --- a/test/e2e/tests/colocated.spec.js +++ b/test/e2e/tests/colocated.spec.js @@ -71,7 +71,7 @@ test("scoped colocated css works", async ({ page, browserName }) => { await expect(blueLocator).toHaveCount(6); - for (const shouldBeBlue in blueLocator.all()) { + for (const shouldBeBlue of await blueLocator.all()) { await expect(shouldBeBlue).toHaveCSS("background-color", "rgb(0, 0, 255)"); } @@ -79,7 +79,7 @@ test("scoped colocated css works", async ({ page, browserName }) => { await expect(noneLocator).toHaveCount(5); - for (const shouldBeTransparent in noneLocator.all()) { + for (const shouldBeTransparent of await noneLocator.all()) { await expect(shouldBeTransparent).toHaveCSS( "background-color", "rgba(0, 0, 0, 0)", @@ -109,7 +109,7 @@ test("scoped colocated css lower bound inclusive/exclusive works", async ({ await expect(lowerBoundContainerLocator).toHaveCount(2); - for (const shouldBeFlex in lowerBoundContainerLocator.all()) { + for (const shouldBeFlex of await lowerBoundContainerLocator.all()) { await expect(shouldBeFlex).toHaveCSS("display", "flex"); } @@ -117,15 +117,15 @@ test("scoped colocated css lower bound inclusive/exclusive works", async ({ await expect(inclusiveFlexItemsLocator).toHaveCount(3); - for (const shouldFlex in inclusiveFlexItemsLocator.all()) { + for (const shouldFlex of await inclusiveFlexItemsLocator.all()) { await expect(shouldFlex).toHaveCSS("flex", "1"); } - const exclusiveFlexItemsLocator = page.locator('[data-test-inclusive="yes"]'); + const exclusiveFlexItemsLocator = page.locator('[data-test-inclusive="no"]'); await expect(exclusiveFlexItemsLocator).toHaveCount(3); - for (const shouldntFlex in exclusiveFlexItemsLocator.all()) { + for (const shouldntFlex of await exclusiveFlexItemsLocator.all()) { await expect(shouldntFlex).not().toHaveCSS("flex", "1"); } }); From 24fee687dc695c0f35f3bd52aae60545d7a46b3d Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Fri, 27 Mar 2026 12:46:04 +0000 Subject: [PATCH 08/11] fix flex assertions --- test/e2e/tests/colocated.spec.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/e2e/tests/colocated.spec.js b/test/e2e/tests/colocated.spec.js index bbe0f7dbd2..fbf8dcf529 100644 --- a/test/e2e/tests/colocated.spec.js +++ b/test/e2e/tests/colocated.spec.js @@ -99,7 +99,14 @@ test("scoped colocated css works", async ({ page, browserName }) => { test("scoped colocated css lower bound inclusive/exclusive works", async ({ page, + browserName, }) => { + // TODO: revisit when + // https://bugzilla.mozilla.org/show_bug.cgi?id=1980526 + // https://bugzilla.mozilla.org/show_bug.cgi?id=1914188 + // are fixed. + test.skip(browserName === "firefox", "Currently broken"); + await page.goto("/colocated"); await syncLV(page); @@ -118,7 +125,7 @@ test("scoped colocated css lower bound inclusive/exclusive works", async ({ await expect(inclusiveFlexItemsLocator).toHaveCount(3); for (const shouldFlex of await inclusiveFlexItemsLocator.all()) { - await expect(shouldFlex).toHaveCSS("flex", "1"); + await expect(shouldFlex).toHaveCSS("flex", "1 1 0%"); } const exclusiveFlexItemsLocator = page.locator('[data-test-inclusive="no"]'); @@ -126,6 +133,6 @@ test("scoped colocated css lower bound inclusive/exclusive works", async ({ await expect(exclusiveFlexItemsLocator).toHaveCount(3); for (const shouldntFlex of await exclusiveFlexItemsLocator.all()) { - await expect(shouldntFlex).not().toHaveCSS("flex", "1"); + await expect(shouldntFlex).toHaveCSS("flex", "0 1 auto"); } }); From 4651788ece55a4ba1a23b9268adb2dc16e9a7029 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Fri, 27 Mar 2026 12:59:42 +0000 Subject: [PATCH 09/11] typo --- lib/phoenix_live_view/colocated_css.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view/colocated_css.ex b/lib/phoenix_live_view/colocated_css.ex index c53724c6c9..56df2f9a89 100644 --- a/lib/phoenix_live_view/colocated_css.ex +++ b/lib/phoenix_live_view/colocated_css.ex @@ -186,7 +186,7 @@ defmodule Phoenix.LiveView.ColocatedCSS do The `directives` needs to be a keyword list that supports the following options: - * `root_tag_attribute`: A `{key, value}` tuple that will be added a + * `root_tag_attribute`: A `{key, value}` tuple that will be added as an attribute to all "root tags" of the template defining the scoped CSS tag. See the section on root tags below for more information. * `tag_attribute`: A `{key, value}` tuple that will be added as an attribute to From 2ee76349247d81c2aa91ab78113e87adeb4c2df5 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Fri, 27 Mar 2026 13:12:12 +0000 Subject: [PATCH 10/11] fix --- lib/phoenix_live_view/colocated_assets.ex | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/phoenix_live_view/colocated_assets.ex b/lib/phoenix_live_view/colocated_assets.ex index 5e50da8af1..8bed38e635 100644 --- a/lib/phoenix_live_view/colocated_assets.ex +++ b/lib/phoenix_live_view/colocated_assets.ex @@ -165,15 +165,17 @@ defmodule Phoenix.LiveView.ColocatedAssets do File.ln_s(relative_node_modules_path, Path.join(target_dir(), "node_modules")), false <- Keyword.get(global_settings(), :disable_symlink_warning, false) do disable_hint = """ - If you don't use colocated hooks / js or you don't need to import files from "assets/node_modules" - in your hooks, you can simply disable this warning by setting + If you don't use colocated hooks / js / css or you don't need to import files from "assets/node_modules" + in your colocated assets, you can simply disable this warning by setting config :phoenix_live_view, :colocated_assets, disable_symlink_warning: true """ IO.warn(""" - Failed to symlink node_modules folder for Phoenix.LiveView.ColocatedJS: #{inspect(reason)} + Failed to symlink node_modules folder for colocated assets: #{inspect(reason)} + + See the documentation for Phoenix.LiveView.ColocatedJS for details. On Windows, you can address this issue by starting your Windows terminal at least once with "Run as Administrator" and then running your Phoenix application.#{is_fallback && "\n\n" <> disable_hint} @@ -237,7 +239,6 @@ defmodule Phoenix.LiveView.ColocatedAssets do config :phoenix_live_view, :colocated_assets, ... - instead. """) end From 365ebc6be73119cd87d2a99de4b289fe1f78f08c Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Tue, 14 Apr 2026 12:03:02 +0200 Subject: [PATCH 11/11] Apply suggestion from @SteffenDE --- lib/phoenix_live_view/colocated_assets.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view/colocated_assets.ex b/lib/phoenix_live_view/colocated_assets.ex index 8bed38e635..cbbe441cfb 100644 --- a/lib/phoenix_live_view/colocated_assets.ex +++ b/lib/phoenix_live_view/colocated_assets.ex @@ -268,7 +268,7 @@ defmodule Phoenix.LiveView.ColocatedAssets do phoenix_live_view: [colocated_assets: ...] ] - in your mix.exs instead. + in your mix.exs project configuration. """) end end