Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion docs/_includes/layouts/base.njk
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
background-color: light-dark(white, var(--rh-color-surface-darker, #1f1f1f));
}
</style>
<script src="/assets/javascript/shadowroot-adopted-stylesheets.js"></script>
<link rel="stylesheet" href="/styles/styles.css">

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

<script async src="https://ga.jspm.io/npm:es-module-shims@1.8.0/dist/es-module-shims.js"></script>
</head>

Expand Down
34 changes: 34 additions & 0 deletions docs/_plugins/lit-ssr/lit-html-minifier-node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { minifyHTMLLiterals } from '@literals/html-css-minifier';
import { fileURLToPath } from 'node:url';

interface HookContext {
source: string | ArrayBuffer;
format: 'module' | 'commonjs' | 'wasm' | 'json';
}

type LoadFunction = (url: string, context: HookContext) => Promise<HookContext>;

const cache = new Map<string, string>();

const ELEMENT_PATH_RE = /\/(elements|lib|uxdot)\//;

export async function load(url: string, context: HookContext, nextLoad: LoadFunction) {
const result = await nextLoad(url, context);
if (result.format !== 'module' || !ELEMENT_PATH_RE.test(url)) {
return result;
}
const source = typeof result.source === 'string' ?
result.source
: result.source?.toString();
if (!source || !source.includes('html`')) {
Comment on lines +20 to +23
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Arraybuffer source corrupted 🐞 Bug ≡ Correctness

The lit-html-minifier-node loader converts ArrayBuffer module sources via .toString(), which
does not decode the buffer contents; if nextLoad() returns an ArrayBuffer, minification will
operate on garbage text and can break SSR module loading.
Agent Prompt
## Issue description
The ESM loader hook in `docs/_plugins/lit-ssr/lit-html-minifier-node.ts` calls `result.source?.toString()` when `source` is an `ArrayBuffer`. This does not decode bytes into the JavaScript source text and can corrupt the module source passed into `minifyHTMLLiterals()`.

## Issue Context
`HookContext` explicitly allows `source: string | ArrayBuffer`, so this branch is a supported code path.

## Fix Focus Areas
- Convert `ArrayBuffer` to a string with `Buffer.from(arrayBuffer).toString('utf8')` (or `new TextDecoder('utf-8').decode(arrayBuffer)`), not `.toString()`.
- Preserve behavior for string sources.

### Fix Focus Areas (code pointers)
- docs/_plugins/lit-ssr/lit-html-minifier-node.ts[4-34]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

return result;
}
if (!cache.has(url)) {
const fileName = url.startsWith('file://') ?
fileURLToPath(url)
: url;
const minified = await minifyHTMLLiterals(source, { fileName });
cache.set(url, minified?.code ?? source);
}
return { ...result, source: cache.get(url) };
}
33 changes: 1 addition & 32 deletions docs/_plugins/lit-ssr/lit.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import type { EleventyPage } from '@11ty/eleventy/src/UserConfig.js';
import type { UserConfig } from '@11ty/eleventy';

import { readFile, writeFile, rm } from 'node:fs/promises';
import { glob } from 'node:fs/promises';
import { join } from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';

import { Piscina } from 'piscina';
import tsBlankSpace from 'ts-blank-space';
Expand All @@ -13,33 +11,6 @@ import { register } from 'node:module';
import { fileURLToPath } from 'node:url';
import type { Options } from '#11ty-plugins/rhds.js';

/**
* Clean compiled .js files from elements, lib, and uxdot directories to prevent
* module resolution conflicts during SSR
*/
async function cleanCompiledJs() {
const cwd = process.cwd();
const dirs: { path: string; pattern: string }[] = [
{ path: 'elements', pattern: '*/*.js' },
{ path: 'lib', pattern: '*.js' },
{ path: 'uxdot', pattern: '*.js' },
];

for (const { path: dir, pattern } of dirs) {
const dirPath = join(cwd, dir);
for await (const file of glob(pattern, { cwd: dirPath })) {
const jsPath = join(dirPath, file);
const tsPath = jsPath.replace(/\.js$/, '.ts');
// Only remove .js files that have a corresponding .ts file
try {
await readFile(tsPath);
await rm(jsPath);
} catch {
// .ts file doesn't exist, keep the .js file
}
}
}
}

export interface RenderRequestMessage {
content: string;
Expand Down Expand Up @@ -77,8 +48,6 @@ export default async function(
// If there are no component modules, we could never have anything to render.
if (imports?.length) {
eleventyConfig.on('eleventy.before', async function() {
// Clean compiled .js files to prevent module resolution conflicts
await cleanCompiledJs();
await redactTSFileInPlace('./worker.ts');
const filename = fileURLToPath(new URL('worker.js', import.meta.url));
pool = new Piscina({
Expand Down
209 changes: 139 additions & 70 deletions docs/_plugins/lit-ssr/worker.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
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';

import { LitElementRenderer } from '@lit-labs/ssr/lib/lit-element-renderer.js';

import { register } from 'node:module';
import { register as registerTS } from 'tsx/esm/api';

import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
Expand All @@ -28,8 +27,8 @@ interface WorkerInitData {

const { imports } = Piscina.workerData as WorkerInitData;

registerTS();
register('./lit-css-node.ts', import.meta.url);
register('./lit-css-node.js', import.meta.url);
register('./lit-html-minifier-node.js', import.meta.url);

/* eslint-disable no-console */
for (const bareSpec of imports) {
Expand All @@ -38,11 +37,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(
/\.ts$/,
'.js'
);
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 +56,116 @@ for (const bareSpec of imports) {
}
/* eslint-enable no-console */

class RHDSSSRableRenderer extends LitElementRenderer {
static styleCache = new Map<string, Thunk>();
const specifierMap = new Map<string, string>();
const styleIdentityMap = new WeakMap<object, 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);
}
}
return renderValue(
// @ts-expect-error: if upstream can do it, so can we
this.element.render(),
renderInfo,
override connectedCallback() {
super.connectedCallback();
const styles =
(this.element.constructor as typeof LitElement).elementStyles ?? [];
const unnamed = styles.filter(s =>
!styleIdentityMap.has(s)
&& !(s as CSSResult & { specifier?: string }).specifier);
for (const style of styles) {
const specifier = styleIdentityMap.get(style)
?? (style as CSSResult & { specifier?: string }).specifier
?? (unnamed.length === 1 ?
this.tagName.toLowerCase()
: `${this.tagName.toLowerCase()}-${unnamed.indexOf(style)}`);
styleIdentityMap.set(style, specifier);
this.#specifiers.push(specifier);
if (!specifierMap.has(specifier)) {
specifierMap.set(
specifier,
this.#processCSS((style as CSSResult).cssText, specifier),
);
},
];
}
}
}

#renderStyles(): Thunk {
const styles = (this.element.constructor as typeof LitElement).elementStyles;
if (styles !== undefined && styles.length > 0) {
return () => [
'<style>',
...this.thunkStyles(styles),
'</style>',
];
} else {
return () => '';
override renderAttributes() {
const result = super.renderAttributes();
if (this.#specifiers.length > 0) {
result.push(
` shadowrootadoptedstylesheets="${this.#specifiers.join(' ')}"`
);
}
return result;
}

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);
override renderShadow(renderInfo: RenderInfo): ThunkedRenderResult {
const result: ThunkedRenderResult = [];

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

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 +176,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 +230,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 };
}
Loading
Loading