All files / src/browser lazy-loader.ts

94.36% Statements 67/71
80% Branches 24/30
92.3% Functions 12/13
96.92% Lines 63/65

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215                                                                    2x                         2x     2x                                     2x                   24x 22x 22x 22x 22x 19x 19x                                                             21x 6x 6x 6x 2x 2x   4x 4x 4x   3x     1x 1x     6x     15x   15x 11x 11x   10x 10x   10x 10x 9x   9x   9x 9x   7x 7x     2x 2x         15x 28x 28x 13x 13x   15x 15x                         15x 15x 15x 15x 6x 6x 6x 6x   6x 6x   6x 6x 6x   6x 6x                   15x    
/**
 * @module Browser/LazyLoader
 * @description Lazy loads dashboard modules using IntersectionObserver.
 * Defers dynamic import() calls until the dashboard container enters the viewport,
 * reducing initial page load and improving Time to Interactive (TTI).
 *
 * Falls back to immediate loading when IntersectionObserver is unavailable.
 *
 * @performance Each lazy dashboard defers its dynamic import() until the section
 * scrolls into view (plus a `DEFAULT_ROOT_MARGIN` pre-fetch margin), preventing
 * Chart.js (~200 KB), D3 (~250 KB) and PapaParse (~50 KB) from blocking the
 * initial parse/render.
 *
 * The default pre-fetch margin (`2000 px`) is intentionally generous so that on
 * dedicated `/dashboards/<name>.html` pages — where a single dashboard container
 * frequently sits 1 000–1 500 px below the fold beneath hero + navigation — the
 * IntersectionObserver still fires immediately without any user scroll. A
 * narrower 200 px margin (the previous default) silently broke those pages
 * because the dashboard never entered the observer's root.
 */
 
import { logger } from './shared/logger.js';
 
// ─── Types ────────────────────────────────────────────────────────────────────
 
/** A dashboard that should be loaded lazily when its container enters the viewport. */
export interface LazyDashboard {
  /** The `id` attribute of the section/container element to observe. */
  containerId: string;
  /** Async function that dynamically imports and initialises the dashboard. */
  loader: () => Promise<void>;
}
 
/** CSS class applied to a container while its module is loading. */
export const CHART_SKELETON_CLASS = 'chart-skeleton';
 
/**
 * Default `IntersectionObserver` `rootMargin` used by {@link initLazyDashboards}.
 *
 * Set generously (≈ 2 viewport heights) so that on dedicated dashboard pages
 * (`/dashboards/<name>.html`) the single below-fold dashboard container always
 * pre-fetches without requiring user scroll — the previous `200px` value left
 * those pages with empty charts until the user scrolled, because the only
 * dashboard on the page sits ~1 000–1 500 px below a 720 px viewport.
 *
 * Tests assert this exact value to lock the contract.
 */
export const DEFAULT_ROOT_MARGIN = '2000px';
 
/** Default intersection threshold paired with {@link DEFAULT_ROOT_MARGIN}. */
export const DEFAULT_THRESHOLD = 0.01;
 
/**
 * Narrow `IntersectionObserver` `rootMargin` to use on pages that are NOT
 * a single-dashboard "deep link" (`/dashboards/<slug>.html`,
 * `/politician-dashboard*.html`).  Multi-section pages such as `index*.html`
 * have several below-the-fold sections — `#coalition-status` typically sits
 * 600–1400 px below the fold — and the 2000 px {@link DEFAULT_ROOT_MARGIN}
 * would pre-fetch them eagerly, defeating the entire point of lazy loading.
 *
 * The previous behaviour caused the homepage to download
 * `view_riksdagen_politician_experience_summary_sample.csv` (≈ 5.8 MiB) and
 * `view_riksdagen_politician_sample.csv` (≈ 542 KiB) on initial render via
 * `coalition-loader`, driving Lighthouse Performance to 4 (LCP 5.3 s, TBT
 * 5,330 ms).  A 300 px margin still pre-fetches a touch before the section
 * scrolls into view (smooth UX) without forcing the download on page load.
 *
 * Tests assert this exact value to lock the contract.
 */
export const HOMEPAGE_ROOT_MARGIN = '300px';
 
/**
 * Parse the first component of an `IntersectionObserver` `rootMargin` string
 * (e.g. `"2000px"`, `"100px 50px"`) into a pixel number. Returns `0` when the
 * value is missing, percentage-based, or otherwise unparseable — this is
 * intentionally conservative so the eager-load fallback never fires for
 * containers that the observer itself would not consider intersecting.
 */
export function parseRootMarginPx(rootMargin: string | undefined): number {
  if (!rootMargin) return 0;
  const first = rootMargin.trim().split(/\s+/)[0];
  Iif (!first) return 0;
  const match = /^(-?\d+(?:\.\d+)?)px$/.exec(first);
  if (!match) return 0;
  const value = Number(match[1]);
  return Number.isFinite(value) ? value : 0;
}
 
