Skip to content

Commit 7bcebf0

Browse files
committed
fix: hoist scripts/styles to head + data-layout for router detection
Two fixes for client-side navigation with light DOM layouts: 1. SSR hoistHeadTags: leading <script> and <style> tags from the body are moved to <head>. Tailwind browser script, @theme styles, and design tokens now load before body content — survive body swaps. 2. Client router: findLayoutShell now detects [data-layout] elements (not just custom elements with hyphens). swapSlotContent swaps only <main> children when using data-layout, keeping header/footer mounted across navigations. No flicker, no CSS loss.
1 parent 2dcc532 commit 7bcebf0

3 files changed

Lines changed: 82 additions & 38 deletions

File tree

examples/blog/app/layout.ts

Lines changed: 27 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -226,35 +226,34 @@ export default function RootLayout({ children }: { children: unknown }) {
226226
main article { margin: 0; }
227227
</style>
228228
229-
<!-- Shell: header -->
230-
<header class="sticky top-0 z-20 flex items-center gap-6 px-[clamp(var(--sp-4),4vw,var(--sp-6))] py-3 border-b border-border bg-[color-mix(in_oklch,var(--bg)_75%,transparent)] backdrop-blur-[18px] backdrop-saturate-[180%]">
231-
<a href="/" class="mr-auto inline-flex items-center gap-2 no-underline text-fg font-semibold text-[15px] leading-none tracking-tight">
232-
<span class="inline-block w-[22px] h-[22px] rounded-[6px] bg-gradient-to-br from-accent to-[color-mix(in_oklch,var(--accent)_55%,var(--fg))] shadow-[inset_0_0_0_1px_oklch(1_0_0/0.15),0_1px_4px_var(--accent-tint)]"></span>
233-
<span>webjs</span>
234-
<span class="text-fg-subtle mx-1 font-normal">/</span>
235-
<span>blog</span>
236-
</a>
237-
<nav class="flex gap-4 items-center">
238-
<a href="/" class="text-fg-muted no-underline font-medium text-[13px] leading-none tracking-[0.005em] transition-colors duration-[140ms] hover:text-fg">Posts</a>
239-
<a href="/about" class="text-fg-muted no-underline font-medium text-[13px] leading-none tracking-[0.005em] transition-colors duration-[140ms] hover:text-fg">About</a>
240-
<a href="/dashboard" class="text-fg-muted no-underline font-medium text-[13px] leading-none tracking-[0.005em] transition-colors duration-[140ms] hover:text-fg">Dashboard</a>
241-
<theme-toggle></theme-toggle>
242-
</nav>
243-
</header>
229+
<div data-layout>
230+
<header class="sticky top-0 z-20 flex items-center gap-6 px-[clamp(var(--sp-4),4vw,var(--sp-6))] py-3 border-b border-border bg-[color-mix(in_oklch,var(--bg)_75%,transparent)] backdrop-blur-[18px] backdrop-saturate-[180%]">
231+
<a href="/" class="mr-auto inline-flex items-center gap-2 no-underline text-fg font-semibold text-[15px] leading-none tracking-tight">
232+
<span class="inline-block w-[22px] h-[22px] rounded-[6px] bg-gradient-to-br from-accent to-[color-mix(in_oklch,var(--accent)_55%,var(--fg))] shadow-[inset_0_0_0_1px_oklch(1_0_0/0.15),0_1px_4px_var(--accent-tint)]"></span>
233+
<span>webjs</span>
234+
<span class="text-fg-subtle mx-1 font-normal">/</span>
235+
<span>blog</span>
236+
</a>
237+
<nav class="flex gap-4 items-center">
238+
<a href="/" class="text-fg-muted no-underline font-medium text-[13px] leading-none tracking-[0.005em] transition-colors duration-[140ms] hover:text-fg">Posts</a>
239+
<a href="/about" class="text-fg-muted no-underline font-medium text-[13px] leading-none tracking-[0.005em] transition-colors duration-[140ms] hover:text-fg">About</a>
240+
<a href="/dashboard" class="text-fg-muted no-underline font-medium text-[13px] leading-none tracking-[0.005em] transition-colors duration-[140ms] hover:text-fg">Dashboard</a>
241+
<theme-toggle></theme-toggle>
242+
</nav>
243+
</header>
244244
245-
<!-- Shell: main content -->
246-
<main class="block max-w-[760px] mx-auto px-[clamp(var(--sp-4),5vw,var(--sp-6))] pt-[72px] pb-[48px] min-h-screen">
247-
${children}
248-
</main>
245+
<main class="block max-w-[760px] mx-auto px-[clamp(var(--sp-4),5vw,var(--sp-6))] pt-[72px] pb-[48px] min-h-screen">
246+
${children}
247+
</main>
249248
250-
<!-- Shell: footer -->
251-
<footer class="max-w-[760px] mx-auto px-[clamp(var(--sp-4),5vw,var(--sp-6))] pt-[48px] pb-[72px] border-t border-border flex justify-between flex-wrap gap-3 text-fg-subtle font-mono text-[11px] leading-[1.4] tracking-[0.12em] uppercase">
252-
<span><span class="text-accent">&#9679;</span>&nbsp; webjs / demo</span>
253-
<span>
254-
<a href="/api/posts" class="text-inherit no-underline transition-colors duration-[140ms] hover:text-fg-muted">api</a>
255-
&nbsp;&middot;&nbsp;
256-
<a href="/__webjs/health" class="text-inherit no-underline transition-colors duration-[140ms] hover:text-fg-muted">health</a>
257-
</span>
258-
</footer>
249+
<footer class="max-w-[760px] mx-auto px-[clamp(var(--sp-4),5vw,var(--sp-6))] pt-[48px] pb-[72px] border-t border-border flex justify-between flex-wrap gap-3 text-fg-subtle font-mono text-[11px] leading-[1.4] tracking-[0.12em] uppercase">
250+
<span><span class="text-accent">&#9679;</span>&nbsp; webjs / demo</span>
251+
<span>
252+
<a href="/api/posts" class="text-inherit no-underline transition-colors duration-[140ms] hover:text-fg-muted">api</a>
253+
&nbsp;&middot;&nbsp;
254+
<a href="/__webjs/health" class="text-inherit no-underline transition-colors duration-[140ms] hover:text-fg-muted">health</a>
255+
</span>
256+
</footer>
257+
</div>
259258
`;
260259
}

packages/core/src/router-client.js

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -173,15 +173,28 @@ async function performNavigation(href, isPopState) {
173173
const currentShell = findLayoutShell(document.body);
174174
const newShell = findLayoutShell(doc.body);
175175

176-
if (currentShell && newShell && currentShell.tagName === newShell.tagName) {
177-
// Same layout — minimal swap: title + slot content only.
176+
if (currentShell && newShell &&
177+
(currentShell.tagName === newShell.tagName ||
178+
(currentShell.hasAttribute('data-layout') && newShell.hasAttribute('data-layout')))) {
179+
// Same layout — minimal swap: title + page content only.
178180
const newTitle = doc.querySelector('title');
179181
if (newTitle) document.title = newTitle.textContent || '';
180182

181-
const children = [...newShell.childNodes].filter(
183+
// For data-layout shells, swap only the <main> element's children
184+
// (header and footer stay mounted). For custom element shells with
185+
// shadow DOM, swap all non-DSD children (the old slot content).
186+
const currentMain = currentShell.hasAttribute('data-layout')
187+
? currentShell.querySelector('main')
188+
: currentShell;
189+
const newMain = newShell.hasAttribute('data-layout')
190+
? newShell.querySelector('main')
191+
: newShell;
192+
const target = currentMain || currentShell;
193+
const source = newMain || newShell;
194+
const children = [...source.childNodes].filter(
182195
(n) => !(n instanceof HTMLTemplateElement && /** @type any */ (n).getAttribute('shadowrootmode'))
183196
);
184-
swapSlotContent(currentShell, children);
197+
swapSlotContent(target, children);
185198
} else {
186199
// Different layout structure — full swap.
187200
// Move nodes directly from the parsed doc (preserves DSD shadow roots)
@@ -255,16 +268,22 @@ function swapSlotContent(shell, children) {
255268
}
256269

257270
/**
258-
* Walk body's direct children looking for the first custom element
259-
* (a tag with a hyphen in its name). In webjs apps this is typically
260-
* the layout shell: <blog-shell>, <doc-shell>, etc.
271+
* Walk body's direct children looking for the layout shell.
272+
*
273+
* Detection order:
274+
* 1. An element with `data-layout` attribute (light DOM shells).
275+
* 2. A custom element (tag name with a hyphen: <blog-shell>, etc.).
261276
*
262277
* Skips <script>, <style>, text nodes, and comments.
263278
*
264279
* @param {HTMLElement} body
265280
* @returns {Element | null}
266281
*/
267282
function findLayoutShell(body) {
283+
// data-layout attribute (light DOM convention)
284+
const marked = body.querySelector(':scope > [data-layout]');
285+
if (marked) return marked;
286+
// Custom element fallback (shadow DOM convention)
268287
for (const child of body.children) {
269288
if (child.tagName.includes('-')) return child;
270289
}

packages/server/src/ssr.js

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,13 +220,38 @@ async function collectMetadata(route, ctx, dev) {
220220
return meta;
221221
}
222222

223+
/**
224+
* Extract leading `<script>` and `<style>` tags from the body HTML and
225+
* hoist them into `<head>`. Ensures blocking scripts (e.g. Tailwind
226+
* browser runtime, theme bootstrap) run before any body content renders.
227+
*
228+
* @param {string} headHtml
229+
* @param {string} bodyHtml
230+
* @returns {{ head: string, body: string }}
231+
*/
232+
function hoistHeadTags(headHtml, bodyHtml) {
233+
const hoisted = [];
234+
const re = /^\s*(<(?:script|style)[\s>][\s\S]*?<\/(?:script|style)>)/i;
235+
let remaining = bodyHtml;
236+
let m;
237+
while ((m = re.exec(remaining)) !== null) {
238+
hoisted.push(m[1]);
239+
remaining = remaining.slice(m[0].length);
240+
}
241+
if (!hoisted.length) return { head: headHtml, body: bodyHtml };
242+
const newHead = headHtml.replace('</head>', hoisted.join('\n') + '\n</head>');
243+
return { head: newHead, body: remaining };
244+
}
245+
223246
/**
224247
* Buffered wrapper (error / not-found paths; no Suspense streaming).
225248
* @param {string} body
226249
* @param {{ metadata: Record<string,any>, moduleUrls: string[], dev: boolean }} opts
227250
*/
228251
function wrapInDocument(body, opts) {
229-
return wrapHead({ ...opts, streaming: false }) + body + `\n</body>\n</html>`;
252+
const headHtml = wrapHead({ ...opts, streaming: false });
253+
const { head, body: bodyOut } = hoistHeadTags(headHtml, body);
254+
return head + bodyOut + `\n</body>\n</html>`;
230255
}
231256

232257
/**
@@ -406,6 +431,7 @@ function deduplicatedPreloads(componentUrls, moduleUrls, graph, entryFiles, appD
406431
* @param {Record<string, any>} [metadata]
407432
*/
408433
function streamingHtmlResponse(headHtml, bodyHtml, ctx, status, req, url, metadata) {
434+
const { head, body: hoistedBody } = hoistHeadTags(headHtml, bodyHtml);
409435
const encoder = new TextEncoder();
410436
const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
411437
// Cache-Control from page/layout metadata — standard HTTP caching
@@ -418,12 +444,12 @@ function streamingHtmlResponse(headHtml, bodyHtml, ctx, status, req, url, metada
418444
}
419445

420446
if (!ctx.pending.length) {
421-
return new Response(headHtml + bodyHtml + '\n</body>\n</html>', { status, headers });
447+
return new Response(head + hoistedBody + '\n</body>\n</html>', { status, headers });
422448
}
423449

424450
const stream = new ReadableStream({
425451
async start(controller) {
426-
controller.enqueue(encoder.encode(headHtml + bodyHtml));
452+
controller.enqueue(encoder.encode(head + hoistedBody));
427453
try {
428454
// Loop: resolve all currently-pending promises in parallel; nested
429455
// Suspense inside resolved content adds more pending entries.

0 commit comments

Comments
 (0)