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/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_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 new file mode 100644 index 0000000000..99948d87c3 --- /dev/null +++ b/lib/phoenix_live_view/colocated_css.ex @@ -0,0 +1,309 @@ +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-css` 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 + + ``` + + **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, 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. 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. + + 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__) + ``` + + 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. + + ## Options + + Colocated CSS can be configured through the attributes of the ` + + <.scoped_css_component /> + +
+
     defmodule SyntaxHighlight do
       @behaviour Phoenix.Component.MacroComponent
@@ -159,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/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..5e82c27ba6 100644
--- a/test/e2e/tests/colocated.spec.js
+++ b/test/e2e/tests/colocated.spec.js
@@ -30,6 +30,40 @@ test("colocated JS works", async ({ page }) => {
   await expect(page.locator("#hello")).toBeVisible();
 });
 
+test("global colocated CSS works", async ({ page }) => {
+  await page.goto("/colocated");
+  await syncLV(page);
+
+  // 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 }) => {
   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..d3a88e6ced
--- /dev/null
+++ b/test/phoenix_live_view/colocated_css_test.exs
@@ -0,0 +1,142 @@
+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 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
+
+      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__)}\.TestScopedComponent$/
+             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/data-phx-css=".+"/, file_contents, "data-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 ==
+             "@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()
+
+    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__.TestScopedComponent)
+    :code.purge(__MODULE__.TestScopedComponent)
+  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
diff --git a/test/phoenix_live_view/html_engine_test.exs b/test/phoenix_live_view/html_engine_test.exs
index ce9bbbf50a..81d730090b 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,431 @@ defmodule Phoenix.LiveView.HTMLEngineTest do
       assert meta[:column] == 10
     end
   end
+
+  describe "data-phx-css attribute" do
+    test "is correctly applied to a single self-closing tag" do
+      enable_apply_css_scope_attribute()
+
+      source = "
" + scope = Phoenix.LiveView.TagEngine.generate_scope(source) + + rendered = + source + |> render() + |> normalize_whitespace() + + expected = ~s(
) + + assert rendered == normalize_whitespace(expected) + after + disable_apply_css_scope_attribute() + end + + test "is correctly applied to a single tag with body" do + enable_apply_css_scope_attribute() + + source = "
Test
" + scope = Phoenix.LiveView.TagEngine.generate_scope(source) + + rendered = + source + |> render() + |> normalize_whitespace() + + expected = ~s(
Test
) + + assert rendered == normalize_whitespace(expected) + after + disable_apply_css_scope_attribute() + end + + test "is correctly applied to multiple self-closing tags" do + enable_apply_css_scope_attribute() + + source = """ +
+
+
+ """ + + scope = Phoenix.LiveView.TagEngine.generate_scope(source) + + rendered = + source + |> render() + |> normalize_whitespace() + + expected = """ +
+
+
+ """ + + assert rendered == normalize_whitespace(expected) + after + disable_apply_css_scope_attribute() + end + + test "is correctly applied to multiple tags with bodies" do + enable_apply_css_scope_attribute() + + source = """ +
Test1
+
Test2
+
Test3
+ """ + + scope = Phoenix.LiveView.TagEngine.generate_scope(source) + + rendered = + source + |> render() + |> normalize_whitespace() + + expected = """ +
Test1
+
Test2
+
Test3
+ """ + + assert rendered == normalize_whitespace(expected) + after + disable_apply_css_scope_attribute() + end + + test "is correctly applied to all outermost tags, but not nested tags" do + enable_apply_css_scope_attribute() + + source = """ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """ + + scope = Phoenix.LiveView.TagEngine.generate_scope(source) + + rendered = + source + |> render() + |> normalize_whitespace() + + expected = """ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """ + + assert rendered == normalize_whitespace(expected) + after + disable_apply_css_scope_attribute() + end + + test "is correctly applied to contents of component inner_blocks" do + enable_apply_css_scope_attribute() + + source = """ +
+
+ +
+
+ Inner Block 1 +
+
+
+ +
+
+ Inner Block 2 +
+
+
+
+
+ """ + + scope = Phoenix.LiveView.TagEngine.generate_scope(source) + + pattern = + """ +
+
+
+
+
+ Inner Block 1 +
+
+
+
+
+
+ Inner Block 2 +
+
+
+
+
+ """ + |> normalize_whitespace() + |> Regex.compile!() + + rendered = + source + |> render() + |> normalize_whitespace() + + [inner_block_and_slot_scope, inner_block_and_slot_scope] = + Regex.run(pattern, rendered, capture: :all_but_first) + + refute scope == inner_block_and_slot_scope + after + disable_apply_css_scope_attribute() + end + + test "is correctly applied to contents of component named slots" do + enable_apply_css_scope_attribute() + + source = """ +
+
+ + <:test> +
+
+ Inner Block 1 +
+
+ +
+ + <:test> +
+
+ Inner Block 2 +
+
+ +
+
+
+ """ + + scope = Phoenix.LiveView.TagEngine.generate_scope(source) + + pattern = + """ +
+
+
+ +
+
+ +
+
+
+ """ + |> normalize_whitespace() + |> Regex.compile!() + + rendered = + source + |> render() + |> normalize_whitespace() + + [inner_block_and_slot_scope, inner_block_and_slot_scope] = + Regex.run(pattern, rendered, capture: :all_but_first) + + refute scope == inner_block_and_slot_scope + after + disable_apply_css_scope_attribute() + end + + test "is correctly applied to nested component calls with inner_blocks and slots" do + enable_apply_css_scope_attribute() + + source = """ +
+
+ +
+ +
+ +
+ <:test> +
+ +
+ +
+
+ <:test> +
+ +
+ +
+ <:test> +
+ +
+ +
+
+ +
+
+
+ """ + + scope = Phoenix.LiveView.TagEngine.generate_scope(source) + + pattern = + """ +
+
+
+
+
+
+

Simple

+
+ +
+
+ +
+
+
+ """ + |> normalize_whitespace() + |> Regex.compile!() + + rendered = + source + |> render() + |> normalize_whitespace() + + [ + 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) + + # 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 + + 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