🌐

SSR Declarative Shadow DOM

One of the big missing pieces of web components has historically been the ability to render them on the server.

Enhance’s approach

A while ago I heard about Enhance - a web components-first approach to building web applications. Their offering has the ability to render on the server, but via their own special interface of a render method which takes a state and html templating function.

It makes sense that if you know the function signatures ahead of time, you can just call them in your NodeJS code.

Declarative Shadow DOM

The HTML spec is apparently moving almost as fast as CSS is these days, as out of nowhere the Declarative Shadow DOM has appeared.

This new set of APIs allows you to define what a shadow root should be immediately instantiated into. So if you’re in a server side context, in literally any language, you can define what the shadow root should be and on the first browser render… Boom, there’s your shadow root.

It should be noted that the elements are static, there’s no event listeners or any Javascript attached to them. But they’re there, visible and ready to be used.

Static initialization blocks

Another neat little trick from Javascript is the new static initialization blocks, which will run immediately when the class is loaded. This turns out to be the perfect place to define a web component.

class MyComponent extends HTMLElement {
  static {
    customElements.define("my-component", MyComponent);
  }
}

A bare bones example

My favourite backend language is Elixir, so I’ll show you just how easy it is to define a Server Side Rendered web component in a Phoenix Heex template.

defmodule MyAppWeb.Components.MyComponent do
  use MyAppWeb, :component

  def render(assigns) do
    assigns = assigns
    |> assign_new(:name, fn -> "World" end)
    ~H"""
    <div>
        <h1>DSD</h1>
        <hello-world>
            <template shadowrootmode="open">
                <span>Hello <%= @name %></span>
            </template>
        </hello-world>
    </div>
    """
  end
end

So what actually gets rendered with the above?

<div>
  <h1>DSD</h1>
  <hello-world>
    <!-- Shadow DOM -->
    <!-- In here, styles are encapsulated -->
    <span>Hello World</span>
    <!-- /Shadow DOM -->
  </hello-world>
</div>

A slightly more complex component

Let’s take it one step further and increase the complexity by implementing a calendar component.

<calendar-view>
  <template shadowrootmode="open">
        <style>
            /*
            Styles are encapsulated by default, so we can import
            a global stylesheet for use inside the shadow DOM.
            */
          @import '/styles/shadow.css';

          .calendar {
            max-width: var(--max-width, 250px);
            margin: 0 auto;
            font-size: var(--font-size-0);
            container-type: inline-size;
          }

          .header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: var(--size-1);
          }

          .header h2 {
            white-space: nowrap;
            font-size: var(--font-size-1);
          }

          @container (max-width: 180px) {
            .header h2 {
              font-size: var(--font-size-0);
            }
          }

          .days {
            display: grid;
            grid-template-columns: repeat(7, 1fr);
            gap: var(--day-gap, 0px);
            text-align: center;
          }

          :is(.day, button) {
            padding: var(--day-padding, var(--size-1));
            text-decoration: none;
            color: var(--text-color);
            border: 1px solid var(--border-color, transparent);
            outline: 1px solid var(--outline-color, transparent);
            background-color: var(--background-color, transparent);
            border-radius: var(--radius-2);
          }

          :is(button, .day):hover {
            --background-color: var(--mantle);
          }

          :is(button, .day):focus-visible {
            --background-color: var(--mantle);
            --outline-color: var(--sapphire);
          }

          .day.empty {
            pointer-events: none;
          }

          .weekday {
            font-weight: bold;
            padding: var(--day-padding, var(--size-1));
            color: var(--surface2);
          }

          button {
            background: none;
            border: none;
            cursor: pointer;
            padding: 0.5rem;
            color: var(--text-color);
          }

          .today {
            --border-color: var(--blue);
          }
        </style>

        <div class="calendar">
          <div class="header">
            <button class="prev-month"></button>
            <h2 part="month-name"><%= format_month_year(@current_date) %></h2>
            <button class="next-month"></button>
          </div>

          <div class="days">
            <div class="weekday">Mo</div>
            <div class="weekday">Tu</div>
            <div class="weekday">We</div>
            <div class="weekday">Th</div>
            <div class="weekday">Fr</div>
            <div class="weekday">Sa</div>
            <div class="weekday">Su</div>

            <%= for day <- calendar_days(@current_date) do %>
              <%= if day == :empty do %>
                <div class="day empty"></div>
              <% else %>
                <a
                  href={~p"/day/#{Date.to_iso8601(day)}"}
                  class={"day #{if Date.compare(day, Date.utc_today()) == :eq, do: "today"}"}
                  data-date={Date.to_iso8601(day)}
                >
                  <%= day.day %>
                </a>
              <% end %>
            <% end %>
          </div>
        </div>
    </template>
