Skip to content

Commit 7af7eaf

Browse files
fix(shell): emit htmlAttrs on <html> instead of dropping them
htmlAttrs was declared, typed and merged, then thrown away. head.ts typed it, process.ts merged config over runtime useHead() onto the render path, and generateDocumentShell neither destructured it nor emitted it — the tag was hardcoded to `<html lang="${lang}">`. Its sibling bodyAttrs was emitted, and the docs already advertised htmlAttrs as "sets attributes on <html>", so the gap read as an oversight rather than a decision. It blocks the migration the DOCTYPE rule asks for. A layout that scopes its design tokens to the root element — html.marketing { --bg: … }, needed when two layouts define the same custom properties and the router keeps both stylesheets alive — has no way to put that class back once it deletes its hand-written shell. Every workaround is worse than the rule: relocating to body.marketing strips :root-anchored properties, a pre-paint script hand-writes the vanilla JS the standards exist to delete, and keeping the DOCTYPE violates the rule. - generateDocumentShell emits htmlAttrs, symmetric with bodyAttrs. htmlAttrs.lang wins over the lang option rather than emitting lang twice (browsers keep the first, silently ignoring the more specific one). Values are escaped and unsafe attribute names dropped. The bare tag is byte-identical when htmlAttrs is unset. - applyHtmlAttrs merges them into a hand-written <html> too, so whether the attribute lands doesn't depend on who wrote the document element. class unions with the template's own; everything else overrides. - AppHeadConfig gains htmlAttrs — config-level use wasn't even expressible in TS, though process.ts already merged it. - mergeHtmlAttrs unions class between config and page, so a global structural class doesn't vanish the moment one page adds its own. - The router reconciles <html> across an SPA layout change. Driven by the data-stx-html-class / data-stx-html-attrs markers the shell emits, NOT by diffing the element: the live root also carries the color-mode boot's dark class and data-reduced-motion, which no server response contains and a blind diff would strip on every navigation. Without this the class is right on first paint and stale after the first hop, which is the case that motivated it. - The runtime useHead (signals.ts) applied only htmlAttrs.lang while the module impl applied all of them — dual-impl drift, with the root class as the one thing the client dropped. Both now apply every key, and both merge class rather than replacing it, which would clobber the boot script's dark. Closes #1798
1 parent 316f9e2 commit 7af7eaf

12 files changed

Lines changed: 822 additions & 12 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,11 +497,35 @@ export default {
497497
title: 'My App',
498498
meta: [{ name: 'description', content: '...' }],
499499
bodyClass: 'dark min-h-screen',
500+
htmlAttrs: { class: 'h-full', 'data-app': 'storefront' },
500501
},
501502
},
502503
}
503504
```
504505

506+
### Attributes on `<html>`
507+
508+
`htmlAttrs` is the only way to reach the root element once a template stops
509+
writing its own shell — which matters when design tokens are scoped to it
510+
(`html.marketing { --bg: … }`) rather than to `:root` globally.
511+
512+
Set them globally in config, or per-page/per-layout from `<script server>`:
513+
514+
```html
515+
<script server>
516+
useHead({ htmlAttrs: { class: 'marketing' } })
517+
</script>
518+
```
519+
520+
Page-level `class` unions with the config one; every other attribute overrides.
521+
`htmlAttrs.lang` takes precedence over the `lang` option.
522+
523+
The router reconciles these across SPA navigation, so a class one layout scopes
524+
its tokens to is removed when you navigate to a layout that doesn't declare it.
525+
It reconciles only what stx wrote (tracked via `data-stx-html-class` /
526+
`data-stx-html-attrs` on the root element) — classes and attributes added at
527+
runtime, like the pre-paint color-mode `dark` class, are left alone.
528+
505529
Layouts are pure content fragments:
506530
```html
507531
<!-- layouts/default.stx -->

