dom-layout-shim
v0.10.0
Published
Patch deterministic layout and hit testing into DOM-like test environments.
Maintainers
Readme
DOM Layout Shim
For agents, start with the agent guide and text documentation index. Every documentation page links to a static Markdown alternative; CSS support has a compact index with separate topic files.
Deterministic layout and hit testing for fast DOM test harnesses.
This package is an early proof of concept. The core is framework agnostic and
attaches to DOM-like documents such as happy-dom documents. Layout is computed
through a Taffy-backed pipeline. When attached, the layout engine discovers supported
@font-face rules and measures text directly from their font data, with a
deterministic approximation for unmatched families. DOM API patching and hit
testing are derived from the resulting layout snapshot.
Installation
pnpm add dom-layout-shimDOM Layout Shim works with the DOM environment already owned by your test harness. It does not install or require a particular DOM implementation.
Quick start
import { attachLayoutEngine } from 'dom-layout-shim'
await attachLayoutEngine({ window })
const button = document.getElementById('save')!
const rect = button.getBoundingClientRect()
const target = document.elementFromPoint(
rect.left + rect.width / 2,
rect.top + rect.height / 2,
)The layout engine patches getBoundingClientRect(), getClientRects(), offsetWidth,
offsetHeight, offsetTop, offsetLeft, offsetParent, clientWidth,
clientHeight, scrollWidth, and scrollHeight from the same layout snapshot,
so geometry APIs agree with hit testing.
scrollWidth and scrollHeight report the padding-box size or its scrollable
content extent, whichever is larger, rounded to integer CSS pixels. For example,
a 100×60 container with overflow:auto and a 240×180 child reports scroll sizes
of 240×180 instead of zero. Borders are excluded; nested clipped overflow does
not enlarge the outer container, and scrolling does not shrink the reported
size. The engine's existing synthetic html/body, inline-display, native-control,
and transform-containing-block limitations still apply.
Repeated reads reuse the layout snapshot and its hit-test ordering. Scroll-only
changes reuse computed layout and update viewport geometry, sticky positioning,
and clipping. Editing one stylesheet preserves the parsed data for other sheets;
viewport changes re-evaluate media queries without reparsing unchanged CSS.
Inline declaration parsing, selector expansion, and built-in text measurements
use adaptive bounded caches: recently evicted inputs being reused trigger growth
from 512 up to 4,096 entries; unique inputs alone do not. Active layout passes
retain their selector expansions even
when a stylesheet exceeds the shared cache, avoiding repeated parsing for
large CSS-in-JS stylesheets. Injected textMeasurer implementations are not memoized;
scroll-only reads reuse the layout already computed from their measurements.
DOM mutations are checked synchronously when geometry is read. Hosts with
non-patchable CSSOM or scroll APIs retain conservative validation paths.
See cache benchmarks for measured results and reproduction commands.
Wrapped inline phrasing elements expose one DOMRect per line through
getClientRects(), while getBoundingClientRect() returns their union. Stylesheet
matching supports :root, :first-child, :nth-child(), :last-child, :hover,
:focus, and :disabled in addition to the existing selector forms.
For example, :root { --panel-width: 80px; } .panel { width: var(--panel-width); }
gives .panel elements an 80px width. :root matches the document element
and has pseudo-class specificity, so it takes precedence over html.
The default viewport is 1280×720. Inline styles and document <style> elements
are discovered automatically. Accessible <link rel="stylesheet"> sheets and
constructable sheets in document.adoptedStyleSheets also participate in their
CSS cascade order. Cached geometry reads track CSSOM revisions without
re-serializing unchanged stylesheet rules. Changes to their rules, membership,
or ordering invalidate the cached layout automatically. Configuration is only
needed to override a default or supply additional stylesheet text:
await attachLayoutEngine({
window,
viewport: { width: 1440, height: 900 },
stylesheets: [appLayoutCss],
})The deterministic portable user-agent style profile supplies the package's
narrow baseline for headings, paragraphs, lists, and controls. Disable that
presentation layer or specialize it with CSS at the user-agent cascade origin:
await attachLayoutEngine({
window,
userAgentStyles: {
profile: 'portable', // Or 'none' for no presentation defaults.
overrides: 'p { margin: 0 } button { font: inherit }',
},
})Overrides are lower priority than document and inline styles. Structural HTML
behavior, such as hidden inputs not generating boxes, is not disabled by
profile: 'none'. Native-control intrinsic sizes are configured separately
with nativeControls.
The engine currently uses html and body as its synthetic viewport
containing block rather than independent boxes, so profile overrides do not yet
model their own margins, padding, or geometry.
The returned handle has the public type LayoutEngine. TypeScript consumers
should replace imports of LayoutEngineAttachment with LayoutEngine:
import { attachLayoutEngine, type LayoutEngine } from 'dom-layout-shim'
const layoutEngine: LayoutEngine = await attachLayoutEngine({ window })
layoutEngine.detach()attachLayoutEngine() returns the active layout engine. Change its viewport
without rebuilding the DOM when a test exercises responsive behavior:
const layoutEngine = await attachLayoutEngine({ window })
layoutEngine.setViewport({ width: 390, height: 844 })
// Layout, innerWidth/innerHeight, and matchMedia() now use the mobile viewport.Changing the viewport invalidates cached geometry and dispatches a resize
event on the attached window.
Use isLayoutEngineAttached(window) to check whether a layout engine is attached and
layoutEngine.detach() to return the window to its DOM harness:
import { attachLayoutEngine, isLayoutEngineAttached } from 'dom-layout-shim'
if (!isLayoutEngineAttached(window)) {
const layoutEngine = await attachLayoutEngine({ window })
// Run the test using deterministic geometry.
layoutEngine.detach()
console.log(isLayoutEngineAttached(window)) // false
}Detach restores original property descriptors for geometry, hit testing,
scrolling, viewport dimensions, matchMedia, observer constructors, and CSSOM
tracking. It disconnects internal mutation observers, removes event listeners,
cancels pending observer delivery, and clears layout-backed observations and
caches. Shared prototype hooks remain available to other attached windows;
detached elements use their native behavior. Calling detach() repeatedly is
safe. setViewport() and flushLayout() on the detached layout engine throw.
Attaching again replaces the previous layout engine; calling the old layout engine's
detach() cannot disconnect its replacement.
Assigning window.innerWidth or window.innerHeight while attached throws a
TypeError that points to layoutEngine.setViewport({ width, height }). For
example, replace window.innerWidth = 320 with
layoutEngine.setViewport({ width: 320, height: 640 }). This also applies in
non-strict scripts, where an assignment previously could silently do nothing.
Observe element resizing
Resize observations and inline fragments use the padding resolved during layout.
For example, a content-box block with width:100px;height:80px;padding:10%;border:
2px solid inside a 200px-wide parent has 20px padding on every side: its border
box is 144×124px, and its observed content box is 100×80px. Grid items resolve
percentage padding against their grid area. Changing a containing block's size
recomputes the padding before sizing parents and following siblings.
Attaching the engine installs a layout-backed window.ResizeObserver. Layout
remains lazy until an observer has an active target; observed mutations are
then batched and delivered automatically:
await attachLayoutEngine({ window })
const observer = new window.ResizeObserver(([entry]) => {
console.log(entry.contentRect.width)
})
observer.observe(window.document.querySelector('.panel'))Use manual delivery when a test needs an explicit synchronization point:
const layoutEngine = await attachLayoutEngine({
window,
observers: { delivery: 'manual' },
})
panel.style.width = '320px'
layoutEngine.flushLayout() // Recomputes and synchronously delivers pending entries.content-box, border-box, and device-pixel-content-box observations are
supported. Geometry reads remain lazy and do not themselves deliver callbacks.
The same delivery controls apply to the layout-backed
window.IntersectionObserver. Viewport and element roots, pixel/percentage
rootMargin values, and threshold arrays use the shim's deterministic geometry:
const observer = new window.IntersectionObserver(([entry]) => {
console.log(entry.isIntersecting, entry.intersectionRatio)
}, { threshold: [0, 0.5, 1] })
observer.observe(window.document.querySelector('.card'))External sheets whose cssRules cannot be read, including cross-origin sheets,
are routed through the unsupported CSS policy. The default policy warns and
continues; strict mode throws instead of silently computing layout without the
sheet.
Custom properties in inline and document styles resolve before supported layout values are parsed. Values inherit through the element tree, local declarations override inherited values, and nested fallbacks recover missing or cyclic references:
window.document.body.innerHTML = `
<main style="--card-width: 288px">
<article style="width:var(--card-width); padding:var(--space, 12px)"></article>
</main>
`
await attachLayoutEngine({ window })
// The article is 288px wide with 12px fallback padding.
window.document.querySelector('article')?.getBoundingClientRect()If a reference remains unresolved, the declaration follows the configured unsupported CSS policy rather than contributing an incorrect layout value.
The layout engine also answers window.matchMedia() from that configured
viewport. It supports screen/all media types, width and height constraints,
orientation, aspect ratio, query lists, and not/and combinations.
Non-viewport media features that the deterministic configuration does not
define evaluate to false.
Stylesheet @media rules use the same configured viewport, so responsive
layout branches agree with matchMedia() instead of using the DOM host's
window size. Matching supports screen/all media types, width and height ranges,
orientation, aspect ratio, query lists, conjunctions, and nested media rules.
Unsupported media features route through the unsupported CSS policy.
Calling element.scrollIntoView() scrolls layout-backed ancestors and the
configured viewport. Boolean arguments and block/inline alignment options
are supported. Smooth behavior completes immediately so subsequent test
assertions remain deterministic.
The current implementation uses Lightning CSS for <style> parsing and supports
a small CSS subset for proof-of-concept block, flex, absolute, fixed, and sticky
positioned fixtures. Sticky boxes honor physical insets against the nearest
supported scrolling ancestor or the viewport, including sticky table headers,
and stop at their containing block. Grid layouts pass rectangular named
templates through Taffy's native grid-template-areas and grid-area model,
including areas that span rows or columns and templates with unnamed . cells. Unsupported declarations are ignored with deduplicated
warnings so applications can continue using the supported subset. Each warning
identifies the declaration and links to its compatibility entry.
Supported layout lengths include pixels, percentages where documented, em,
rem, and viewport units. calc() expressions and custom-property references
are evaluated when they reduce to one supported length, percentage, or number.
Inline attributes and stylesheets share CSS declaration parsing and cascade
priority. A stylesheet width: 100px !important beats normal inline
width: 200px; an important inline declaration beats important author rules.
Font-relative dimensions resolve after font size: both width: 2em; font-size:
30px and the reversed declaration order produce a 60px width. Generated
::before and ::after content also resolves custom properties through this
cascade, including variables declared on the pseudo-element itself.
Portable presentation defaults and HTML sizing hints also enter this cascade.
Normal user-agent rules yield to HTML hints such as <img width="100">, and
normal author CSS can override those hints. Important user-agent rules retain
priority over important author rules. A paragraph with font-size: 2em inside
a 30px parent now resolves to 60px, rather than using the portable paragraph
font size as its inheritance base.
For non-replaced text containers, authored display: inline uses the same
wrapped fragments as native phrasing elements. For example,
<div style="display:inline">one two three</div> participates in its parent's
line layout instead of creating a block. Absolute/fixed elements and flex/grid
items are blockified before layout. Atomic inline replaced elements,
inline-block, and inline outer flex/grid/table layout remain outside this
inline-formatting subset.
Table properties also use normal inheritance. A caption with caption-side: top
overrides a table's caption-side: bottom; empty-cells can inherit through
row groups and rows before a cell's own declaration overrides it.
Intrinsic sizing accepts min-content, max-content, and fit-content for
width, height, inline-size, and block-size. For example,
style="width: max-content" sizes a text box to its unwrapped content instead
of filling the parent; width: fit-content clamps the available width between
the minimum and maximum content widths. Intrinsic keywords in min/max dimension
constraints remain unsupported.
Grid rows and columns accept auto, min-content, max-content, and
fit-content(<px-or-percentage>) in explicit, implicit, and integer-repeat
tracks. For example, style="display: grid; grid-auto-flow: column;
grid-auto-columns: max-content" gives each implicit column its own content
width instead of splitting the container evenly.
Mixed percentage-and-pixel dimensions such as calc(100% - 32px) resolve when
their containing-block axis is definite. Styled flex buttons include inline
icons, gaps, padding, and borders in deterministic intrinsic sizing. Nested
positioned stacking contexts constrain descendant z-index values during point
queries.
The exported HitBox diagnostic type exposes the optional stackingOrder
paint key used to compare nested contexts.
Two-dimensional rotation, skew, and matrix transforms affect client geometry
and use polygonal hit regions rather than treating the empty corners of a
transformed bounding rectangle as clickable.
Images, SVG, and canvas use intrinsic aspect ratios when one CSS dimension is
auto. For example, <svg viewBox="0 0 200 100" style="width:100px;height:auto">
has a 100×50 layout box. Images use host-provided natural dimensions, with
width/height attributes as sizing hints; canvas uses its bitmap dimensions.
Supported min/max constraints and flex/grid layout preserve these ratios, and
image load/error events invalidate cached geometry. Image loading stays with
the DOM host; SVG shapes and canvas pixels are not rendered. See the guide for
attribute-length and percentage-constraint limitations.
Text measurement receives inherited numeric font-weight and resolved
letter-spacing and word-spacing in addition to family, size, line height,
and white-space. For example, word-spacing: 4px adds four pixels to each
remaining space when measuring text and deciding where normal text wraps.
Word spacing accepts normal and supported lengths, including negative values;
script-specific word separators are not modeled.
Static TTF, OTF, and WOFF sources declared through @font-face are loaded when
the layout engine is attached and their glyph advances and kerning drive text measurement.
Data URLs and resolvable URL sources are supported; local() and WOFF2 sources
fall through to the next source or deterministic measurement.
Supported text-transform values (none, uppercase, lowercase, and
capitalize) transform the string passed to intrinsic measurement without
changing the element's authored textContent.
Inline phrasing runs alongside block children contribute anonymous line boxes
to their shared container instead of disappearing from its intrinsic height.
Generated strings and attr() values from ::before and ::after contribute
to intrinsic text measurement. Non-inline generated content also creates an
anonymous layout box with its own dimensions and spacing, including when the
originating element is a flex or grid container. For example, a block
::before with height: 12px and margin-bottom: 3px contributes 15px before
the originating element's ordinary content. Supported state, functional, and
case-insensitive attribute selectors participate in stylesheet matching.
Calculated dimensions also resolve for generated boxes and descendants of table
cells after their containing widths are allocated. For example, a 200px cell
containing a child with width: calc(100% - 20px); aspect-ratio: 2 gives that
child a 180px width and a 90px height; the row includes that resulting height.
Native CSS nesting is supported for supported selectors and declarations,
including &, implicit descendants, child combinators, and nested viewport
@media rules. Parent selector lists retain their highest specificity and
mixed declarations retain source order. For example,
.card { .item { width: 60px; } } gives an .item inside .card a
60px width through getBoundingClientRect().
Use a callback to display or collect warnings in the test runner:
await attachLayoutEngine({
window,
unsupportedCss: {
onWarning: ({ property, value, reason }) => {
testLogger.warn(`${property}: ${value} (${reason})`)
},
},
})To reduce a warning stream to one adoption-cost number for a test suite, reuse an unsupported CSS reporter across layout engines:
import {
attachLayoutEngine,
createUnsupportedCssReporter,
} from 'dom-layout-shim'
const reporter = createUnsupportedCssReporter()
await attachLayoutEngine({
window,
unsupportedCss: { reporter },
})
document.body.getBoundingClientRect() // Trigger lazy layout and diagnostics.
const summary = reporter.getSummary()
console.log(summary.unsupportedDeclarationCount)The count represents unique unsupported combinations of property, value, and
reason. summary.declarations contains stable, sorted details, occurrence
counts, source and selector types, affected elements, and the browser-like
computed values observed when the warning was collected. These extra fields
help separate active layout inputs from repeated or superseded fallback rules.
Call reporter.reset() between independent suites.
Use unsupportedCss: { reporter } to collect warnings directly. The default is
warn; explicit default, properties, and property decisions still take
precedence. An optional onWarning callback receives the same warnings. Layout
is lazy: query geometry before reading the summary. Values contain CSS text
(for example, animation-delay: 0.4s reports value 0.4s), and unsupported
selectors contain selector text rather than an AST dump. Unsupported rules and
parser recovery include the authored stylesheet; unavailable stylesheet entries
retain their diagnostic description.
Merge summaries from isolated test workers or windows with the public
mergeUnsupportedCssSummaries(summaries) helper:
import { mergeUnsupportedCssSummaries } from 'dom-layout-shim'
const combined = mergeUnsupportedCssSummaries([firstSummary, secondSummary])
console.log(combined.unsupportedDeclarationCount)Transport each worker's reporter.getSummary() as JSON using your test runner's
collection mechanism. Merging combines equal property/value/reason entries,
sums occurrences, and sorts and deduplicates their metadata without mutating
inputs. Warning deduplication still happens per layout engine, so occurrences count
collected warnings rather than every element or layout query. An empty input
produces an empty summary.
Set unsupportedCss.default to 'throw' for strict CI enforcement or
'ignore' to deliberately suppress unsupported declarations. Decisions can
also be overridden by property. Text measurement falls back to a deterministic
approximation in Node-like runtimes without canvas text measurement.
Stylesheet parser recovery also consults the unsupported-CSS policy. For example,
stylesheets: ['div: { width: 20px }'] reports an unsupported-rule entry with
property stylesheet and the original CSS when layout is queried, instead of
silently discarding the rule. Strict policy errors and warning callback errors
propagate unchanged.
Native-control intrinsic geometry uses the deterministic portable profile by
default. Select it explicitly when a test wants to document that target rather
than follow the machine running the test:
await attachLayoutEngine({
window,
nativeControls: {
profile: 'portable',
overrides: {
textInput: { width: 220 },
checkboxRadio: { width: 16, height: 16 },
},
},
})
// 220×23: the overridden width plus the profile's unchanged height.
document.querySelector('input')?.getBoundingClientRect()Overrides are merged by control group and by metric. Override only the values a
test environment needs to specialize, or provide every field in every group to
define a fully custom profile. The exported NativeControlMetrics and
NativeControlOverrides types describe the complete shape.
Native-control profiles emulate outer geometry, not operating-system rendering or internal widget behavior. Additional Chromium platform profiles are added only from recorded cross-platform parity evidence.
The package requires Node.js 22 or newer and uses standard ES modules.
See the documentation site and
its filterable CSS support overview.
The site also hosts matching Material UI and Ant Design task-workspace examples,
whose happy-dom tests exercise geometry, portals, overlays, scrolling, and hit
testing through the public API.
The underlying source of truth is
the machine-readable records in support/css. Each record follows
support/css-support.schema.json. Topics are
divided into behavior-specific claims so support level, Chromium evidence,
conditions, and limitations remain scoped to the behavior they describe. The
explorer can preview each linked parity fixture locally or open its source on
GitHub. Run
pnpm run css:validate to check the inventory, pnpm run css:generate after
editing it, or pnpm run css:query -- flex-basis to query it from a terminal.
Ignored documentation-site output, including generated CSS support JSON, is
written to .site/.
Run pnpm run docs:generate to rebuild the ignored .site/ deployment output,
or pnpm run docs:check to validate and regenerate it. Run
pnpm run docs:serve to generate and serve the complete documentation site
locally. Pass a port when needed, for example
pnpm run docs:serve -- 4174.
Run pnpm run examples:check to build, typecheck, and test every example app.
Run pnpm run examples:compatibility to produce non-gating Chromium-versus-shim
interaction reports for the UI-library examples. Layout differences are
recorded for inspection; only an execution or capture error fails the command.
Run pnpm run test:package to build and pack the exact npm artifact, install it
into an isolated consumer project, typecheck its public API, and exercise layout
and hit testing through the packaged output.
Development prerequisites
Building from source requires Node.js 22 or newer, pnpm 10, Rust 1.98, the
wasm32-unknown-unknown Rust target, and wasm-pack 0.15. The published package
already contains the generated WebAssembly module; consumers do not need Rust
or wasm-pack.
With Rust managed by mise, install the remaining tools once:
mise use [email protected]
rustup target add wasm32-unknown-unknown
cargo install wasm-pack --version 0.15.0 --lockedVerify the toolchain and build the ignored generated binding:
rustc --version
rustup target list --installed
wasm-pack --version
pnpm run wasm:checkpnpm run build, pnpm test, browser parity, documentation, and package tests
rebuild the binding automatically. Rust source and Cargo.lock are committed;
the generated JavaScript glue and .wasm stay ignored in the source tree and
are copied into dist/ only when producing the package.
See docs/implementation-phases.md for the case-based implementation plan.
See docs/taffy-pipeline-roadmap.md for the Taffy pipeline contract and migration roadmap.
See docs/releasing.md for the changeset, changelog, and pre-1.0 versioning policy.
See CONTRIBUTING.md before opening a pull request. User-facing changes include a Changeset so their release impact is explicit.
See SUPPORT.md for issue guidance, SECURITY.md for private vulnerability reporting, and CODE_OF_CONDUCT.md for participation expectations.
Commit convention
Use <type>(<scope>): <imperative summary> for commit subjects. A scope is
required for every commit.
Allowed types:
feat: add supported behavior or a public capabilityfix: correct existing behaviortest: change tests without changing intended package behaviorrefactor: restructure code without an intended behavior changedocs: change documentation onlyperf: improve performance
Allowed scopes:
css: CSS parsing, layout behavior, hit testing, and browser parityapi: the public API surface for package consumerscfg: dependencies, package metadata, build scripts, and tooling
Choose the type based on the effect of the change. For example, a dependency
update may be feat(cfg), fix(cfg), or refactor(cfg) depending on why it is
being made.
Keep the subject imperative and under 72 characters. Both uppercase and lowercase letters are allowed. Keep commit bodies short and omit them when the subject is sufficient. Use a body when it explains non-obvious reasoning or provides a useful enumeration of changes. Commit bodies may use Markdown. When applicable, include a minimal code example that makes the changed CSS, API, or configuration behavior easy to see. Prefer a focused usage example over reproducing implementation details or a large part of the diff. Add a short code comment when it helps identify the newly supported or changed part of the example.
For example, a parity commit covering several distinct behaviors should enumerate them:
test(css): Expand grid and aspect-ratio parity coverage
- Cover grid track content alignment
- Cover grid item stretching and self-alignment
- Cover inverse and constrained aspect-ratio sizingFor example, a commit adding CSS behavior may show the newly supported form:
feat(css): Support percentage row gaps
```css
.items {
display: flex;
flex-wrap: wrap;
height: 100px;
row-gap: 10%; /* Percentage row gaps are now supported. */
}
```If a change genuinely requires another scope, add that scope to the list above in the same change. Do not introduce unlisted scopes only in commit subjects.
Agents may create focused commits for their own changes after relevant checks pass. They must preserve unrelated changes, exclude generated output, and must not amend existing commits or push unless explicitly requested.
License
DOM Layout Shim is released into the public domain under the Unlicense. You may use, copy, modify, publish, compile, sell, or distribute the software for any commercial or non-commercial purpose.
Overflow clips remain attached to their ancestors when descendants transform.
For example, a 100px-wide child translated 80px right inside a 100px-wide
overflow: hidden parent is hittable only in the visible 20px strip.
Intersection observations use the same projected ancestor clips.
Inline text, including bare text after a block child, shares styled line layout
with generated content. For example, a 30px-tall block followed by Hello in a
container with line-height: 20px contributes a total height of 50px. Nested
inline font settings and pseudo-element typography affect measurement; inline
client fragments also participate in point queries. pre-wrap and pre-line
use the shared wrapping rules, including preserved hard-break fragments.
Table cells lay out block, flex, grid, and inline descendants through the shared
formatting pipeline. A width: 100px; height: 40px div inside an otherwise empty
cell now reports 100×40px rather than a zero rectangle. Allocated cell widths
reflow text; vertical-align: top, middle, and bottom place cell contents.
Full intrinsic table track distribution and collapsed-border conflict resolution
remain outside the supported table subset.
Calculated dimensions use one containing-block resolver before and after layout,
including percentage ancestors and border-box padding. For example, a child
with width: calc(100% - 20px) inside a 200px border box with 10px padding on
each side and 5px borders has width 150px. Absolute descendants use their
positioned containing block across intervening static ancestors. In standards
mode, observed auto height does not establish a definite percentage-height basis.
For non-replaced inline elements, offsetWidth and offsetHeight span the
fragment union, while offsetLeft and offsetTop use the first fragment.
clientWidth and clientHeight remain zero. Positioned overlays paint above
ordinary inline text, and in-flow descendants paint above their positioned
container's background.