</calendar-view>

And then some Javascript to implement the interactivity.

class Calendar extends HTMLElement {
  static {
    customElements.define("calendar-view", Calendar);
  }

  connectedCallback() {
    this.shadowRoot.querySelector(".prev-month").addEventListener(
      "click",
      () => {
        const currentDate = this.getCurrentDate();
        const prevMonth = new Date(
          currentDate.getFullYear(),
          currentDate.getMonth() - 1
        );
        this.navigateToMonth(prevMonth);
      },
      { signal: this.abortController.signal }
    );

    this.shadowRoot.querySelector(".next-month").addEventListener(
      "click",
      () => {
        const currentDate = this.getCurrentDate();
        const nextMonth = new Date(
          currentDate.getFullYear(),
          currentDate.getMonth() + 1
        );
        this.navigateToMonth(nextMonth);
      },
      { signal: this.abortController.signal }
    );
  }

  getCurrentDate() {
    const dateAttr =
      this.shadowRoot.querySelector(".day[data-date]")?.dataset.date;
    return dateAttr ? new Date(dateAttr) : new Date();
  }

  async navigateToMonth(date) {
    const monthNames = [
      "January",
      "February",
      "March",
      "April",
      "May",
      "June",
      "July",
      "August",
      "September",
      "October",
      "November",
      "December",
    ];

    const firstDay = new Date(date.getFullYear(), date.getMonth(), 1);
    const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0);
    const startOffset = firstDay.getDay();

    // Update month display
    this.shadowRoot.querySelector("[part='month-name']").textContent = `${
      monthNames[date.getMonth()]
    } ${date.getFullYear()}`;

    // Generate calendar grid
    const calendarGridDays = this.shadowRoot.querySelectorAll(
      ".calendar .days .day"
    );
    calendarGridDays.forEach((day) => day.remove());

    // Add empty cells for days before the first of the month
    const calendarGrid = this.shadowRoot.querySelector(".calendar .days");
    Array.from({ length: startOffset }).forEach(() => {
      calendarGrid.appendChild(createDayElement(""));
    });

    // Add actual days of the month
    Array.from({ length: lastDay.getDate() }).forEach((_, index) => {
      const dayDate = new Date(
        date.getFullYear(),
        date.getMonth(),
        index + 1,
        12
      );
      const dayElement = createDayElement(
        index + 1,
        dayDate.toISOString().split("T")[0]
      );
      calendarGrid.appendChild(dayElement);
    });
  }
}

function createDayElement(content, dateAttr = null) {
  let day;
  if (dateAttr) {
    day = document.createElement("a");
    day.className = "day";
    day.dataset.date = dateAttr;
    day.href = `/day/${dateAttr}`;
    day.innerHTML = content;
  } else {
    day = document.createElement("div");
    day.className = "day empty";
  }
  return day;
}

The above is a bit verbose, but basically it sets up event listeners for the previous and next month buttons, and updates the rendered HTML with new days when clicked between months.

I think this showcases pretty nicely the power of the Declarative Shadow DOM. Its a tool that pushes the boundaries of when rendering occurs all the way to the server, leaving the browser to just be a passive consumer of the rendered HTML, before hydrating the DOM, adding interactivity. Exactly the dream of SSR!

References