Reaching the document
A component owns its own subtree. Sometimes the thing it has to affect is not in there: the page must not scroll behind an open drawer, the tab needs a title, a dialog belongs at the top of the document rather than three flex containers deep.
The imperative answer is one line, and it is the wrong one:
@mounted lock() {
document.documentElement.classList.add("nav-locked"); // ✗
}
It is a second copy of state you already hold. menuOpen is the truth; the class is a
duplicate that has to be kept in step by hand, removed when the component goes away, and remembered
by whoever adds the next handler that touches the same state. ramonda-check
reports it.
Sort the answer by what is being set.
Styling that depends on state → a class you already render
Render the class where the state lives, and let the stylesheet reach up:
render() {
return <div className={this.menuOpen ? "body nav-open" : "body"}>{this.props.children}</div>;
}
html:has(.body.nav-open),
html:has(.body.nav-open) body {
overflow: hidden;
}
:has() is what lets the document act on a class held by a descendant. Nothing writes to
documentElement, so there is no second copy to keep in step, no cleanup to forget, and no class
left behind when the component unmounts.
Lead with the server, because that is the argument that decides it. The class is in the markup the server sends, so the page is right on the first paint. An imperative write cannot be: it lands after hydration, so there is always a moment of the wrong thing on screen — a scrollbar that appears and vanishes, a flash of the light theme.
This is what the documentation site itself ships for its drawer.
:has() is Chrome 105, Safari 15.4, Firefox 121. Below that the rule simply does not apply, which
is worth choosing deliberately: the drawer still opens and closes, the page behind it just moves as
it did before.
Tags in <head> → the Head hook
Title, description, canonical, Open Graph. Head is the proven shape for reaching
outside your subtree reactively, through one code path on the server and the client:
this.use(Head, (self: Article) => ({ title: self.post.title }));
Content that must live elsewhere in the DOM → Portal
A dialog, a toast, a tooltip that must escape an overflow: hidden ancestor.
Portal renders it into a container outside your root while keeping it in
your component tree — so context still reaches it and it is destroyed with its owner.
An arbitrary value on <html> or <body> → no answer yet
data-theme="dark", lang="sr", style="--accent: #c33".
:has() selects on a class but cannot carry a value, so the first answer does not stretch to cover
this. renderDocument takes lang and bodyClass, but it is static and
server-only — one value for the whole build, not one a component can change.
So this case has no declarative answer today, and rather than leave that implied: nothing in the
Ramonda repository sets an attribute on <html> or <body> from a component. It is a hole in the
design, not one anybody has fallen into.
If you need it now, an imperative write in @mounted with its undo in @destroyed is what there
is. ramonda-check will say so, and there is no way to silence it on the line —
the // ramonda-check-ignore directive covers a path the analyzer cannot resolve, not this. It is a
warning, so it prints and your build still passes; write a comment beside the line saying which
of the cases above did not fit, so the next reader knows it was a decision.
If it is ever built it will not be called Body: lang belongs on <html>, so one hook has to
cover both, shaped like Head — last mount wins, restored on @destroyed, one path for the server
and the client.
Not code you can write today — a sketch, deliberately unhighlighted so it cannot be mistaken for something to copy:
this.use(Shell, (self: App) => ({
html: { lang: self.locale, "data-theme": self.theme },
body: { class: self.dense ? "dense" : "" },
}));
A command is not any of this
scrollIntoView(), focus(), select(), getBoundingClientRect() — these tell the browser to
do something. They have no declarative form, nothing is duplicated by calling them, and they are
never reported. Reach for a ref and call them.
The line is not "never touch the DOM". It is that rendering done imperatively is a second copy of state, and a command is not.