Skip to content

Commit 61153df

Browse files
committed
refactor(adapter-lit): drop Lit 2.x unsafeHTML fallback, Lit 3.x only
- Remove dead Lit 2.x detection path (string marker + _$resolve) - LessJS uses Lit 3.x exclusively; 2.x fallback was unreachable code - Delete UNSAFE_HTML_DIRECTIVE constant - Add 3 unsafeHTML tests (Lit 3.x extraction, mixed escaping, empty content) - Update comments: v0.6 → v0.6.3
1 parent 812c555 commit 61153df

2 files changed

Lines changed: 64 additions & 21 deletions

File tree

packages/adapter-lit/__tests__/ssr.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,40 @@ Deno.test('renderLitToString passes non-TemplateResult through String()', () =>
226226
assertEquals(renderLitToString('plain text'), 'plain text');
227227
assertEquals(renderLitToString(42), '42');
228228
});
229+
230+
// ─── unsafeHTML Directive Tests ─────────────────────────────────
231+
232+
Deno.test('renderLitToString renders unsafeHTML directive (Lit 3.x)', async () => {
233+
// Core bug: blog post content rendered as [object Object]
234+
// Lit 3.x: unsafeHTML() returns { _$litDirective$: UnsafeHTML, values: [htmlString] }
235+
const { unsafeHTML } = await import('lit/directives/unsafe-html.js');
236+
const rawHtml = '<h2>Title</h2><p>Paragraph with <strong>bold</strong></p>';
237+
const rendered = renderLitToString(html`
238+
<div>${unsafeHTML(rawHtml)}</div>
239+
`);
240+
assertStringIncludes(rendered, rawHtml);
241+
// Must NOT contain [object Object]
242+
assertEquals(rendered.includes('[object Object]'), false);
243+
});
244+
245+
Deno.test('renderLitToString escapes regular content alongside unsafeHTML', async () => {
246+
const { unsafeHTML } = await import('lit/directives/unsafe-html.js');
247+
const rendered = renderLitToString(html`
248+
<section>
249+
<p>${'<script>xss</script>'}</p>
250+
<div>${unsafeHTML('<em>trusted</em>')}</div>
251+
</section>
252+
`);
253+
// Regular content must be escaped
254+
assertStringIncludes(rendered, '&lt;script&gt;xss&lt;/script&gt;');
255+
// unsafeHTML content must be raw
256+
assertStringIncludes(rendered, '<em>trusted</em>');
257+
});
258+
259+
Deno.test('renderLitToString handles unsafeHTML with empty/null content', async () => {
260+
const { unsafeHTML } = await import('lit/directives/unsafe-html.js');
261+
const rendered = renderLitToString(html`
262+
<div>${unsafeHTML('')}</div>
263+
`);
264+
assertEquals(compactHtml(rendered), '<div></div>');
265+
});

packages/adapter-lit/src/ssr.ts

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,6 @@ const LIT_TEMPLATE_TYPE_MARKER = '_$litType$';
4646
/** Lit's `nothing` sentinel — used to conditionally remove attributes */
4747
const NOTHING_SYMBOL = Symbol.for('lit-nothing');
4848

49-
/** Lit's unsafeHTML directive marker — bypasses escaping */
50-
const UNSAFE_HTML_DIRECTIVE = 'lit-html:unsafe-html';
51-
5249
/**
5350
* Check if a value is a Lit TemplateResult.
5451
* Works with any Lit version that uses the _$litType$ marker.
@@ -194,27 +191,36 @@ function stringifyContentValue(value: unknown): string {
194191
return unwrapDsdForNestedCe(result);
195192
}
196193

197-
// v0.6: Check if this value has a directive marker that indicates
198-
// it should bypass escaping (e.g., unsafeHTML directive).
199-
// Lit's unsafeHTML directive returns a special object with properties
200-
// that signal "trust this HTML content".
194+
// v0.6.3: Detect Lit 3.x unsafeHTML directive to bypass escaping.
195+
// unsafeHTML() returns { _$litDirective$: UnsafeHTML, values: [htmlString] }
196+
// - _$litDirective$ is the directive class (not a string)
197+
// - The class has static `directiveName: 'unsafeHTML'` and `resultType: 1`
198+
// - The HTML string is in `values[0]`
201199
if (typeof value === 'object' && value !== null) {
202200
const obj = value as Record<string, unknown>;
203-
// Check for Lit's unsafeHTML directive marker
204-
if (obj._$litDirective$ === UNSAFE_HTML_DIRECTIVE && typeof obj._$resolve === 'function') {
205-
try {
206-
const resolved = obj._$resolve();
207-
if (resolved != null) {
208-
const str = String(resolved);
209-
// FIX: Also unwrap DSD from unsafeHTML results containing custom elements
210-
return unwrapDsdForNestedCe(str);
201+
const directiveCtor = obj._$litDirective$;
202+
203+
if (typeof directiveCtor === 'function') {
204+
const ctor = directiveCtor as unknown as Record<string, unknown>;
205+
const isUnsafeHtml = ctor.directiveName === 'unsafeHTML' ||
206+
ctor.resultType === 1; // resultType 1 = unsafe HTML in Lit 3.x
207+
208+
if (isUnsafeHtml && obj.values != null) {
209+
try {
210+
const vals = Array.isArray(obj.values)
211+
? obj.values
212+
: Array.from(obj.values as ArrayLike<unknown>);
213+
const resolved = vals[0];
214+
if (resolved != null) {
215+
const str = String(resolved);
216+
return unwrapDsdForNestedCe(str);
217+
}
218+
return '';
219+
} catch (e) {
220+
log.debug(
221+
`unsafeHTML value extraction failed: ${e instanceof Error ? e.message : String(e)}`,
222+
);
211223
}
212-
return '';
213-
} catch (e) {
214-
// If resolution fails, fall through to escaped string
215-
log.debug(
216-
`Directive resolution failed: ${e instanceof Error ? e.message : String(e)}`,
217-
);
218224
}
219225
}
220226
// _$litDirective$ also appears in other Lit directives — fall through

0 commit comments

Comments
 (0)