Lit Custom Attribute Names: A Testing Pitfall
The Bug
Component tests for Entry.ts hung for 60 seconds with zero browser logs, then
timed out. Binary search across 20 tests isolated it to a single assertion:
// THIS HANGS
const el: Entry = await fixture(html`<lms-calendar-entry density="compact"></lms-calendar-entry>`);
const timeElement = el.shadowRoot?.querySelector('.time');
expect(timeElement).to.not.exist; // ← never completes
Changing the assertion target to el instead of timeElement made it pass.
Changing density="compact" to .density=${'compact'} also made it pass.
Root Cause
Entry.ts declares a custom HTML attribute name:
@property({ type: String, reflect: true, attribute: 'data-density' })
density: 'compact' | 'standard' | 'full' = 'standard';
The attribute: 'data-density' option tells Lit to observe the data-density HTML
attribute — not density. Writing density="compact" in a template sets an
unrecognized HTML attribute that Lit ignores. The density property stays at its
default: 'standard'.
The Chain Reaction
densityproperty remains'standard'(not'compact')._shouldShowTime()returnstruefor standard density._renderTime()calls_displayInterval(undefined)(no.timeprop set)._displayIntervalreturns Lit'snothingsentinel whentimeis undefined.nothingis aSymbol— truthy in JavaScript — so the ternarytimeString ? html\…` : nothing` takes the truthy branch.- An empty
<span class="time"></span>is rendered in the shadow DOM. querySelector('.time')returns anHTMLSpanElement, notnull.expect(anHTMLSpanElement).to.not.exist(or.to.be.null) fails.- Chai/loupe tries to serialize the DOM element for the error message. In the web-test-runner + Vite plugin + Playwright context, this serialization hangs the browser page indefinitely.
- web-test-runner never receives test results → 60 s
testsFinishTimeoutexpires. - Zero browser logs appear because the test runner only collects logs from completed (not timed-out) test pages.
Why It Was Hard to Debug
| Symptom | Misleading Interpretation |
|---|---|
| Zero browser logs | "Module isn't loading" |
| 60 s timeout | "Circular import / infinite loop" |
| Removing the import type line didn't help | "Not an import issue" |
| Minimal fixture with same attr passed | "Must be a concurrency / interaction issue" |
| Vite serves valid JS (verified via curl) | "Not a transform issue" |
| esbuild plugin shows errors; Vite doesn't | "Vite swallows errors" (true but unrelated) |
The real issue — a wrong HTML attribute name — was invisible because:
- The component renders without errors (just with default density).
- The test looks correct at a glance (
density="compact"reads naturally). - The failure mode is a timeout, not an assertion error, because Chai's DOM serialization hangs in this environment.
The Fix
Use Lit property bindings (.property=${value}) instead of HTML attributes for
any @property that declares a custom attribute: name:
- <lms-calendar-entry density="compact">
+ <lms-calendar-entry .density=${'compact'}>
Or use the actual HTML attribute name:
- <lms-calendar-entry density="compact">
+ <lms-calendar-entry data-density="compact">
Rules of Thumb
-
Check
attribute:in@propertydeclarations. If a property usesattribute: 'something-else', the HTML attribute issomething-else, not the JS property name. -
Prefer
.propertybindings in test fixtures. They always work regardless of the attribute mapping and bypass string-to-type coercion. -
Be wary of Lit's
nothingsentinel.nothingis aSymboland is truthy. Code likereturn value ? html\…` : nothingwill take the truthy branch whenvalueisnothing. If you returnnothingfrom a helper, check for it explicitly:return value !== nothing && value ? … : nothing`. -
Chai + DOM elements in web-test-runner can hang. When an assertion fails on a DOM element, Chai's serializer (loupe) may hang trying to inspect it. If a component test times out with zero logs, suspect a failing assertion on a DOM element — not a missing module or infinite loop.
Related Component Properties
These Entry.ts properties all use custom attribute names:
| Property | HTML Attribute | Gotcha |
|---|---|---|
density |
data-density |
density="…" silently ignored |
displayMode |
data-display-mode |
displayMode="…" silently ignored |
floatText |
data-float-text |
floatText silently ignored |
Always use .density, .displayMode, .floatText property bindings in templates
and tests for these.