跳到正文
DEV Community· Connie Leung·· 3 小時前AI 評分55

Connie Leung 如何用 Antigravity `/learn` 建立 `angular-bundle-guard`,排查 Angular `main.js` 與 Tailwind CSS v4 膨脹

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

AI 導讀

Connie Leung 以 Antigravity CLI 的 `/learn` 將反覆排查 Angular bundle 膨脹的流程整理成可重用技能 `angular-bundle-guard`,用來分析初始 JavaScript bundle、延遲載入區塊及 Tailwind CSS v4 元件樣式。

正文

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 (<= 3 Utilities to HTML)"]
    Stage2 --> C3["3. Partial Static Splits (Free HTML Slots + Variant Pairs)"]
    Stage2 --> C4["4. Duplicate @apply and @keyframes Consolidation"]

    V8 & L3 & C1 & C2 & C3 & C4 --> Report["HTML Report: Component CSS Files > 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/<domain>

When building services and utility functions under core/<domain>, it is common practice to re-export everything—services, utility functions, constants, types, and interfaces—from a single core/<domain>/index.ts barrel file for cleaner imports.

However, we discovered two painful ways mixed barrel files leak code into main.js:

  1. injectAsync() Sabotage: When a component used injectAsync() (our async service injection helper) to lazy-load a heavy service from core/<domain>, while an eager file in main.js statically imported a constant or helper from that same core/<domain> barrel, the bundler evaluated the barrel and pulled the lazy service straight into main.js—reducing the injectAsync() chunk to a tiny, useless wrapper!
  2. 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<void> {
  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<void> {
  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 classes per HTML element ceiling. Choosing 3 is a personal preference: < 3 is too strict to be practical, while > 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 (<= 3 Utilities -> 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 <= 3 total classes ((currentHtmlClassCount - 1) + utilities.length <= 3), the skill recommends moving the utilities to .html and deleting the .css rule (and when a rule has 4 intuitive utilities, I occasionally inline all 4 as well to eliminate the .css class):

<!-- Before: .results-wrapper in component.css had `@apply flex flex-col gap-6;` -->
<section class="results-wrapper">...</section>

<!-- After: 3 classes in HTML, 0 bytes in the component JS chunk! -->
<section class="flex flex-col gap-6">...</section>

3. Partial Static Splits (Keeping Variant Pairs Together)

When a rule has more than 3 utilities, the skill calculates how many free class slots remain on the HTML element (freeHtmlSlots = 3 - currentHtmlClassCount) and suggests moving standalone utilities (flex, w-full, relative) to .html while keeping the class name.

Tip

Specificity Guardrail Learned via /learn: The skill always keeps coupled x:* variant pairs in the same layer (for example, grid-cols-1 + md:grid-cols-2, or border + focus-visible:border-2) so Angular's [_ngcontent] selector in .css never overrides a responsive or focus utility in .html.

4. Duplicate @apply & @keyframes Consolidation

While auditing our stylesheets, we also found duplicate @apply rules and redundant @keyframes animations hiding in component .css files, which we taught the skill to catch automatically:

Duplicate @apply Selectors in the Same Component (.a vs. .b)

In a header component, we had two separate CSS classes—say .a and .b—that each applied the same ~10 utilities:

/* BEFORE: Two selectors in the same .css file with identical @apply utilities */
.a {
  @apply inline-flex items-center justify-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors;
}
.b {
  @apply inline-flex items-center justify-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium transition-colors;
}

There was no reason to compile both selectors into the JS chunk. The skill now flags identical rules within a file so you can delete .b from .css and use .a in .html.

Duplicated @keyframes fadeIn Across styles.css and Component CSS

When I searched for @keyframes in ng-firebase-image-analyzer, I found @keyframes fadeIn defined in src/styles.css and redefined inside a component .css file—differing only slightly by a translateY() value. When I asked Gemini 4 about it, it recommended deleting the component's @keyframes fadeIn copy and using the global animate-fade-in utility from src/styles.css. We fed that into /learn, so angular-bundle-guard now scans src/styles.css for global @utility animations and tells you to replace component-level duplicate @keyframes with the global utility.

Component @keyframes spin Colliding with Tailwind's Built-In animate-spin

Even better, one component .css file had defined its own @keyframes spin from scratch—even though Tailwind CSS already includes animate-spin out of the box! We deleted @keyframes spin from the component .css file, switched the template to animate-spin, and taught the skill to flag any component @keyframes that collide with Tailwind's built-in animations (spin, pulse, ping, bounce).


Act 4: Inspecting "Fat" CSS Files in the HTML Report & Shaving Bytes Here and There

In the generated HTML report, right above the 4 optimization categories, there is a collapsible table titled 2. Component CSS Files > 1 KB (2, +11 <= 1 KB). It lists the "fat" component .css files in descending order by compiled byte size, showing:

Source CSS File Compiled Size Tailwind CSS v4 @property Overhead Triggering @apply Classes
src/app/.../some.component.css:14 4.82 kB (4,936 B) 3.05 kB (63%) — duplicate @property --tw-* boilerplate border (L14,28), focus-visible:ring-2 (L19), tracking-wide (L24), leading-relaxed (L35)

When a component .css file appeared at the top of this table, we couldn't always reduce it to 0 kB—complex components still needed custom styles. Instead, I asked Gemini 4 how to optimize individual components based on the latest HTML report. Those reviews uncovered several micro-optimizations that didn't drop a stylesheet to 0 kB, but shaved a few bytes here and there across components and styles.css:

1. When Gemini 4 Suggested focus:ring-* -> focus:outline-*

When I asked Gemini 4 how to optimize one of the fat .css files from the HTML report, it suggested rewriting focus:ring-* to focus:outline-* (and focus-visible:ring-* to focus-visible:outline-*).

Why? In Tailwind CSS, ring-* utilities compile into a multi-variable box-shadow composition (--tw-ring-inset, --tw-ring-offset-width, --tw-ring-offset-color, --tw-ring-color, --tw-ring-shadow). Rewriting focus:ring-* / focus-visible:ring-* to focus:outline-* / focus-visible:outline-* uses native CSS outline properties so Tailwind injects fewer declarations and variables into the compiled CSS:

/* BEFORE: Injects multi-variable --tw-ring-* / --tw-shadow-* declarations */
.action-input {
  @apply rounded-md border px-3 py-2 focus-visible:ring-2 focus-visible:ring-primary;
}

/* AFTER: Uses native CSS outline—shaving injected bytes from the compiled rule */
.action-input {
  @apply rounded-md border px-3 py-2 focus-visible:outline-2 focus-visible:outline-primary;
}

2. Moving Conditional State Classes into [class] Bindings

When reviewing a large .css file (like the image dropzone in ng-firebase-image-analyzer), I also looked for classes that only existed to toggle UI states—such as dragging a file over a dropzone or not. By moving those conditional utilities into [class]="..." bindings in .html, I could delete the extra state selectors from .css, trimming the stylesheet size:

<!-- Conditional utilities moved to [class] in component.html (e.g., dragging a file or not) so state classes can be deleted from .css -->
<div
  class="dropzone"
  [class]="isDragging() ? 'border-primary bg-primary/10' : 'border-outline-variant bg-surface-container'"
>
  ...
</div>

3. Trimming tracking-*, leading-*, uppercase, Defaults (font-sans / text-base), Responsive Spacing, & duration-*

Reviewing those fat .css files also revealed several typography, spacing, and transition utilities we could simplify or remove:

  • Deleting tracking-*, leading-*, and uppercase: Extra letter-spacing (tracking-*) wasn't visually important to me, and both tracking-* and leading-* trigger Tailwind CSS v4 @property overhead inside @apply. Combined with converting static badge/header text to ALL CAPS directly in .html so we could delete uppercase, removing tracking-*, leading-*, and uppercase shrank our component .css files and removed those unused utility definitions from the global styles.css.
  • Removing redundant font-sans and text-base: Because the application's default font family and font size are already font-sans and text-base, we deleted redundant font-sans (another @property trigger in @apply) and text-base declarations.
  • Unifying sm:, md:, and lg: margins and padding: In places where responsive padding or margins (sm:p-*, md:p-*, lg:m-*) could use the same spacing value across screen sizes, unifying them into a single spacing class eliminated extra @media rules.
  • Setting a default 200ms duration in styles.css and deleting duration-*: Seeing different duration-* utilities scattered across components, I set --default-transition-duration: 200ms in src/styles.css and removed duration-* from both .html and .css files.
/* In src/styles.css: Set default transition duration to 200ms globally */
@theme {
  --default-transition-duration: 200ms;
}

4. Safety Guardrails Learned Along the Way

To make sure these refactors never broke existing behavior, /learn encoded three strict guardrails into angular-bundle-guard:

  • Dynamic [class] Binding Protection (isDynamicallyBoundInHtml): Once a class is dynamically bound via [class.foo], [class], or [ngClass], the skill automatically skips it from static inlining recommendations.
  • Unit Test (*.spec.ts) Selector Warnings: If a unit test queries a class via By.css('.some-class') or querySelector('.some-class'), the HTML report displays a ⚠️ Test Queries Selector badge with the exact .spec.ts line number so you don't break tests when deleting a .css class.
  • Global Override Protection: If a component .css rule intentionally overrides a global class defined in src/styles.css (such as .error-block in sign-in-modal.component.css), the skill leaves it untouched in .css.

Individually, each of these changes shaved a few bytes or kilobytes. Combined across ng-firebase-tts and ng-firebase-image-analyzer—from deleting dead classes and duplicate @apply/@keyframes rules to inlining static utilities, moving state classes to [class], and trimming focus:ring-*, typography, responsive spacing, and duration-* utilities—they noticeably shrank main.js, our lazy chunks, and styles.css.


Try angular-bundle-guard in Your Angular Workspace

What started as a moment of frustration—"I am tired of this—let's build a skill to save my sanity"—turned into one of the most useful tools in my Angular workflow, built entirely by pairing with Gemini 4 and /learn in Antigravity CLI. (In fact, building this skill went so well that I'm planning to build another skill to turn these blog posts into a book!)

You can install angular-bundle-guard as an Antigravity CLI plugin or via the skills CLI:

# Option 1: Install as an Antigravity CLI (agy) plugin
agy plugin install https://github.com/railsstudent/angular-bundle-guard

# Option 2: Install via npx skills
npx skills add https://github.com/railsstudent/angular-bundle-guard

Or run the zero-dependency analyzer directly from the root of any Angular project:

node <skill-dir>/scripts/analyze-bundle.mjs

來源:DEV Community · dev.to