diff --git a/.eslintignore b/.eslintignore index d2cde2d..58ba735 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,3 +2,7 @@ packages/angular/**/*.test.ts packages/angular/**/*.config.ts examples/angular-example/**/*.config.ts + +# Next.js build output +docs/.next/** +docs/node_modules/** diff --git a/docs/app/components/DocsLayout.tsx b/docs/app/components/DocsLayout.tsx index bd4dbd0..f3a1553 100644 --- a/docs/app/components/DocsLayout.tsx +++ b/docs/app/components/DocsLayout.tsx @@ -29,6 +29,7 @@ const navigation = [ { title: 'API Reference', href: '/docs/api' }, { title: 'Examples', href: '/docs/examples' }, { title: 'Best Practices', href: '/docs/best-practices' }, + { title: 'How This Works', href: '/docs/how-this-works' }, { title: 'Changelog', href: '/docs/changelog' }, ], }, diff --git a/docs/app/components/Header.tsx b/docs/app/components/Header.tsx index 912c1cd..5277969 100644 --- a/docs/app/components/Header.tsx +++ b/docs/app/components/Header.tsx @@ -58,6 +58,12 @@ export function Header() { > API + + How This Works + API + setMobileMenuOpen(false)} + > + How This Works + +
{/* Traffic-light dots */}
diff --git a/docs/app/docs/how-this-works/page.tsx b/docs/app/docs/how-this-works/page.tsx new file mode 100644 index 0000000..e938b4b --- /dev/null +++ b/docs/app/docs/how-this-works/page.tsx @@ -0,0 +1,1112 @@ +import { DocsLayout } from '../../components/DocsLayout'; + +export default function HowThisWorks() { + return ( + +
+

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: absolute to remove it from document flow +
  • +
  • + top and left values matching the measured element's position +
  • +
  • + width and height matching the measured element's dimensions +
  • +
  • + A shimmer animation (CSS gradient with background-position + keyframes) +
  • +
+ +

+ 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-ignore + and data-shimmer-no-children attributes +
  • +
  • + 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-children + to 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: +

+ +
    +
  1. + Apply all CSS changes first (set color: transparent, + inject measurement styles) without reading any layout properties +
  2. +
  3. + Perform all measurements in a single pass, reading{' '} + getBoundingClientRect() on every element without making any DOM changes +
  4. +
  5. + Render shimmer overlays using the captured measurements, which doesn't + affect the measured elements' layout +
  6. +
+ +

+ 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 loading + boolean, 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 (
+    
+      
+ {user?.name} +

{user?.name}

+

{user?.bio}

+
+
+ ); +}`}
+
+ +

+ 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 (
+    
+      
+ {user?.name} +

{user?.name}

+

{user?.bio}

+
+
+ ); +}`}
+
+ +

+ 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() + {' '} + - Reads getBoundingClientRect() and computed styles on a DOM element to + produce an ElementInfo object with position, dimensions, and border radius +
  • +
  • + + isLeafElement() + {' '} + - Determines whether an element should receive a shimmer block (ignores void elements + like <br>, <wbr>, <hr>) +
  • +
  • + + createResizeObserver() + {' '} + - Shared ResizeObserver utility with requestAnimationFrame + throttling for responsive shimmer updates +
  • +
  • + + SHIMMER_CONTAINER_STYLES + {' '} + - CSS string applied to measurement containers, handling{' '} + data-shimmer-ignore and data-shimmer-no-children attribute + exclusions +
  • +
  • + + shimmerDefaults + {' '} + - Default configuration values (colors, duration, border radius) shared across all + adapters +
  • +
  • + TypeScript types - Shared interfaces like ElementInfo,{' '} + ShimmerConfig, and ShimmerContextValue +
  • +
+ +

+ 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 uses watch{' '} + and nextTick, Svelte uses $effect and onMount, + Angular uses ngAfterViewInit, and SolidJS uses createEffect +
  • +
  • + Managing component state - Each framework has its own reactivity system + (React's useState, Vue's ref, Svelte's runes, Angular's + signals, SolidJS's signals) +
  • +
  • + Providing global configuration - React uses Context API ( + ShimmerProvider), Vue uses provide/inject, Svelte uses{' '} + setContext/getContext, Angular uses dependency injection ( + provideShimmerConfig), and SolidJS uses createContext +
  • +
  • + 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 (
+    
+ {user.name} +

{user.name}

+

{user.bio}

+ {user.role} +
+ ); +}`}
+
+ +

+ 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 
Loading...
; + } + + return ( +
+ {user.name} +

{user.name}

+

{user.bio}

+ {user.role} +
+ ); +}`}
+
+ +

+ 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 (
+    
+      
+    
+  );
+}
+
+function UserCard({ user }) {
+  return (
+    
+ {user.name} +

{user.name}

+

{user.bio}

+ {user.role} +
+ ); +}`}
+
+ +

+ When loading is true, the library: +

+ +
    +
  1. + Clones the <UserCard> component +
  2. +
  3. + Spreads templateProps onto it, so it receives{' '} + + user={'{'}mockUser{'}'} + +
  4. +
  5. Renders the component with mock data in a hidden measurement container
  6. +
  7. + Measures the resulting DOM structure using getBoundingClientRect() +
  8. +
  9. Generates shimmer overlays based on those measurements
  10. +
  11. + Renders the real component (which receives{' '} + + user={'{'}mockUser{'}'} + + from the fallback) with shimmer overlays on top +
  12. +
+ +

+ 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: templateProps always 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: +

+ +
    +
  1. + Write Phase: Apply all CSS changes first - set{' '} + color: transparent on text elements, inject measurement container styles, + apply data-shimmer-ignore exclusions - without reading any layout + properties +
  2. +
  3. + Read Phase: Perform all measurements in a single pass, calling{' '} + getBoundingClientRect() on every element that needs to be measured, without + making any DOM changes +
  4. +
  5. + Render Phase: Generate and render shimmer overlays using the captured + measurements, which doesn't affect the measured elements' layout since overlays are + absolutely positioned +
  6. +
+ +

+ 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:

+ +
+          {`
+  Product Name
+`}
+        
+ +

+ 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: +

+ +
    +
  1. + 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 call{' '} + getBoundingClientRect() yet. +
  2. +
  3. + 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. +
  4. +
  5. + 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. +
  6. +
+ +

+ 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. +

+
+
+ ); +}