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]
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:
-
injectAsync()Sabotage: When a component usedinjectAsync()(our async service injection helper) to lazy-load a heavy service fromcore/<domain>, while an eager file inmain.jsstatically imported a constant or helper from that samecore/<domain>barrel, the bundler evaluated the barrel and pulled the lazy service straight intomain.js—reducing theinjectAsync()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.tsfile. 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.cssstays in the.cssfile (0 Binmain.js). - Every component with
styleUrlcompiles its CSS rules into JavaScript string literals (styles: ["..."]) embedded directly insidemain.jsor its lazy.jschunk.
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 coupledx:*variant pairs in the same layer (for example,grid-cols-1+md:grid-cols-2, orborder+focus-visible:border-2) so Angular's[_ngcontent]selector in.cssnever 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-*, anduppercase: Extra letter-spacing (tracking-*) wasn't visually important to me, and bothtracking-*andleading-*trigger Tailwind CSS v4@propertyoverhead inside@apply. Combined with converting static badge/header text toALL CAPSdirectly in.htmlso we could deleteuppercase, removingtracking-*,leading-*, anduppercaseshrank our component.cssfiles and removed those unused utility definitions from the globalstyles.css. -
Removing redundant
font-sansandtext-base: Because the application's default font family and font size are alreadyfont-sansandtext-base, we deleted redundantfont-sans(another@propertytrigger in@apply) andtext-basedeclarations. -
Unifying
sm:,md:, andlg: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@mediarules. -
Setting a default
200msduration instyles.cssand deletingduration-*: Seeing differentduration-*utilities scattered across components, I set--default-transition-duration: 200msinsrc/styles.cssand removedduration-*from both.htmland.cssfiles.
/* 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 viaBy.css('.some-class')orquerySelector('.some-class'), the HTML report displays a⚠️ Test Queries Selectorbadge with the exact.spec.tsline number so you don't break tests when deleting a.cssclass. -
Global Override Protection: If a component
.cssrule intentionally overrides a global class defined insrc/styles.css(such as.error-blockinsign-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
- Angular Bundle Guard Repository: github.com/railsstudent/angular-bundle-guard
-
Companion Series Repositories:
ng-firebase-tts&ng-firebase-image-analyzer
來源:DEV Community · dev.to