Dev.to · 9 min read

From `main.js` Leaks to Hidden Tailwind CSS v4 Bloat — How I Built `angular-bundle-guard` with Antigravity `/learn` [GDE]

From `main.js` Leaks to Hidden Tailwind CSS v4 Bloat — How I Built `angular-bundle-guard` with Antigravity `/learn` [GDE]

Day 33: From main.js Leaks to Hidden Tailwind CSS v4 Bloat — How I Built angular-bundle-guard with Antigravity /learn I briefly paused this series for a side quest that started with a faded memory of what my Angular bundle size used to be. As I was making code changes across ng-firebase-tts and ng-firebase-image-analyzer in this series, I noticed main.js and the initial bundle total looked bigger than I remembered. I asked Gemini 4 in Antigravity CLI why the bundle had grown, Gemini 4 tracked down the root cause, and we fixed it. Then a few changes later, it happened again. And again. After having the same bundle-debugging conversation one too many times, I finally hit my limit: "I am tired of this—let's build a skill to save my sanity." So I paused writing the series and asked /learn and Antigravity CLI to turn our repeated debugging sessions into a reusable skill: angular-bundle-guard. I didn't write a single line of its Node.js static-analysis code myself. Every time we uncovered a new reason main.js or a lazy chunk was bloated in ng-firebase-tts or ng-firebase-image-analyzer—or found a new way to shave bytes off component CSS—we fed it back into the skill until it evolved into a full JavaScript and Tailwind CSS v4 bundle auditor. Architecture & Mental Model: How the Skill Evolved Even that "faded memory of the previous bundle size" is baked into SKILL.md, where Source A for determining the baseline build size is literally User Memory / Target (before falling back to earlier session builds or a temporary git worktree against HEAD). We didn't design this entire architecture upfront. We added each check organically via /learn as we hit real bundle problems in ng-firebase-tts and ng-firebase-image-analyzer, eventually growing into a single-pass analyzer (analyze-bundle.mjs) built with zero external dependencies (node:fs, node:path, node:crypto, node:os, node:child_process). flowchart TD Trigger["User Memory or Git Baseline"] --> Build["analyze-bundle.mjs (Smart stats.json Check)"] Build --> Stage1["Stage 1: Initial Bundle and Lazy JS Audit"] Build --> Stage2["Stage 2: Component CSS and Tailwind v4 Audit"] Stage1 --> V8["8 Initial Bundle Leak Vectors (main.js)"] Stage1 --> L3["3 Lazy Chunk Leak Vectors (chunk-*.js)"] Stage2 --> C1["1. Dead CSS and Unused @theme Tokens"] Stage2 --> C2["2. Short Static @apply ( 1 KB Table"] Report --> Refactor["Incremental Byte Shaving: [class] Bindings, focus:outline-*, Trim Typography, Spacing, and Duration"] Act 1: Saving My Sanity—Automating the main.js & Lazy Chunk Leak Hunt In those early conversations with Gemini 4 in Antigravity CLI, the goal was simple: figure out why a code change caused main.js or a lazy chunk to grow or slow down. Three patterns in particular convinced me we needed both automated guardrails and a dedicated skill. Leak 1: The Mixed Barrel File (index.ts) Trap in core/ When building services and utility functions under core/, it is common practice to re-export everything—services, utility functions, constants, types, and interfaces—from a single core//index.ts barrel file for cleaner imports. However, we discovered two painful ways mixed barrel files leak code into main.js: injectAsync() Sabotage: When a component used injectAsync() (our async service injection helper) to lazy-load a heavy service from core/, while an eager file in main.js statically imported a constant or helper from that same core/ barrel, the bundler evaluated the barrel and pulled the lazy service straight into main.js—reducing the injectAsync() chunk to a tiny, useless wrapper! Collateral Service Imports: Even without injectAsync(), when an eager file imported a small utility function from a barrel file, the bundler also pulled the services re-exported in that barrel into the initial bundle. That led to our strict rule of thumb, which angular-bundle-guard now enforces as Vector 1: Important Barrel File Rule of Thumb: Only export lightweight items (types, interfaces, and constants) from a barrel index.ts file. Always import utility functions and services directly from their discrete file paths. // AVOID in src/app/core/ai/index.ts: Re-exporting services and utilities in a barrel export * from './ai.type'; export * from './ai.constant'; export * from './ai.util'; export * from './ai.service'; // Leaks into main.js when an eager file imports from '@/core/ai'! // GOOD in src/app/core/ai/index.ts: Only export types, interfaces, and constants export type * from './ai.type'; export type * from './ai.interface'; export * from './ai.constant'; // Import utilities and services directly from their file paths: import { formatPrompt } from '@/core/ai/ai.util'; // And at the class level in a component: private readonly aiService = injectAsync(() => import('@/core/ai/ai.service').then((m) => m.AiService), ); Leak 2: Missing import type Declarations & Automated ESLint Fixes Another sneaky culprit was importing interfaces or types using standard value imports (import { SomeModel } from '...') instead of import type. When the TypeScript compiler does not elide them, value imports leave runtime module edges that can pull unintended files into the bundle. Tracking down that type-import error led me to discover @typescript-eslint/consistent-type-imports. I added the rule to eslint.config.mjs: // eslint.config.mjs rules: { '@typescript-eslint/consistent-type-imports': ['error', { disallowTypeAnnotations: false }], } I then configured a lint:fix script in package.json: { "scripts": { "lint:fix": "eslint \"src/**/*.{ts,js,mjs}\" --fix" } } Now, running npm run lint:fix converts value imports of types into import type immediately, while angular-bundle-guard double-checks for any remaining value imports from .interface.ts or .type.ts files during bundle audits. Leak 3: Sequential Lazy Chunk Downloads (@defer + injectAsync() and Sequential await import()) Once we started splitting code out of main.js, we encountered a new problem: sequential network calls where one lazy download waited for another to finish. Through /learn, we taught angular-bundle-guard to catch both patterns under Lazy Vector 2 (Sequential Lazy Chunk Downloads). Example 1: Using injectAsync() Inside a @defer Component We use @defer to separate non-critical components from main.js and the initial bundle so the browser downloads their JavaScript chunks on demand. With injectAsync(), you declare the helper at the class level (to capture Angular's injection context) and invoke it via await this.featureService() inside a method. However, doing this inside a @defer component triggers two sequential network calls: first downloading the @defer component chunk, and then making an extra network call when await this.featureService() runs. Because a deferred component is already separated from main.js and the initial bundle, loading its service upfront with the standard inject() function bundles the service into the @defer chunk and saves that extra network round trip: // AVOID inside a @defer component: Leads to an extra sequential network call! export class DeferredFeatureComponent { private readonly featureService = injectAsync(() => import('@/core/feature/feature.service').then((m) => m.FeatureService), ); async runAction() { // Extra network round trip after the @defer component chunk is already downloaded const service = await this.featureService(); return service.execute(); } } // GOOD inside a @defer component: Already separated from main.js—load the service upfront! export class DeferredFeatureComponent { // Bundled with the @defer chunk; saves the extra network round trip private readonly featureService = inject(FeatureService); runAction() { return this.featureService.execute(); } } Example 2: Awaiting Independent Dynamic import() Calls Sequentially Another sequential download occurred in loadFirebase(), where we awaited two independent Firebase imports—firebase/app and firebase/remote-config—one after the other. Because those two module downloads don't depend on each other, awaiting them sequentially forced the second chunk to wait for the first chunk to finish downloading. Using await Promise.all() downloads both Firebase chunks concurrently: // AVOID: Awaiting two independent Firebase imports one after another private async loadFirebase(): Promise { try { const { initializeApp } = await import('firebase/app'); const remoteConfigSdk = await import('firebase/remote-config'); // ... other initialization code ... } catch (error) { // ... error cleanup ... } } // GOOD: Download independent Firebase imports concurrently with await Promise.all() private async loadFirebase(): Promise { try { const [{ initializeApp }, remoteConfigSdk] = await Promise.all([ import('firebase/app'), import('firebase/remote-config'), ]); // ... other initialization code ... } catch (error) { // ... error cleanup ... } } All 8 Initial Bundle Vectors & 3 Lazy Chunk Vectors As we kept debugging different bundle regressions with Gemini 4 and running /learn, the skill grew to audit 8 Initial Bundle Leak Vectors (such as mixed barrels, missing import type, top-level heavy SDKs, viewChild(ComponentClass) breaking @defer, eager routes in app.routes.ts, and static JSON imports) plus 3 Lazy Chunk Vectors (bloated lazy chunks, sequential lazy downloads, and shared barrel leaks across lazy chunks). Act 2: The Plot Twist—Why Component CSS Was Bloating Our JS Chunks Once the JavaScript import leaks were under control, the skill evolved into something I hadn't originally anticipated: hunting CSS bloat inside JavaScript chunks. In Angular's default ViewEncapsulation.Emulated mode: Global src/styles.css stays in the .css file (0 B in main.js). Every component with styleUrl compiles its CSS rules into JavaScript string literals (styles: ["..."]) embedded directly inside main.js or its lazy .js chunk. Even worse, our projects used Tailwind CSS v4. Whenever a component's .css file used @apply with utilities that rely on CSS custom properties—such as border, ring, shadow, font-sans, leading-*, tracking-*, bg-linear-*, translate, scale, animate-*, or duration-*—Tailwind CSS v4 injected ~2.5 to 3.2 kB of @property --tw-* and @supports definitions into that component's compiled JS chunk: /* Inside a component .css file */ .card-wrapper { @apply flex flex-col gap-4 rounded-xl border p-6 shadow-sm; } // What actually gets embedded inside your Angular lazy chunk-*.js (simplified): styles: [".card-wrapper[_ngcontent-%COMP%]{display:flex;flex-direction:column;...}@property --tw-border-style{syntax:'*';inherits:false;initial-value:solid}@property --tw-shadow{syntax:'*';inherits:false;initial-value:0 0 #0000}... /* ~3 kB of @property rules per component! */"] Seeing ~3 kB of duplicate @property boilerplate repeated across lazy chunks for components that only had a few lines of @apply CSS was eye-opening—and became the next target for our skill. Act 3: Teaching the Skill to Find Dead CSS, Static Utilities, & Duplicates Because common utility classes (flex, flex-col, gap-4, w-full) already exist in the global styles.css file, moving static utilities from a component's .css file to class="..." in its .html template adds 0 B to the global CSS bundle while shrinking the JS chunk—and if you remove every @apply trigger from a component, its entire ~3 kB @property block disappears! To keep HTML templates readable while maximizing bundle savings, /learn taught angular-bundle-guard to audit 4 CSS Optimization Categories using a 3 starts feeling like too many classes cluttering the HTML. (That said, I sometimes manually relax the rule to 4 classes when the utilities are intuitive and inlining that fourth class lets me delete the .css rule to shrink the bundle!) 1. Dead and Unused CSS & Vanilla Rules After rounds of component changes, dead .class selectors, unused @keyframes, and unused --* custom properties often linger in .css files. The skill scans sibling .html and .ts files to flag dead classes that still compile into JS chunks. 2. Short Static @apply Classes ( Full Move to HTML) If a component .css class only wraps 1–3 static Tailwind utilities and replacing it in .html keeps the element at

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News