Skip to content

Module-import side effects: CSS injected into host page even when component never mounts #186

@pcamarajr

Description

@pcamarajr

What

import { Agentation } from 'agentation' (or any import from the package entry) immediately appends ten <style id="feedback-tool-styles-*"> elements to document.head, regardless of whether the component ever renders. Several of those rules (e.g. svg[fill=none] { fill: none !important } from design-mode — see #181) leak into the host page.

Empirically, in a Next.js production build of a consumer that gates the toolbar with process.env.NODE_ENV !== 'development':

import { Agentation } from 'agentation';
export function DevToolbar() {
  if (process.env.NODE_ENV !== 'development') return null;
  return <Agentation />;
}

<DevToolbar /> returns null in production, but the bundled _app-*.js chunk still contains the feedback-tool-styles-* strings and the document.head.appendChild IIFEs. They execute at module evaluation, so all ten <style> tags appear in the host <head> even though the React tree never mounts the toolbar.

Why this happens

tsup.config.ts's scssModulesPlugin emits one IIFE per .scss file:

if (typeof document !== 'undefined') {
  let style = document.getElementById('feedback-tool-styles-…');
  if (!style) {
    style = document.createElement('style');
    style.id = 'feedback-tool-styles-…';
    document.head.appendChild(style);
  }
  style.textContent = css;
}

injectAgentationColorTokens() is also called at the top of page-toolbar-css/index.tsx. Both run on module evaluation.

This makes "sideEffects": false (added in #125) inaccurate: tree-shaking-aware bundlers either:

  • Trust the declaration and reorder/defer evaluation past first paint (Bug: CSS styles fail to inject on first load — unstyled settings panel #174 — Astro/Vite), causing FOUC, or
  • Trust the declaration and tree-shake correctly (Rollup), but still ship buggy behavior for any consumer who does import the entry, or
  • Detect the side effects and keep the module in the bundle anyway (Webpack/Next.js), at which point the IIFE runs and styles leak into production.

Conflict with #174's proposed fix

#174 proposes "sideEffects": ["./dist/index.js", "./dist/index.mjs"] to keep eager evaluation honest. That fixes the FOUC but cements the production-bloat problem — bundlers must then keep the import for any consumer that touches it, even when usage is dead code. It treats the symptom; the underlying contract (top-level DOM writes on import) is still broken.

The structurally-correct fix is to move CSS injection out of module evaluation and into the component lifecycle. Then sideEffects: false becomes honest, FOUC stops (a useLayoutEffect runs synchronously before paint), and consumers who gate with NODE_ENV get clean production behavior — all without sacrificing tree-shaking.

PR #169 (shadow DOM) implements roughly this approach as a superset (also adds shadow-DOM isolation). The proposal below is the minimum-viable subset, without the shadow-DOM scope changes — useful if PR #169 isn't landing in its current form.

How (minimum-change proposal, with working prototype)

Prototype branch on my fork: pcamarajr/agentation:experiment/lazy-css-injection.

Changes:

  1. tsup.config.tsscssModulesPlugin emits each .scss as pure exports: export const css: string, export const styleId: string, default classnames map. No if (typeof document …) block.
  2. src/utils/inject-style.ts — new ref-counted hook useInjectedStyle(styleId, css) that injects in useLayoutEffect and removes on the last unmount.
  3. src/scss.d.ts — declare the named exports.
  4. Each component that imports SCSS calls useInjectedStyle(styleId, css) once at the top of its function body. 12 component files, mechanical one-line additions.
  5. injectAgentationColorTokens() rewritten as a pure CSS string constant injected via the same hook from PageFeedbackToolbarCSS.

Total: 15 files, +130/-65 LOC. All 85 tests pass (83 existing + 2 new lifecycle tests).

Empirical results

Verified against the built dist:

unpatched 3.0.2 prototype
<style> tags after bare import { Agentation } 10 0
Tests 83/83 85/85
Mount injects styles n/a (already injected) yes
Unmount removes styles n/a yes

Consumer bundle (Webpack production, process.env.NODE_ENV !== 'development' gated):

unpatched 3.0.2 prototype
Bundle size 384 KB 384 KB (unchanged — see below)
Top-level code in the bundled chunk document.head.appendChild(...)textContent='.styles-module__popup___...' {popup:"styles-module__popup___...",...} (pure data)
<style> tags injected at page load yes no

Same consumer with Rollup (Vite production):

unpatched 3.0.2 prototype
Bundle size 120 B 120 B
Agentation in bundle no (Rollup already trusts sideEffects: false) no

Caveat: Webpack bundle size is unchanged

Webpack does import-level tree-shaking before constant folding the NODE_ENV branch, so the import is recorded as used even though the JSX never executes in the final binary. This isn't something the package can fix on its own — consumers wanting zero production bytes still need import('agentation') dynamically.

But the runtime behavior is what matters for the host-page-CSS-leak: with the patched bundle, the document.head mutations are in useInjectedStyle, which is only called inside the component's lifecycle. In a NODE_ENV-gated production build, the JSX is dead-coded out, the component never mounts, and the CSS never injects. Bundle size doesn't shrink, but host page styles are no longer affected — which is the main reason consumers gate the import in the first place.

Out of scope (noted for context)

Acceptance criteria

  • import { Agentation } from 'agentation' produces no <style> tags until the component mounts.
  • Mount injects, unmount removes (verifiable via the lifecycle test in the prototype).
  • sideEffects: false in package.json matches actual behavior.
  • No regressions in the existing 83 tests.

What to do with this

Posting this as an issue (not a PR) so you can decide whether (a) PR #169 is the right vehicle, (b) the smaller prototype is preferable, or (c) you want a different approach entirely. Happy to open a PR from the prototype branch if useful.

Cross-refs: #125 (sideEffects added), #174 (related — opposite proposed fix), #169 (shadow-DOM superset), #181 (scoping fix), PR #185 (#181's scoping fix).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions