HelixOverviewComponentsMapsGraphIntroductionInstallationDeriving a themeSandboxTheme builderVisual testingCortexDocsEngramBuildingTraceBuildingRecallDeclaredServicesInsightsGet in touchSource on GitHub
Essentials
Server renderingEvery component renders on a server with no DOM. If you only use Helix
components, they work in your server components with no configuration. If you
extend a component, follow the rules below. They are the complete set.The server has no DOMLit's element shim has attributes and no element tree. querySelector and
this.children are undefined, and any code that walks the DOM throws during
rendering.We found these two examples in Helix the first time each component got a server
spec:// Select. `display` is read during render and reached the render root.private get listbox(){returnthis.renderRoot?.querySelector('[part="listbox"]')??null}// Menu item. `hasSubmenu` is read during render and spread its children.get hasSubmenu():boolean {return[...(this.children??[])].some((child)=>child.slot==='submenu')}Before we added those two guards, neither component could render on a server.
Both worked in every browser, because a browser always has a render root and an
element tree.Read the element tree through this.knownChildren, in willUpdate or in a
getter that the render calls. On a client it holds every child, and on a server
it holds none. The server renders the "not known yet" state, and the client's
first render already has the children. If you read the tree after the render,
every client renders twice, and Lit's dev build warns about that.this.childrenKnown is false on a server, where the children cannot be read. A
list on a server therefore does not render its "Nothing to show" message over
items it cannot see.Declarative shadow DOMIf you render with Lit SSR, the declarative shadow DOM it writes hydrates in the
browser. Load Lit's hydration support before the components:import'@lit-labs/ssr-client/lit-element-hydrate-support.js'import'@fusion.dev/helix/button'Hydration assumes the markup already matches the client's first render. If a
value differs, hydration does not write it, and the page keeps the server's
value. If a template differs, hydration throws. While hydrating, a component
renders exactly what the server rendered. Anything it can read only in a browser
waits for the next update:this.hydrating is true for the render that hydrates.this.knownChildren is empty during it, as on the server.this.finishHydration(), called from firstUpdated, requests the update
that reads what was skipped.The browser does two things before any script can listen. Components handle
them this way:A declarative shadow root reports its slots as it is parsed. A component
does not rely on slotchange alone to find what is slotted. It reads its
children first and uses slotchange for later changes.The parser fetches images. A load or error event can fire before
hydration. A component checks the state of the image (this.serverRendered)
and dispatches the event again a task later.HTML cannot declare manual slot assignment. A component that assigns its slots
by hand therefore gets a root that can assign only by name. On such a root
(this.assignsByName), and only there, the component writes slot onto its
children with this.route().Run your page's own scripts after the definitions load. Hydration expects that
order. A property you set before hydration is one the server did not render
with.We render every component example in both tiers with Lit SSR, hydrate it in
Chromium, and compare it with the same example rendered on a client.React server components do not use declarative shadow DOM. Their output is
replayed through DOM APIs, and declarative shadow DOM does not activate for
nodes that a script inserts. See React.Never initialize state in connectedCallbackSSR does not call connectedCallback. Derive state from properties, or set
initial state in willUpdate, which SSR calls.Guard generated idsif(!this.id){this.id=`helix-${crypto.randomUUID()}`}The server writes the id into the HTML. On the client, the id is already
present, so the component does not generate a new one, and both ids match.
Without the guard, the ids differ, and the association breaks on hydration.Put semantics on the host and presentation in the shadow rootIDREFs do not cross shadow boundaries, so aria-labelledby inside a shadow root
cannot resolve to a light-DOM id. Use ElementInternals. It writes no
attributes, so the host stays exactly as you wrote it, and hydration finds no
difference.Labels have the same problem. <label for> cannot reach into a shadow root
either. For that reason:A field's label is rendered inside the control, and no wrapping element
renders it.A wrapping "form field" component can own the label only by passing it down
as a property.aria-label on a host has to be forwarded from where you wrote it.See Composition.Where an id cannot reach, use an element reference. Element references resolve
outward, so an element in a shadow root can point at an element in the light
DOM. Listbox uses one for aria-activedescendant, because its options are
your elements and the element that takes focus is in its shadow root. Test for
support before you use element references. Assigning to a property that an
engine does not implement does nothing and throws no error.A parent finds its childrenA child does not announce itself to its parent, and there is no ready event. A
parent cannot wait on a child that has not upgraded, so what the parent needs
from the child decides how it finds it:For what you slotted, read assignedElements on the slot, and read it again
on slotchange.For children that arrive, move, or change outside a slot, observe the light
DOM with a MutationObserver.For a child's property, wait on customElements.whenDefined. A custom element
has no properties before it upgrades.For a child's state that a script can change with no event, add a controller
to the child with addController. Its hostUpdated runs after every update
of the child.Where the child holds the answer, it finds its parent by identity with
closestInstance, as Accordion.Item does.Serve the values of a data tableA data table reads each cell from its column, and the server does not pass it
its columns, so it serves its headings and no values. Write the first page under
the columns as helix-table-row and helix-table-cell elements, in the table's
sort and page size. The table shows them until rows holds a row, then draws
its own rows in their place. Size its columns with columns on the table, which
the server sends, so the served rows are laid out at the widths the table draws.
See React for the server component version.A Table served with declarative shadow DOM lays out its headings and cells in
columns before it hydrates. A column of sentences takes its width from its text once the table
upgrades, so set columns on a table that has one.Write a server specEvery component has a *.ssr.spec.ts file. Breaking one rule for these specs
has produced a false pass twice:Never assert structure against the raw SSR string. Declarative shadow DOM
inlines the component's stylesheet into the markup you assert against. That
stylesheet names every part, host attribute, and token the component uses.
toContain('part="media"') can pass because of a selector, and
not.toContain('data-scrolled') can fail because of a :host([data-scrolled])rule. Neither result tells you what rendered.Use these helpers:withoutStyleshasPartpartTaghostTagMatch attribute bindings with a pattern, because they render across several
lines:expect(markup).toMatch(/<a\s+part="button"/)// ✅expect(markup).toContain('<a part="button"')// ❌ never matches