You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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':
<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:
#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)
tsup.config.ts — scssModulesPlugin emits each .scss as pure exports: export const css: string, export const styleId: string, default classnames map. No if (typeof document …) block.
src/utils/inject-style.ts — new ref-counted hook useInjectedStyle(styleId, css) that injects in useLayoutEffect and removes on the last unmount.
src/scss.d.ts — declare the named exports.
Each component that imports SCSS calls useInjectedStyle(styleId, css) once at the top of its function body. 12 component files, mechanical one-line additions.
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).
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)
freeze-animations.ts monkey-patches window.setTimeout, setInterval, and requestAnimationFrame at module top level. Also a side effect on bare import. Same fix pattern would apply but not included in this prototype.
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.
What
import { Agentation } from 'agentation'(or any import from the package entry) immediately appends ten<style id="feedback-tool-styles-*">elements todocument.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':<DevToolbar />returnsnullin production, but the bundled_app-*.jschunk still contains thefeedback-tool-styles-*strings and thedocument.head.appendChildIIFEs. 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'sscssModulesPluginemits one IIFE per.scssfile:injectAgentationColorTokens()is also called at the top ofpage-toolbar-css/index.tsx. Both run on module evaluation.This makes
"sideEffects": false(added in #125) inaccurate: tree-shaking-aware bundlers either: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: falsebecomes honest, FOUC stops (auseLayoutEffectruns synchronously before paint), and consumers who gate withNODE_ENVget 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:
tsup.config.ts—scssModulesPluginemits each.scssas pure exports:export const css: string,export const styleId: string, default classnames map. Noif (typeof document …)block.src/utils/inject-style.ts— new ref-counted hookuseInjectedStyle(styleId, css)that injects inuseLayoutEffectand removes on the last unmount.src/scss.d.ts— declare the named exports.useInjectedStyle(styleId, css)once at the top of its function body. 12 component files, mechanical one-line additions.injectAgentationColorTokens()rewritten as a pure CSS string constant injected via the same hook fromPageFeedbackToolbarCSS.Total: 15 files, +130/-65 LOC. All 85 tests pass (83 existing + 2 new lifecycle tests).
Empirical results
Verified against the built dist:
<style>tags after bareimport { Agentation }Consumer bundle (Webpack production,
process.env.NODE_ENV !== 'development'gated):document.head.appendChild(...)textContent='.styles-module__popup___...'{popup:"styles-module__popup___...",...}(pure data)<style>tags injected at page loadSame consumer with Rollup (Vite production):
sideEffects: false)Caveat: Webpack bundle size is unchanged
Webpack does import-level tree-shaking before constant folding the
NODE_ENVbranch, 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 needimport('agentation')dynamically.But the runtime behavior is what matters for the host-page-CSS-leak: with the patched bundle, the
document.headmutations are inuseInjectedStyle, 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)
freeze-animations.tsmonkey-patcheswindow.setTimeout,setInterval, andrequestAnimationFrameat module top level. Also a side effect on bare import. Same fix pattern would apply but not included in this prototype.svg[fill="none"]rule — addressed separately in Bug: Global svg[fill=none] rule in design-mode CSS leaks into host app (regression of #58) #181 / PR Fix design-mode svg[fill="none"] leaking into host page (#181) #185.Acceptance criteria
import { Agentation } from 'agentation'produces no<style>tags until the component mounts.sideEffects: falseinpackage.jsonmatches actual behavior.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).