0030: manageTitle(fn) — reactive <title> sync
Status: Accepted Date: 2026-05-11
Context
ADR 0008 shipped head(content) —
an SSR-only helper that appends HTML to the <head> accumulator
the renderer surfaces via renderToString({ extractHead: true }).
Components call it to emit static <title> and <meta> tags into
the SSR shell.
head() is explicitly a no-op on the client. Once the SSR-rendered
<head> is parsed by the browser, the client-side bindings can't
update it — the SSR markup is the only source of truth. This works
for the first paint, but breaks on SPA navigation: navigate('/about')
swaps the route view but leaves the page title unchanged. ADR 0008's
deferred non-features called this out:
No client-side head management. Phase 1 only emits the head markup on the server. Reactive head element management on the client is a follow-up (likely splits into a
@purityjs/headpackage).
The most common piece of head state that needs to track navigation
is the <title>. Browsers display it in the tab strip, screen
readers announce it on route change, history entries use it. The
other head bits (<meta name="description">, OG tags) matter less
for SPA UX — search engines crawl the static SSR, social previews
fetch the URL fresh.
This ADR ships a focused reactive helper for <title> only.
Broader reactive head management stays deferred (its design space
spans removal / dedup / per-route ownership and warrants its own
ADR).
Decision
Add manageTitle(fn) to @purityjs/core. Isomorphic:
- Server. Once at call time, the function emits
<title>${fn()}</title>into the SSR head accumulator (via the existinghead()mechanism). Returns a no-op teardown. - Client. Wraps
fnin awatch()that writes the result todocument.title. Re-runs on every dependency change. Returns the watch teardown.
import { manageTitle, html } from '@purityjs/core';
function App() {
manageTitle(() => `Hello ${userName()}`);
return html`<h1>Hi ${userName()}</h1>`;
}
On SSR, the rendered head contains <title>Hello Anonymous</title>.
On the client, when userName() changes, document.title updates
synchronously after the next watch flush.
Concretely:
manageTitle(fn: () => string): () => void— accepts a signal-reading function. The function is called inside awatch()on the client; subscribers track exactly as they would inside a template binding.- Server behavior: emits
<title>${escapedTitle}</title>throughhead()so the SSR head accumulator picks it up. Escaping uses the existing SSRescHtmlto keep tags / quotes in titles safe. MultiplemanageTitlecalls during one SSR render emit multiple<title>tags; the browser ignores all but the last per HTML spec — that's identical to multiplehead(html\<title>…`)` calls, so no special dedup needed. - Client behavior: synchronous title write inside the
watch()callback. No microtask wrap — title changes are cheap and idempotent. The watch auto-disposes when the surrounding component (if any) unmounts; outside of a component context the watch lives until manual teardown. - Return value: the watch teardown on the client; a no-op on the server (no listener to remove). Apps that want to stop managing the title call the returned function.
- Reads stale on hydration: when the client hydrates over
the SSR-rendered title, the first
manageTitlecall runs the watch and writesdocument.title = fn(). Iffn()produces exactly the SSR-rendered title, the write is a no-op; if it produces something different (because some client-only state shifted between SSR and hydration), the title updates. This matches the documented "self-heal" behavior ofenableHydration TextRewrite(ADR 0007).
Explicit non-features
- No reactive
<meta>/ OG tag management. Out of scope for this ADR. A follow-upmanageMeta(name, fn)would extend the pattern; defer until apps actually need it. - No template-tag form. A reactive
<title>could be a template that's mounted into a synthetic head node. Rejected: the function form is direct, side-effecting, and matches the existinghead()shape. The template form adds a render pipeline that bypasses the existing head accumulator. - No SSR-side dedup. If two components call
manageTitleduring the same render, both<title>tags are emitted. The HTML spec says only the last<title>element in<head>applies; same behavior as directhead(html\<title>…`)` calls today. - No "title format" helper. Apps wanting prefix/suffix
patterns (
'My Site — ') build the string themselves insidefn. AmanageTitle({ template: 'My Site — :title' })abstraction is over-engineered for Phase 1. - No undoing on teardown. When the watch is torn down on the
client,
document.titleis NOT reverted. Apps that want restore-on-unmount semantics save the original title before calling and restore it manually. - No interaction with the
<title>rendered byhead(). Both can coexist — a route can emit a static<title>viahead(html\<title>X</title>`)and a reactive<title>viamanageTitle(() => …). The SSR emits both; the browser uses the last; the client'smanageTitle` then takes over.
Consequences
Positive:
- Closes the documented ADR 0008 non-feature for the most
visible client-side head case. SPA navigation now updates the
tab title without manual
document.title = …calls in every route component. - Isomorphic API. One function, both runtimes. Tree-shakes when unused.
- ~15 LOC of new code. Watch +
document.title =on the client; onehead()call on the server. - Composes with
currentPath()(ADR 0011),loaderData()(ADR 0026), and any user signal. Apps wire upmanageTitle(() => loaderData<{title}>()?.title ?? 'Default')trivially.
Negative:
- Reactive head management is broader than
<title>alone. Apps with reactive meta needs still write their ownwatch(() => { document.querySelector('meta[name=description]') ?.setAttribute('content', …) }). Documented as a non-feature; follow-up ADRs can extend. - The watch lifetime is the caller's responsibility outside a
component context. Apps wiring
manageTitleat module load capture a permanent watch until manual teardown — matches the othermanage*helpers (scroll/focus/transitions).
Neutral:
- One new export (
manageTitle). The existinghead()stays unchanged. - Tests cover SSR head emission (head[] contains the rendered
<title>tag) and client-side watch behavior (jsdomdocument.titleupdates on signal change).
Alternatives considered
Make head() reactive when given a function (auto-rerun on
signal change, re-emit). Rejected: head() is SSR-only and
designed for one-shot string output. Reactive re-emit would
require client mutation of the SSR-rendered nodes — a larger
design (selecting, dedup, removal) than this ADR's scope.
Custom Element <purity-title> with a value attribute
bound to a signal. Declarative and template-friendly. Rejected:
adds a Custom Element to the bundle; the function form composes
inside any component already.
Ship a manageHead(reactiveFn) that updates both SSR head +
client document.title/<meta> via direct DOM queries.
Generalises this ADR to all head elements. Rejected for Phase 1:
client-side DOM queries to find existing elements + create new
ones for missing tags get complicated quickly (per-name dedup,
attribute mutation vs element replacement). manageTitle is the
80% case shipped on its own; broader management follows when
the use cases crystallise.
Always wrap the title in a fixed template (e.g.
'Purity — :title'). Rejected: apps with different prefixes have
to provide the whole string anyway. Format helpers compose
trivially in user-land.
Take a signal directly rather than a function
(manageTitle(titleSignal)). Rejected: forces apps to build a
signal even when the title comes from loaderData() or
currentPath() directly. The function form accepts both
(signals are callable, loaderData()<T>() is a function call).