// ─── Public API ───────────────────────────────────────────────────────────────
 
/**
 * Register dashboard modules for lazy loading via IntersectionObserver.
 *
 * When a container element intersects the viewport (with a 200 px pre-fetch
 * margin), its loader is called, the skeleton class is added, and removed
 * once the promise resolves or rejects.
 *
 * If `IntersectionObserver` is unavailable (old browser), all loaders are
 * invoked immediately as a graceful fallback (containers still checked for DOM
 * presence before loading).
 *
 * The created `IntersectionObserver` is returned so callers can hold a
 * reference (preventing GC) and call `disconnect()` if needed. `undefined` is
 * returned when the fallback path is taken.
 *
 * @param dashboards - Array of lazy-loadable dashboard descriptors.
 * @param options    - Optional `IntersectionObserver` init overrides.
 * @returns The active `IntersectionObserver`, or `undefined` in fallback mode.
 */
export function initLazyDashboards(
  dashboards: LazyDashboard[],
  options: IntersectionObserverInit = {
    rootMargin: DEFAULT_ROOT_MARGIN,
    threshold: DEFAULT_THRESHOLD,
  },
): IntersectionObserver | undefined {
  if (typeof IntersectionObserver === 'undefined') {
    for (const { containerId, loader } of dashboards) {
      const el = document.getElementById(containerId);
      if (!el) {
        logger.debug(`Lazy loader (fallback): #${containerId} not in DOM, skipping`);
        continue;
      }
      el.classList.add(CHART_SKELETON_CLASS);
      Promise.resolve()
        .then(() => loader())
        .then(() => {
          el.classList.remove(CHART_SKELETON_CLASS);
        })
        .catch((err: unknown) => {
          el.classList.remove(CHART_SKELETON_CLASS);
          logger.error(`Lazy load failed for #${containerId}:`, err);
        });
    }
    return undefined;
  }
 
  const pending = new Map<Element, () => Promise<void>>();
 
  const observer = new IntersectionObserver((entries: IntersectionObserverEntry[]) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
 
      const el = entry.target as HTMLElement;
      observer.unobserve(el);
 
      const loaderFn = pending.get(el);
      if (!loaderFn) continue;
      pending.delete(el);
 
      el.classList.add(CHART_SKELETON_CLASS);
 
      Promise.resolve()
        .then(() => loaderFn())
        .then(() => {
          el.classList.remove(CHART_SKELETON_CLASS);
          logger.debug(`✓ lazy loaded #${el.id}`);
        })
        .catch((err: unknown) => {
          el.classList.remove(CHART_SKELETON_CLASS);
          logger.error(`✗ lazy load failed #${el.id}:`, err);
        });
    }
  }, options);
 
  for (const { containerId, loader } of dashboards) {
    const el = document.getElementById(containerId);
    if (!el) {
      logger.debug(`Lazy loader: #${containerId} not in DOM, skipping`);
      continue;
    }
    pending.set(el, loader);
    observer.observe(el);
  }
 
  // Belt-and-suspenders fallback: on dedicated dashboard pages the single
  // observed container often sits 1 000–1 500 px below the fold, well
  // outside the default 200 px pre-fetch margin used previously. Even with
  // the new 2 000 px DEFAULT_ROOT_MARGIN, some browser / iframe contexts
  // (e.g. Cypress AUT) do not always deliver an initial intersection entry
  // without a user-driven scroll, leaving the dashboard empty until the
  // user scrolls. Defensively probe each observed container's geometry
  // against the configured rootMargin one frame after registration and
  // synthesise a load for anything that would intersect — this fires the
  // loader exactly once regardless of whether the observer also fires.
  Eif (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
    const rootMarginPx = parseRootMarginPx(options.rootMargin);
    window.requestAnimationFrame(() => {
      for (const [el, loaderFn] of pending) {
        const rect = (el as HTMLElement).getBoundingClientRect();
        const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
        const intersects = rect.bottom >= -rootMarginPx && rect.top <= viewportHeight + rootMarginPx;
        Iif (!intersects) continue;
 
        observer.unobserve(el);
        pending.delete(el);
 
        (el as HTMLElement).classList.add(CHART_SKELETON_CLASS);
        Promise.resolve()
          .then(() => loaderFn())
          .then(() => {
            (el as HTMLElement).classList.remove(CHART_SKELETON_CLASS);
            logger.debug(`✓ eager-loaded #${el.id} (near-viewport)`);
          })
          .catch((err: unknown) => {
            (el as HTMLElement).classList.remove(CHART_SKELETON_CLASS);
            logger.error(`✗ eager load failed #${el.id}:`, err);
          });
      }
    });
  }
 
  return observer;
}