How This Works
A deep dive into the internal architecture and design decisions behind shimmer-from-structure.
The Problem
Building modern web applications means dealing with asynchronous data. While your app fetches user profiles, product listings, or dashboard metrics from an API, users stare at blank screens or spinners. The industry solution? Loading skeletons - placeholder UI that mimics the structure of the content to come.
But here's the catch: writing loading skeletons is repetitive, error-prone, and a maintenance nightmare. Every time you build a new component, you write it twice - once for the real content, and once for the skeleton. Change the layout? Update both versions. Add a new field? Don't forget the skeleton. The two implementations drift apart, and suddenly your loading state looks nothing like your actual UI.
Yet the skeleton structure you're manually recreating already exists - it's right there in the rendered DOM. Your component knows how to lay itself out. It knows where the heading goes, how wide the text blocks are, where the avatar sits. The browser has already calculated every dimension, every position, every spacing rule.
This is the core insight behind shimmer-from-structure: if the structure already exists in the DOM, why not measure it? Instead of maintaining parallel skeleton components, we can render the real component once, read its dimensions using browser APIs like getBoundingClientRect(), and generate pixel-perfect shimmer overlays automatically. No duplication. No drift. No manual maintenance.
The Core Idea
At its heart, shimmer-from-structure uses a simple but powerful technique:runtime DOM measurement. When you wrap a component in <Shimmer loading={true}>, the library renders your component normally, then walks through the resulting DOM tree and calls getBoundingClientRect() on each element.
This browser API returns the exact position and dimensions of every element as the browser has calculated them - accounting for CSS, flexbox, grid, responsive breakpoints, dynamic content, everything. We capture the top, left, width, and height of each text node, image, button, and container.
Once we have these measurements, we create absolutely-positioned shimmer overlays that sit on top of the real content. Each overlay is a <div> with:
position: absoluteto remove it from document flowtopandleftvalues matching the measured element's positionwidthandheightmatching the measured element's dimensions- A shimmer animation (CSS gradient with
background-positionkeyframes)
The real content underneath is made transparent using color: transparent (not opacity: 0, which would hide backgrounds and borders). This means the shimmer blocks appear exactly where your text, images, and UI elements will be - because they're positioned based on where those elements actually are.
When loading becomes false, the shimmer overlays are removed, the content becomes visible again, and your component displays normally. The transition is seamless because the layout never changed - the shimmer was always matching the real structure.
Design Constraints
While the runtime DOM measurement approach is powerful, it comes with important constraints that shaped the library's design and implementation. Understanding these limitations helps explain why certain architectural decisions were made and how the library achieves its performance goals.
SSR Environments
The library's core technique - calling getBoundingClientRect()on DOM elements - fundamentally requires a browser environment. This API doesn't exist in server-side rendering (SSR) contexts like Next.js's getServerSideProps, Remix loaders, or Node.js environments.
This means shimmer-from-structure cannot generate shimmer overlays during SSR. The measurement phase must happen client-side, after the component has mounted and the browser has calculated layout. For SSR frameworks, this is acceptable because:
- The shimmer is a loading state - it only appears while fetching data, which typically happens client-side anyway
- SSR delivers the initial HTML shell quickly; the shimmer activates during client-side data fetching
- The library detects SSR environments and safely skips measurement, preventing runtime errors
All framework adapters include SSR guards that check for typeof window !== 'undefined' before attempting DOM measurement. This ensures the library works seamlessly in SSR frameworks without requiring special configuration.
Frame Budget
Browsers render at 60 frames per second (fps), which means each frame has a budget of approximately 16.67 milliseconds. If JavaScript execution, layout calculations, or painting take longer than this, the browser drops frames, causing visible stuttering or flicker.
The measurement phase - where the library walks the DOM tree and calls getBoundingClientRect() on each element - must complete within this frame budget. If measurement takes too long, users will see a flash of unstyled content before the shimmer appears.
To stay within the frame budget, the library:
- Minimizes DOM traversal overhead by using efficient tree walking algorithms
- Batches measurements to trigger only one browser reflow (see "Minimizing Reflows" section)
- Skips unnecessary elements using
data-shimmer-ignoreanddata-shimmer-no-childrenattributes - Caches computed styles to avoid redundant style calculations
For typical component trees (dozens to hundreds of elements), measurement completes in 2-5ms, well within the frame budget. For extremely large trees (thousands of elements), developers can use data-shimmer-no-childrento treat complex subtrees as single shimmer blocks, reducing measurement overhead.
Reflow Minimization
A reflow (also called layout recalculation) occurs when the browser recalculates the position and dimensions of elements in the document. Reflows are expensive operations that can take several milliseconds, especially for complex layouts.
Reading layout properties like getBoundingClientRect(), offsetWidth, or getComputedStyle() forces the browser to perform a reflow if any DOM changes have occurred since the last layout. Worse, interleaving DOM writes (changing styles or content) with DOM reads (measuring dimensions) causes multiple reflows - a performance anti-pattern known as "layout thrashing."
The library's measurement strategy is designed to trigger only one reflow per measurement cycle:
- Apply all CSS changes first (set
color: transparent, inject measurement styles) without reading any layout properties - Perform all measurements in a single pass, reading
getBoundingClientRect()on every element without making any DOM changes - Render shimmer overlays using the captured measurements, which doesn't affect the measured elements' layout
This batching strategy ensures that even complex component trees with hundreds of elements trigger only one reflow, keeping the measurement phase fast and preventing visual flicker. The "Edge Case: Table Cells" section below describes a specific optimization where this batching approach was critical for performance.
Developer API Design
With the technical constraints understood, the next challenge was designing an API that developers would actually want to use. The goal was to make shimmer loading trivially easy to add to any project - no configuration files, no build steps, no complex setup. Just wrap your component and get pixel-perfect loading states.
The API design centers on a simple wrapper pattern: the <Shimmer>component. You wrap any component you want to shimmer, pass a loadingboolean, and the library handles the rest:
import { Shimmer } from '@shimmer-from-structure/react';
function UserProfile({ userId }) {
const { data: user, isLoading } = useQuery(['user', userId], fetchUser);
return (
<Shimmer loading={isLoading}>
<div className="profile">
<img src={user?.avatar} alt={user?.name} />
<h2>{user?.name}</h2>
<p>{user?.bio}</p>
</div>
</Shimmer>
);
}When loading is true, the library measures the child component and renders shimmer overlays. When loading becomes false, the shimmer disappears and the real content shows. No separate skeleton component to maintain, no layout duplication, no drift between loading and loaded states.
Handling Dynamic Data with templateProps
But there's a problem: what if your component needs data to render? In the example above, user is undefined while loading, so the component would render empty - giving the measurement phase nothing to measure.
This is where templateProps comes in. You provide mock data that gets spread onto the child component during the measurement phase:
import { Shimmer } from '@shimmer-from-structure/react';
const mockUser = {
avatar: 'https://via.placeholder.com/150',
name: 'John Doe',
bio: 'Software engineer and open source contributor.',
};
function UserProfile({ userId }) {
const { data: user, isLoading } = useQuery(['user', userId], fetchUser);
return (
<Shimmer
loading={isLoading}
templateProps={{ user: mockUser }}
>
<div className="profile">
<img src={user?.avatar} alt={user?.name} />
<h2>{user?.name}</h2>
<p>{user?.bio}</p>
</div>
</Shimmer>
);
}During measurement, the library clones the child component and spreads templateProps onto it, so the component renders with mock data. The browser calculates layout based on this mock content, and the library captures those dimensions. When loading becomes false, the real data replaces the mock data, and because the layout structure is the same, the transition is seamless.
Design Choice: One Child at a Time
The <Shimmer> component accepts exactly one child that accepts props. This is a deliberate design constraint that keeps the API simple and predictable:
- Clear prop spreading: The library knows exactly where to spread
templateProps- onto the single child component - Predictable behavior: Developers don't have to guess which child receives which props
- Composability: If you need to shimmer multiple components, wrap each one individually or wrap a parent container
- Framework compatibility: This pattern works consistently across React, Vue, Svelte, Angular, and Solid
This constraint trades flexibility for simplicity. You can't wrap multiple sibling components in a single <Shimmer>, but in practice, this is rarely needed - and when it is, wrapping a parent container works just as well. The benefit is an API that's immediately understandable and works the same way in every framework.
Architecture Decision
One of the most important architectural decisions in shimmer-from-structure was how to support multiple JavaScript frameworks (React, Vue, Svelte, Angular, SolidJS) without duplicating the core measurement and shimmer logic. The solution is a monorepo architecture with a framework-agnostic core package and framework-specific adapter packages.
Core Package
The @shimmer-from-structure/core package contains all the framework-agnostic DOM measurement and shimmer logic. This includes:
extractElementInfo()- ReadsgetBoundingClientRect()and computed styles on a DOM element to produce anElementInfoobject with position, dimensions, and border radiusisLeafElement()- Determines whether an element should receive a shimmer block (ignores void elements like<br>,<wbr>,<hr>)createResizeObserver()- SharedResizeObserverutility withrequestAnimationFramethrottling for responsive shimmer updatesSHIMMER_CONTAINER_STYLES- CSS string applied to measurement containers, handlingdata-shimmer-ignoreanddata-shimmer-no-childrenattribute exclusionsshimmerDefaults- Default configuration values (colors, duration, border radius) shared across all adapters- TypeScript types - Shared interfaces like
ElementInfo,ShimmerConfig, andShimmerContextValue
The key insight is that all of these utilities use cross-framework browser APIs. getBoundingClientRect(), getComputedStyle(), ResizeObserver, and DOM traversal work identically in React, Vue, Svelte, Angular, and SolidJS. There's no framework-specific logic in the core package - it's pure DOM manipulation.
This design means that bug fixes, performance optimizations, and new features in the measurement logic only need to be implemented once. When the table cell batching optimization (described in the "Edge Case: Table Cells" section) was added to core, all five framework adapters immediately benefited without any adapter-specific changes.
Framework Adapters
Each framework adapter (@shimmer-from-structure/react, @shimmer-from-structure/vue, @shimmer-from-structure/svelte, @shimmer-from-structure/angular, @shimmer-from-structure/solid) is a thin wrapper around the core package. Adapters are responsible for:
- Hooking into framework-specific rendering lifecycles - React uses
useLayoutEffect(synchronous, before paint), Vue useswatchandnextTick, Svelte uses$effectandonMount, Angular usesngAfterViewInit, and SolidJS usescreateEffect - Managing component state - Each framework has its own reactivity system (React's
useState, Vue'sref, Svelte's runes, Angular's signals, SolidJS's signals) - Providing global configuration - React uses Context API (
ShimmerProvider), Vue usesprovide/inject, Svelte usessetContext/getContext, Angular uses dependency injection (provideShimmerConfig), and SolidJS usescreateContext - Rendering shimmer overlays - Each framework has its own templating syntax (JSX, Vue templates, Svelte templates, Angular templates)
- Handling SSR detection - Checking for
typeof window !== 'undefined'before calling core measurement functions
The adapters import core utilities and call them at the appropriate points in each framework's lifecycle. For example, the React adapter calls extractElementInfo() inside a useLayoutEffect hook, which runs synchronously before the browser paints, preventing visual flicker. The Vue adapter calls the same function inside a watch callback with nextTick for DOM updates. The measurement logic is identical - only the timing and lifecycle integration differs.
This separation between core logic and framework-specific code has several benefits:
- Consistency: All frameworks get the same measurement behavior, shimmer animation, and configuration options
- Maintainability: Core logic changes don't require updating five separate adapters
- Testability: Core utilities can be unit tested in isolation without framework-specific test setup
- Extensibility: Adding support for a new framework only requires writing a thin adapter - the core logic is already done
The architecture can be visualized as a core package with multiple framework adapters depending on it:
┌─────────────────────────────────────────┐
│ @shimmer-from-structure/core │
│ (extractElementInfo, isLeafElement, │
│ createResizeObserver, etc.) │
└─────────────────┬───────────────────────┘
│
┌─────────┼─────────┬─────────┬─────────┐
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌───────┐ ┌───────┐ ┌────────┐ ┌─────────┐ ┌───────┐
│ React │ │ Vue │ │ Svelte │ │ Angular │ │ Solid │
└───────┘ └───────┘ └────────┘ └─────────┘ └───────┘
This architecture ensures that shimmer-from-structure can support any JavaScript framework without compromising on consistency, performance, or maintainability. The core package handles the complex DOM measurement and reflow optimization logic, while adapters focus solely on framework integration - a clean separation of concerns that scales as new frameworks are added.
Handling Real-World Data
The measurement approach works beautifully for static components, but real-world applications present a challenge: components typically render dynamic data from APIs. A user profile card doesn't have hardcoded text - it displays a name, avatar, and bio fetched from a backend. A product listing shows items loaded from a database. A dashboard renders metrics pulled from analytics services.
This creates a chicken-and-egg problem for shimmer measurement.
The Problem
When loading is true, the data hasn't arrived yet. If your component expects a user prop and that prop is undefined, the component might render nothing - or worse, crash with a null reference error. Either way, the measurement phase has nothing to measure. An empty component produces zero-dimension measurements, resulting in no shimmer blocks at all.
Consider this typical React component:
function UserCard({ user }) {
return (
<div className="card">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.bio}</p>
<span className="badge">{user.role}</span>
</div>
);
}If user is undefined, this component will throw an error trying to access user.avatar. Even with optional chaining (user?.avatar), the component renders empty content, giving the measurement phase nothing to work with.
You could add conditional rendering to handle the loading state:
function UserCard({ user }) {
if (!user) {
return <div className="card">Loading...</div>;
}
return (
<div className="card">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.bio}</p>
<span className="badge">{user.role}</span>
</div>
);
}But now you're back to square one - you've created a separate loading state that doesn't match the real component structure. The whole point of shimmer-from-structure is to avoid this duplication.
The Solution
The solution is templateProps - a way to provide mock data during the measurement phase so the component can render with realistic content, allowing the library to capture accurate dimensions. The mock data is only used internally for measurement; it never appears to the user.
Here's how it works in practice:
import { Shimmer } from '@shimmer-from-structure/react';
// Define mock data that matches the shape of real API data
const mockUser = {
avatar: 'https://via.placeholder.com/150',
name: 'John Doe',
bio: 'Software engineer and open source contributor with 6 years of experience.',
role: 'Senior Developer',
};
function UserProfile({ userId }) {
// Fetch real user data from API
const { data: user, isLoading } = useQuery(['user', userId], fetchUser);
return (
<Shimmer
loading={isLoading}
templateProps={{ user: mockUser }}
>
<UserCard user={user ?? mockUser} />
</Shimmer>
);
}
function UserCard({ user }) {
return (
<div className="card">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.bio}</p>
<span className="badge">{user.role}</span>
</div>
);
}When loading is true, the library:
- Clones the
<UserCard>component - Spreads
templatePropsonto it, so it receivesuser={mockUser} - Renders the component with mock data in a hidden measurement container
- Measures the resulting DOM structure using
getBoundingClientRect() - Generates shimmer overlays based on those measurements
- Renders the real component (which receives
user={mockUser}from the fallback) with shimmer overlays on top
When loading becomes false, the shimmer overlays are removed, and the component displays the real data. Because the mock data and real data have the same structure (same fields, similar text lengths), the layout remains consistent, and the transition is seamless.
The user ?? mockUser fallback in the example ensures the component always receives valid data, preventing null reference errors during the loading state. This pattern works across all supported frameworks - React, Vue, Svelte, Angular, and SolidJS.
Why One Child at a Time?
You might wonder why <Shimmer> only accepts a single child component that accepts props. This design constraint exists for a practical reason:prop spreading needs an unambiguous target.
If <Shimmer> accepted multiple children, which one should receive templateProps? The first? All of them? What if they expect different prop shapes? The API would become confusing and error-prone.
By restricting to one prop-accepting child, the behavior is predictable:
- Clear semantics:
templatePropsalways goes to the single child component - Type safety: TypeScript can infer the correct prop types
- Framework consistency: The pattern works identically in React, Vue, Svelte, Angular, and SolidJS
- Composability: If you need to shimmer multiple components, wrap each one individually or wrap a parent container
This constraint trades flexibility for simplicity and predictability - a deliberate design choice that makes the library easier to use correctly and harder to use incorrectly.
Minimizing Reflows
Performance is critical for loading states. If the shimmer takes too long to appear or causes visible stuttering, users will notice - and the experience degrades. One of the most important performance optimizations in shimmer-from-structure is minimizing browser reflows during the measurement phase.
What are Reflows?
A reflow (also called layout recalculation or layout thrashing when it happens repeatedly) is a browser operation that recalculates the position and dimensions of elements in the document. When you change an element's size, position, or content, the browser must recalculate the layout of that element and potentially all of its descendants and ancestors.
Reflows are expensive. For complex layouts with hundreds or thousands of elements, a single reflow can take several milliseconds. This matters because:
- Reflows block the main thread - JavaScript execution pauses while the browser recalculates layout
- Reflows cause visual flicker - if measurement takes too long, users see a flash of unstyled content before the shimmer appears
- Multiple reflows compound - interleaving DOM writes and reads forces the browser to reflow repeatedly, multiplying the performance cost
Reading layout properties like getBoundingClientRect(), offsetWidth, clientHeight, or getComputedStyle() forces the browser to perform a reflow if any DOM changes have occurred since the last layout calculation. This is called a forced synchronous layout.
The worst-case scenario is layout thrashing - alternating between DOM writes (changing styles or content) and DOM reads (measuring dimensions). Each read forces a reflow to get accurate measurements, then the next write invalidates the layout, and the cycle repeats. This can easily consume tens or hundreds of milliseconds, causing visible performance problems.
Base Case
The library's measurement strategy is designed to trigger only one reflow per measurement cycle, regardless of how many elements are being measured. This is achieved through careful batching of DOM operations into three distinct phases:
- Write Phase: Apply all CSS changes first - set
color: transparenton text elements, inject measurement container styles, applydata-shimmer-ignoreexclusions - without reading any layout properties - Read Phase: Perform all measurements in a single pass, calling
getBoundingClientRect()on every element that needs to be measured, without making any DOM changes - Render Phase: Generate and render shimmer overlays using the captured measurements, which doesn't affect the measured elements' layout since overlays are absolutely positioned
This batching strategy ensures that the browser only needs to recalculate layout once - at the start of the read phase, after all CSS changes have been applied. Even if you're measuring a component tree with hundreds of elements, the library triggers only one reflow.
For typical component trees (dozens to hundreds of elements), the entire measurement cycle completes in 2-5 milliseconds, well within the 16.67ms frame budget for 60fps rendering. This means the shimmer appears instantly without any visible flicker or stuttering.
The "Edge Case: Table Cells" section (if implemented) describes a specific scenario where this batching approach was critical - measuring table cell text required temporarily injecting span elements, and the naive sequential approach caused multiple reflows. The optimized solution applies the same three-phase batching pattern to achieve one reflow even for complex table layouts.
ResizeObserver
The measurement phase handles the initial shimmer rendering, but what happens when the window resizes? Responsive layouts change dimensions at different breakpoints - a three-column grid might become two columns on tablets and one column on mobile. The shimmer needs to update to match the new layout.
The library uses the ResizeObserver API to detect when the measured container's dimensions change. When a resize is detected, the library re-measures the component and updates the shimmer overlays to match the new layout.
Critically, ResizeObserver callbacks are automatically batched by the browser and fire after layout has been calculated but before paint. This means:
- No forced synchronous layouts - the browser has already calculated the new layout when the callback fires, so reading
getBoundingClientRect()doesn't trigger an additional reflow - Automatic batching - if multiple elements resize simultaneously (common during window resize), the browser batches all resize notifications into a single callback invocation
- Optimal timing - the callback fires at the ideal moment to read layout properties without causing performance issues
The library further optimizes resize handling by throttling updates using requestAnimationFrame. This ensures that even if the user rapidly resizes the window, shimmer updates are limited to once per frame (60fps), preventing unnecessary re-measurements and keeping the UI responsive.
The createResizeObserver utility in the core package implements this optimization and is shared across all framework adapters (React, Vue, Svelte, Angular, SolidJS). This means every framework gets the same efficient resize handling without duplicating the throttling logic.
The combination of one-reflow measurement and efficient resize handling ensures that shimmer-from-structure maintains excellent performance even in complex, responsive layouts. Users never see flicker or stuttering, and the shimmer always matches the current layout - regardless of screen size or window dimensions.
Edge Case: Table Cells
While the three-phase batching strategy works well for most elements, table cells presented a unique challenge that required a specialized optimization. This edge case demonstrates how the reflow minimization principles apply even to complex scenarios.
The Problem
When measuring table cells (<td> and <th>elements), we want to capture the dimensions of the text content, not the entire cell. This is because table cells often have padding, and measuring the full cell would create shimmer blocks that extend into the padding area, looking visually incorrect.
Consider a typical table cell:
<td style="padding: 12px;">
Product Name
</td>If we measure the <td> element directly using getBoundingClientRect(), we get the dimensions of the entire cell including the 12px padding on all sides. The shimmer block would cover the padding area, creating a visual mismatch - the shimmer would be larger than the actual text.
What we really want is to measure just the text content, excluding the cell's padding. But text nodes don't have getBoundingClientRect() - only elements do. We need a way to measure the text dimensions without measuring the cell's padding.
Initial Approach
The naive solution is to temporarily wrap the text content in a <span> element, measure the span, then remove it:
// For each table cell with text-only content:
const span = document.createElement('span');
span.style.display = 'inline';
// Move text into span
while (cell.firstChild) {
span.appendChild(cell.firstChild);
}
cell.appendChild(span);
// Measure the span (not the cell)
const rect = span.getBoundingClientRect();
// Remove the span and restore text
while (span.firstChild) {
cell.insertBefore(span.firstChild, span);
}
cell.removeChild(span);This approach works correctly - the span wraps only the text content, so measuring it gives us the text dimensions without the cell's padding. However, there's a critical performance problem: this creates multiple reflows.
If you process table cells sequentially - wrap, measure, unwrap, repeat - you're interleaving DOM writes (wrapping/unwrapping) with DOM reads (measuring). Each measurement forces a reflow because the previous wrap operation invalidated the layout. For a table with dozens of cells, this could trigger dozens of reflows, causing visible performance degradation.
Optimized Solution
The solution is to apply the same three-phase batching pattern used for the overall measurement strategy. Instead of processing cells sequentially, we batch all table cell operations into three distinct phases:
- Phase 1 - Writes Only: Traverse the DOM tree, identify all text-only table cells, and wrap their content in
<span>elements. Collect references to the wrapped cells for later measurement. Do not callgetBoundingClientRect()yet. - Phase 2 - Measurements: Measure all wrapped spans (and all other leaf elements) in a single pass. The first
getBoundingClientRect()call triggers one reflow, and subsequent calls use the cached layout. - Phase 3 - Cleanup: Remove all temporary span wrappers and restore the original text nodes. This happens after all measurements are complete, so it doesn't affect the captured dimensions.
This batching approach ensures that only one reflow occurs, regardless of how many table cells need to be measured. Even a complex data table with hundreds of cells triggers just one reflow during the measurement phase.
Here's the implementation structure from the core package:
function extractElementInfo(element: Element, parentRect: DOMRect): ElementInfo[] {
const leafElements: LeafElement[] = [];
const wrappedCells: WrappedCell[] = [];
// Phase 1: Collect leaf elements and wrap table cells (writes only)
collectLeafElements(element, leafElements, wrappedCells);
// Phase 2: Measure all elements (reads only - triggers one reflow)
const elements = measureElements(leafElements, wrappedCells, parentRect);
// Phase 3: Clean up temporary wrappers (writes only)
cleanupWrappedCells(wrappedCells);
return elements;
}
function collectLeafElements(
element: Element,
leafElements: LeafElement[],
wrappedCells: WrappedCell[]
): void {
// ... traverse DOM tree ...
const isTableCell = tag === 'td' || tag === 'th';
if (isTableCell && hasOnlyTextContent(element)) {
// Wrap text in span for measurement
const span = document.createElement('span');
span.style.display = 'inline';
while (element.firstChild) {
span.appendChild(element.firstChild);
}
element.appendChild(span);
// Store reference for Phase 2 measurement
wrappedCells.push({ element, span, borderRadius });
}
}
function measureElements(
leafElements: LeafElement[],
wrappedCells: WrappedCell[],
parentRect: DOMRect
): ElementInfo[] {
const elements: ElementInfo[] = [];
// Measure regular leaf elements
leafElements.forEach(({ element, borderRadius }) => {
const rect = element.getBoundingClientRect();
// ... store measurements ...
});
// Measure wrapped table cells
wrappedCells.forEach(({ span, borderRadius }) => {
const rect = span.getBoundingClientRect();
// ... store measurements ...
});
return elements;
}
function cleanupWrappedCells(wrappedCells: WrappedCell[]): void {
wrappedCells.forEach(({ element, span }) => {
// Restore original text nodes
while (span.firstChild) {
element.insertBefore(span.firstChild, span);
}
element.removeChild(span);
});
}The key insight is that batching DOM operations by type (all writes, then all reads, then all cleanup) prevents layout thrashing. The browser only needs to recalculate layout once - at the start of Phase 2, after all span wrappers have been created. All subsequent measurements in Phase 2 use the cached layout, and the cleanup in Phase 3 happens after measurements are complete, so it doesn't affect the captured dimensions.
This optimization is implemented in the extractElementInfo function in the core package, which means all framework adapters (React, Vue, Svelte, Angular, SolidJS) automatically benefit from this performance improvement. When the optimization was added, no adapter-specific changes were needed - the improved performance appeared across all frameworks immediately.
The table cell edge case demonstrates a broader principle: performance optimizations in DOM manipulation often come from careful batching of operations. By separating writes from reads and processing elements in batches rather than sequentially, we can achieve dramatic performance improvements - in this case, reducing dozens of potential reflows down to just one.