docs/api/composables-ref.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,12 @@ useHead({
604604
| `link` | `Array<Record<string, string>>` | Appends `<link>` tags (deduped by rel+href) |
605605
| `script` | `Array<{ src?: string, innerHTML?: string, async?: boolean, defer?: boolean }>` | Appends `<script>` tags |
606606
| `bodyAttrs` | `{ class?: string }` | Adds classes to `<body>` |
607-
| `htmlAttrs` | `{ lang?: string }` | Sets attributes on `<html>` |
607+
| `htmlAttrs` | `Record<string, string>` | Sets attributes on `<html>`. `class` merges (it never clobbers the pre-paint color-mode class); everything else is set outright |
608+
609+
Called from `<script server>`, `htmlAttrs` also lands in the server-rendered
610+
`<html>` tag — the way to put a class on the root element from a layout, e.g.
611+
for design tokens scoped as `html.marketing { --bg: … }`. The router reconciles
612+
those across SPA navigation. See [Document Shell](../ARCHITECTURE.md#document-shell).
608613

609614
### useSeoMeta
610615

packages/router/src/client.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,34 @@ else {
857857
document.documentElement.lang=doc.documentElement.lang;
858858
}
859859
860+
// ── Reconcile <html> attributes (stacksjs/stx#1798) ──
861+
// A layout that scopes its design tokens to the root element
862+
// (html.marketing { --bg: … }) needs that class to LEAVE when you
863+
// navigate to a layout that doesn't want it — otherwise both token sets
864+
// match at once and the second layout paints with the first one's
865+
// palette. Head stylesheets are additive across a swap, so the class is
866+
// the only thing disambiguating them.
867+
//
868+
// Only what stx wrote is touched, per the markers emitted by
869+
// document-shell.ts. Diffing the whole element against the incoming
870+
// document would strip the color-mode boot's dark class and
871+
// data-reduced-motion, which exist only on the live page.
872+
if(doc.documentElement){
873+
var curRoot=document.documentElement,incRoot=doc.documentElement;
874+
var tokens=function(el,attr){var v=el.getAttribute(attr);return v?v.split(/\\s+/).filter(Boolean):[]};
875+
var prevCls=tokens(curRoot,'data-stx-html-class'),nextCls=tokens(incRoot,'data-stx-html-class');
876+
prevCls.forEach(function(c){if(nextCls.indexOf(c)===-1)curRoot.classList.remove(c)});
877+
nextCls.forEach(function(c){curRoot.classList.add(c)});
878+
if(nextCls.length)curRoot.setAttribute('data-stx-html-class',nextCls.join(' '));
879+
else curRoot.removeAttribute('data-stx-html-class');
880+
881+
var prevNames=tokens(curRoot,'data-stx-html-attrs'),nextNames=tokens(incRoot,'data-stx-html-attrs');
882+
prevNames.forEach(function(n){if(nextNames.indexOf(n)===-1)curRoot.removeAttribute(n)});
883+
nextNames.forEach(function(n){var v=incRoot.getAttribute(n);if(v!==null)curRoot.setAttribute(n,v)});
884+
if(nextNames.length)curRoot.setAttribute('data-stx-html-attrs',nextNames.join(' '));
885+
else curRoot.removeAttribute('data-stx-html-attrs');
886+
}
887+
860888
window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url}}));
861889
862890
// Execute page scripts FIRST — they define setup functions and set _latestSetup
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
/**
2+
* The router reconciles `<html>` attributes across an SPA layout change
3+
* (stacksjs/stx#1798).
4+
*
5+
* A layout that scopes its design tokens to the root element — `html.marketing
6+
* { --bg: … }` — needs that class to LEAVE when navigation lands on a layout
7+
* that defines the same custom properties differently. Head stylesheets are
8+
* deliberately additive across a swap (see the <link> reconcile), so both token
9+
* sheets are live simultaneously and the root class is the only thing telling
10+
* them apart. Without this, the destination page paints with the entry page's
11+
* palette.
12+
*
13+
* The reconcile is driven by the markers the document shell emits, NOT by
14+
* diffing the element: the live root also carries a `dark` class from the
15+
* pre-paint color-mode boot and `data-reduced-motion` from the animation
16+
* runtime, neither of which appears in any server response. A blind diff would
17+
* strip them on every navigation.
18+
*/
19+
import { afterEach, describe, expect, it } from 'bun:test'
20+
import { Window } from 'very-happy-dom'
21+
import { getRouterScript } from '../src/client'
22+
23+
const originalGlobals = {
24+
window: globalThis.window,
25+
document: globalThis.document,
26+
location: globalThis.location,
27+
history: globalThis.history,
28+
fetch: globalThis.fetch,
29+
CustomEvent: globalThis.CustomEvent,
30+
Event: globalThis.Event,
31+
DOMParser: globalThis.DOMParser,
32+
}
33+
34+
afterEach(() => {
35+
Object.assign(globalThis, originalGlobals)
36+
})
37+
38+
function installRouter(html: string, fetchImpl: typeof fetch) {
39+
const window = new Window({ url: 'http://localhost/' })
40+
window.document.write(html)
41+
;(window as any).stx = {}
42+
;(window as any).__stxRouterConfig = {
43+
cache: false,
44+
prefetch: false,
45+
progress: false,
46+
viewTransitions: false,
47+
}
48+
49+
Object.assign(globalThis, {
50+
window,
51+
document: window.document,
52+
location: window.location,
53+
history: window.history,
54+
fetch: fetchImpl,
55+
CustomEvent: window.CustomEvent,
56+
Event: window.Event,
57+
DOMParser: window.DOMParser,
58+
})
59+
60+
new Function(getRouterScript())()
61+
62+
return window as Window & { stxRouter: any }
63+
}
64+
65+
function fullPage(html: string) {
66+
return async () => new Response(html, { status: 200, headers: { 'Content-Type': 'text/html' } })
67+
}
68+
69+
const MARKETING = `
70+
<html lang="en" class="marketing dark" data-stx-html-class="marketing" data-theme="sunset" data-stx-html-attrs="data-theme">
71+
<head>
72+
<meta name="stx-layout" content="layouts/marketing.stx">
73+
<meta name="stx-layout-group" content="app">
74+
</head>
75+
<body><main>Landing</main></body>
76+
</html>
77+
`
78+
79+
const APP_PAGE = `
80+
<html lang="en">
81+
<head>
82+
<meta name="stx-layout" content="layouts/app.stx">
83+
<meta name="stx-layout-group" content="app">
84+
</head>
85+
<body><main>Dashboard</main></body>
86+
</html>
87+
`
88+
89+
describe('router — <html> attribute reconcile', () => {
90+
it('removes the class the previous layout scoped its tokens to', async () => {
91+
const window = installRouter(MARKETING, fullPage(APP_PAGE))
92+
93+
await window.stxRouter.navigate('/dashboard')
94+
95+
const root = window.document.documentElement
96+
expect(root.classList.contains('marketing')).toBe(false)
97+
expect(root.hasAttribute('data-stx-html-class')).toBe(false)
98+
})
99+
100+
it('keeps runtime-owned classes the server never sent', async () => {
101+
// `dark` comes from the pre-paint color-mode boot script. It is not in the
102+
// destination document, and dropping it flashes the wrong theme mid-nav.
103+
const window = installRouter(MARKETING, fullPage(APP_PAGE))
104+
105+
await window.stxRouter.navigate('/dashboard')
106+
107+
expect(window.document.documentElement.classList.contains('dark')).toBe(true)
108+
})
109+
110+
it('applies the destination layout class', async () => {
111+
const window = installRouter(MARKETING, fullPage(`
112+
<html lang="en" class="docs" data-stx-html-class="docs">
113+
<head>
114+
<meta name="stx-layout" content="layouts/docs.stx">
115+
<meta name="stx-layout-group" content="app">
116+
</head>
117+
<body><main>Docs</main></body>
118+
</html>
119+
`))
120+
121+
await window.stxRouter.navigate('/docs')
122+
123+
const root = window.document.documentElement
124+
expect(root.classList.contains('docs')).toBe(true)
125+
expect(root.classList.contains('marketing')).toBe(false)
126+
expect(root.classList.contains('dark')).toBe(true)
127+
expect(root.getAttribute('data-stx-html-class')).toBe('docs')
128+
})
129+
130+
it('keeps a class both layouts declare', async () => {
131+
const window = installRouter(MARKETING, fullPage(`
132+
<html lang="en" class="marketing wide" data-stx-html-class="marketing wide">
133+
<head>
134+
<meta name="stx-layout" content="layouts/pricing.stx">
135+
<meta name="stx-layout-group" content="app">
136+
</head>
137+
<body><main>Pricing</main></body>
138+
</html>
139+
`))
140+
141+
await window.stxRouter.navigate('/pricing')
142+
143+
const root = window.document.documentElement
144+
expect(root.classList.contains('marketing')).toBe(true)
145+
expect(root.classList.contains('wide')).toBe(true)
146+
})
147+
148+
it('removes and updates non-class attributes it owns', async () => {
149+
const window = installRouter(MARKETING, fullPage(`
150+
<html lang="en" dir="rtl" data-stx-html-attrs="dir">
151+
<head>
152+
<meta name="stx-layout" content="layouts/app.stx">
153+
<meta name="stx-layout-group" content="app">
154+
</head>
155+
<body><main>RTL</main></body>
156+
</html>
157+
`))
158+
159+
await window.stxRouter.navigate('/ar')
160+
161+
const root = window.document.documentElement
162+
expect(root.getAttribute('dir')).toBe('rtl')
163+
// data-theme was stx-owned on the entry page and absent on the destination
164+
expect(root.hasAttribute('data-theme')).toBe(false)
165+
expect(root.getAttribute('data-stx-html-attrs')).toBe('dir')
166+
})
167+
168+
it('leaves the root alone when neither document declares any', async () => {
169+
const window = installRouter(`
170+
<html lang="en" class="dark" data-reduced-motion="false">
171+
<head>
172+
<meta name="stx-layout" content="layouts/app.stx">
173+
<meta name="stx-layout-group" content="app">
174+
</head>
175+
<body><main>Home</main></body>
176+
</html>
177+
`, fullPage(APP_PAGE))
178+
179+
await window.stxRouter.navigate('/dashboard')
180+
181+
const root = window.document.documentElement
182+
expect(root.classList.contains('dark')).toBe(true)
183+
expect(root.getAttribute('data-reduced-motion')).toBe('false')
184+
})
185+
186+
it('still mirrors lang', async () => {
187+
const window = installRouter(MARKETING, fullPage(`
188+
<html lang="fr">
189+
<head>
190+
<meta name="stx-layout" content="layouts/app.stx">
191+
<meta name="stx-layout-group" content="app">
192+
</head>
193+
<body><main>Bonjour</main></body>
194+
</html>
195+
`))
196+
197+
await window.stxRouter.navigate('/fr')
198+
199+
expect(window.document.documentElement.getAttribute('lang')).toBe('fr')
200+
})
201+
})

0 commit comments

Comments
 (0)