Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ef0774f
feat: deduplicate component CSS with shadowrootadpotedstylesheets
zeroedin May 8, 2026
473dc06
fix: add shadowrootadoptedstylesheets polyfill, remove old dsd polyfill
zeroedin May 8, 2026
f969cf1
fix: inject @sheet marker for SSR CSS deduplication
zeroedin May 8, 2026
5ce08bb
chore: lint
zeroedin May 8, 2026
cc3984e
chore: lint
zeroedin May 8, 2026
862a909
chore: lint
zeroedin May 8, 2026
4ed9da2
chore: lint
zeroedin May 8, 2026
ca64fe2
fix: correct accidental push of refactored style tags inlined with html
zeroedin May 8, 2026
1e6e146
fix: improved polyfill with deepQuery and to skip non-custom elements
zeroedin May 8, 2026
1a83921
Merge branch 'main' into poc/lit-ssr/css-modules
zeroedin May 8, 2026
27effad
fix: attempt to prevent FOUC by applying adopted stylesheets via Muta…
zeroedin May 11, 2026
84cd99c
docs: minify html/css template literals before SSR rendering
zeroedin May 11, 2026
3e3f892
docs: ensure adopted stylesheets gets loaded for demos
zeroedin May 11, 2026
caf13c7
chore: correct eslint config
zeroedin May 11, 2026
d27f3bc
chore: lint
zeroedin May 11, 2026
6dd5ca6
fix: html minifier source type
zeroedin May 11, 2026
2bfd04f
Merge branch 'main' into poc/lit-ssr/css-modules
zeroedin May 11, 2026
f1231ff
fix: hydration errors, minify exports.
zeroedin May 11, 2026
0ece0a1
fix: hydration errors, minify exports.
zeroedin May 11, 2026
3764f61
docs: serve compiled+minified JS artifacts instead of transpiling TS …
zeroedin May 11, 2026
653d945
chore: lint
zeroedin May 11, 2026
6fca5f3
fix: css markers lost with typescript-transform-lit-css switching to …
zeroedin May 12, 2026
77370c2
chore: lint
zeroedin May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion docs/_includes/layouts/base.njk
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,49 @@
background-color: light-dark(white, var(--rh-color-surface-darker, #1f1f1f));
}
</style>
{#- polyfill: apply shadowRootAdoptedStyleSheets as elements appear during parsing -#}
<script>
// shadowrootAadoptedStylesheet polyfill
(function() {
if ('shadowRootAdoptedStyleSheets' in HTMLTemplateElement.prototype) return;
var SHEET_ATTR = 'shadowRootAdoptedStyleSheets';
var HOST_SEL = '[' + SHEET_ATTR + ']:not([' + SHEET_ATTR + '=""])';
var sheets = new Map();
var applied = new WeakSet();
function collectStyles() {
document.querySelectorAll(
'style[type="module"][specifier]:not([specifier=""])'
).forEach(function(el) {
var spec = el.getAttribute('specifier').trim();
if (!sheets.has(spec)) {
var s = new CSSStyleSheet();
s.replaceSync(el.textContent);
sheets.set(spec, s);
}
});
}
function applyToHost(el) {
if (applied.has(el) || !el.shadowRoot) return;
applied.add(el);
el.shadowRoot.adoptedStyleSheets.push(
...el.getAttribute(SHEET_ATTR).trim().split(/\s+/)
.flatMap(function(n) { return sheets.has(n) ? [sheets.get(n)] : []; })
);
el.shadowRoot.querySelectorAll(HOST_SEL).forEach(applyToHost);
}
new MutationObserver(function(mutations) {
collectStyles();
for (var i = 0; i < mutations.length; i++) {
for (var j = 0; j < mutations[i].addedNodes.length; j++) {
var node = mutations[i].addedNodes[j];
if (node.nodeType !== 1) continue;
if (node.hasAttribute && node.hasAttribute(SHEET_ATTR)) applyToHost(node);
if (node.querySelectorAll) node.querySelectorAll(HOST_SEL).forEach(applyToHost);
}
}
}).observe(document.documentElement, { childList: true, subtree: true });
})();
</script>
<link rel="stylesheet" href="/styles/styles.css">

<script type="importmap">{{ importMap | dump(2) | safe }}</script>
Expand All @@ -37,7 +80,11 @@
{%- for tag in importElements -%}
<script type="module">import '@rhds/elements/{{tag}}/{{tag}}.js';</script>
{%- endfor -%}

<style>
rh-scheme-toggle:not(:defined) {
display: none;
}
</style>
<script async src="https://ga.jspm.io/npm:es-module-shims@1.8.0/dist/es-module-shims.js"></script>
</head>

Expand Down
8 changes: 7 additions & 1 deletion docs/_plugins/lit-ssr/lit-css-node.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { transform } from '@pwrs/lit-css';
import { readFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { fileURLToPath } from 'node:url';

interface HookContext {
Expand All @@ -15,7 +16,12 @@ export async function load(url: string, context: HookContext, nextLoad: LoadFunc
if (url.endsWith('.css')) {
if (!cache.has(url)) {
const filePath = fileURLToPath(new URL(url));
const css = await readFile(filePath, 'utf8');
const specifier = basename(filePath, '.css');
const raw = await readFile(filePath, 'utf8');
// Inject a marker so the SSR renderer can identify this stylesheet by name.
// CSSResult.cssText carries no filename, so without this the renderer can't
// deduplicate styles or assign a specifier for shadowrootadoptedstylesheets.
const css = `/* @sheet:${specifier} */${raw}`;
cache.set(url, await transform({ css, filePath }));
}
const format = 'module';
Expand Down
213 changes: 147 additions & 66 deletions docs/_plugins/lit-ssr/worker.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { CSSResult, CSSResultOrNative, LitElement, ReactiveController } from 'lit';
import type { CSSResult, LitElement, ReactiveController } from 'lit';
import type { RenderInfo } from '@lit-labs/ssr';
import type { RHDSSSRController } from '@rhds/elements/lib/ssr-controller.ts';
import type { RenderRequestMessage, RenderResponseMessage } from './lit.js';
import type { Thunk, ThunkedRenderResult } from '@lit-labs/ssr/lib/render-result.js';
import type { ThunkedRenderResult } from '@lit-labs/ssr/lib/render-result.js';

import '@patternfly/pfe-core/ssr-shims.js';

Expand Down Expand Up @@ -38,11 +38,16 @@ for (const bareSpec of imports) {
throw new Error(`${bareSpec} does not appear to be an element module`);
}
if (!customElements.get(conventionalTagName)) {
const spec = pathToFileURL(resolve(process.cwd(), bareSpec)).href.replace('.js', '.ts');
const spec = pathToFileURL(resolve(process.cwd(), bareSpec)).href.replace(
'.js',
'.ts'
);
try {
await import(spec);
if (!customElements.get(conventionalTagName)) {
throw new Error(`${conventionalTagName} declaration loaded, but not defined!`);
throw new Error(
`${conventionalTagName} declaration loaded, but not defined!`
);
}
} catch (e) {
console.warn((e as Error)?.message?.trim() || e);
Expand All @@ -52,86 +57,127 @@ for (const bareSpec of imports) {
}
/* eslint-enable no-console */

class RHDSSSRableRenderer extends LitElementRenderer {
static styleCache = new Map<string, Thunk>();
const SHEET_MARKER_RE = /^\/\*\s*@sheet:([\w-]+)\s*\*\//;
const specifierMap = new Map<string, string>();

static isRHDSSSRController(ctrl: ReactiveController): ctrl is RHDSSSRController {
class RHDSSSRableRenderer extends LitElementRenderer {
// Eagerly processed CSS strings, not thunks. connectedCallback() must store
// the final CSS in specifierMap before rendering so post-processing can build
// the shared <style type="module"> blocks for <head>. A thunk would be too late.
static styleCache = new Map<string, string>();

static isRHDSSSRController(
ctrl: ReactiveController
): ctrl is RHDSSSRController {
return !!(ctrl as RHDSSSRController).isRHDSSSRController;
}

#specifiers: string[] = [];

getControllers() {
const element = (this.element as LitElement & { _$EO: Set<ReactiveController> });
return Array.from(element._$EO ?? new Set())
.filter(RHDSSSRableRenderer.isRHDSSSRController);
const element = this.element as LitElement & {
_$EO: Set<ReactiveController>;
};
return Array.from(element._$EO ?? new Set()).filter(
RHDSSSRableRenderer.isRHDSSSRController
);
}

override renderShadow(renderInfo: RenderInfo): ThunkedRenderResult {
return [
// Render styles.
this.#renderStyles(),
// Thunk that sets up controllers first, then renders template
async () => {
for (const controller of this.getControllers()) {
if (controller.ssrSetup) {
await controller.ssrSetup(renderInfo);
}
// Extract @sheet: markers from elementStyles after the element is constructed.
// Builds #specifiers for this instance's host/template attributes, and populates
// the page-level specifierMap so each stylesheet is processed and emitted only once.
override connectedCallback() {
super.connectedCallback();
const styles =
(this.element.constructor as typeof LitElement).elementStyles ?? [];
for (const style of styles) {
const { cssText } = style as CSSResult;
const match = cssText.match(SHEET_MARKER_RE);
if (match) {
const [, specifier] = match;
this.#specifiers.push(specifier);
if (!specifierMap.has(specifier)) {
const stripped = cssText.slice(match[0].length).trimStart();
specifierMap.set(specifier, this.#processCSS(stripped, specifier));
}
return renderValue(
// @ts-expect-error: if upstream can do it, so can we
this.element.render(),
renderInfo,
);
},
];
}
}
}

override renderAttributes() {
const result = super.renderAttributes();
if (this.#specifiers.length > 0) {
result.push(
` shadowrootadoptedstylesheets="${this.#specifiers.join(' ')}"`
);
}
return result;
}

#renderStyles(): Thunk {
const styles = (this.element.constructor as typeof LitElement).elementStyles;
if (styles !== undefined && styles.length > 0) {
return () => [
override renderShadow(renderInfo: RenderInfo): ThunkedRenderResult {
const result: ThunkedRenderResult = [];

if (this.#specifiers.length > 0) {
result.push(`<!--@adopted:${this.#specifiers.join(' ')}-->`);
}

// Fallback for styles without @sheet: markers. Marked styles are already in
// specifierMap and will be emitted once in <head>. Anything else renders
// inline so nothing breaks.
const inlineStyles = (
(this.element.constructor as typeof LitElement).elementStyles ?? []
).filter(s => !SHEET_MARKER_RE.test((s as CSSResult).cssText));
if (inlineStyles.length > 0) {
result.push(() => [
'<style>',
...this.thunkStyles(styles),
...inlineStyles.map(s => this.#processCSS((s as CSSResult).cssText)),
'</style>',
];
} else {
return () => '';
]);
}
}

private thunkStyles(styles: CSSResultOrNative[]): Thunk[] {
return styles.flatMap(style => {
const { cssText } = style as CSSResult;
if (!RHDSSSRableRenderer.styleCache.has(cssText)) {
const processed = () => {
try {
const { code } = transform({
filename: 'constructed-stylesheet.css',
code: Buffer.from(cssText),
minify: true,
include: Features.Nesting,
});
// Fix lightningcss normalizing inherit to normal for color-scheme
// https://github.com/parcel-bundler/lightningcss/issues/821#issuecomment-3719524299
return code
.toString()
.replaceAll(
'color-scheme:normal',
'color-scheme:inherit',
);
} catch {
return cssText;
}
};
RHDSSSRableRenderer.styleCache.set(cssText, processed);
result.push(async () => {
for (const controller of this.getControllers()) {
if (controller.ssrSetup) {
await controller.ssrSetup(renderInfo);
}
}
return [RHDSSSRableRenderer.styleCache.get(cssText)!];
return renderValue(
// @ts-expect-error: if upstream can do it, so can we
this.element.render(),
renderInfo
);
});

return result;
}

#processCSS(cssText: string, cacheKey?: string): string {
const key = cacheKey ?? cssText;
if (!RHDSSSRableRenderer.styleCache.has(key)) {
try {
const { code } = transform({
filename: 'constructed-stylesheet.css',
code: Buffer.from(cssText),
minify: true,
include: Features.Nesting,
});
// Fix lightningcss normalizing inherit to normal for color-scheme
// https://github.com/parcel-bundler/lightningcss/issues/821#issuecomment-3719524299
RHDSSSRableRenderer.styleCache.set(
key,
code
.toString()
.replaceAll('color-scheme:normal', 'color-scheme:inherit')
);
} catch {
RHDSSSRableRenderer.styleCache.set(key, cssText);
}
}
return RHDSSSRableRenderer.styleCache.get(key)!;
}
}

const elementRenderers = [
RHDSSSRableRenderer,
];
const elementRenderers = [RHDSSSRableRenderer];

class UnsafeHTMLStringsArray extends Array {
public raw: readonly string[];
Expand All @@ -142,18 +188,52 @@ class UnsafeHTMLStringsArray extends Array {
}
}

function postProcessAdoptedStyleSheets(html: string): string {
if (specifierMap.size === 0) {
return html;
}

const TEMPLATE_RE = new RegExp(
'(<template\\s+shadowroot="[^"]*"\\s+shadowrootmode="[^"]*"'
+ '(?:\\s+shadowrootdelegatesfocus)?)(>)\\s*'
+ '<!--@adopted:([\\w -]+)-->',
'g'
);
let processed = html.replace(
TEMPLATE_RE,
(_, open, close, specs) =>
`${open} shadowrootadoptedstylesheets="${specs.trim()}"${close}`
);

const styleBlocks = Array.from(specifierMap.entries())
.map(([name, css]) =>
`<style type="module" specifier="${name}">${css}</style>`)
.join('\n');

const headClose = processed.indexOf('</head>');
if (headClose !== -1) {
processed = `${processed.slice(0, headClose)}${styleBlocks}\n${processed.slice(headClose)}`;
} else {
processed = `${styleBlocks}\n${processed}`;
}

return processed;
}

/**
* Render a page using lit-ssr
*
* @param opts
* @param opts.page
* @param opts.content
* @param opts.slotControllerElements
*/
export default async function renderPage({
page,
content,
slotControllerElements,
}: RenderRequestMessage): Promise<RenderResponseMessage> {
specifierMap.clear();
const start = performance.now();
const tpl = html(new UnsafeHTMLStringsArray(content));
const result = render(tpl, {
Expand All @@ -162,6 +242,7 @@ export default async function renderPage({
slotControllerElements,
} as unknown as RenderInfo);
const rendered = await collectResult(result);
const postProcessed = postProcessAdoptedStyleSheets(rendered);
const end = performance.now();
return { page, rendered, durationMs: end - start };
return { page, rendered: postProcessed, durationMs: end - start };
}
16 changes: 0 additions & 16 deletions docs/assets/javascript/dsd-polyfill.js
Comment thread
zeroedin marked this conversation as resolved.
Outdated

This file was deleted.

2 changes: 0 additions & 2 deletions docs/assets/javascript/ssr-support.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
// dsd polyfill needs to happen before hydration attempts
// lit-element-hydrate-support needs to be included before lit is loaded
import '/assets/javascript/dsd-polyfill.js';
import '@lit-labs/ssr-client/lit-element-hydrate-support.js';
import 'element-internals-polyfill';
Loading
Loading