Skip to content

Commit 888c990

Browse files
authored
Reduce bundle size (#845)
* Initialize bundle analyzer package * update bundle baseline * Prepare ChartChildren and Chart for tree-shaking - Add `"sideEffects": ["**/*.css"]` to layerchart/package.json so downstream bundlers can prune unused barrel re-exports - Convert ChartChildren's value imports of components only referenced in `ComponentProps<typeof X>` to `import type` (Area, Arc, Bars, BrushContext, Group, Line, Pie, Spline, TooltipContext) - Inline `geoFitObjectTransform` into Chart.svelte to drop the static import edge through `$lib/utils/geo.js` (which transitively imports d3-geo) No visible change in the bundle analyzer (it already does aggressive treeshaking), but unlocks the dynamic-import refactor in the next commit and protects consumers whose bundlers tree-shake less aggressively. * Lazy-load conditionally-rendered components in Chart Convert statically-imported components to `{#await import(...)}` so they only ship to users who opt in via the corresponding prop: - ChartChildren: ChartAnnotations (when annotations.length > 0), DefaultTooltip (tooltipContext truthy), Labels, Legend, Points - TooltipContext: Voronoi (mode === 'voronoi'), Arc (radial bounds/band) Voronoi alone removes d3-geo-voronoi and its transitive d3-geo from the always-loaded bundle. Update the bundle analyzer to sum the entry chunk plus all chunks reachable via static imports (lazy chunks excluded), so the reported size reflects up-front cost rather than total feature surface. Result: `core` (`Chart` + `Svg`) drops 154.94 → 109.95 KB gz (-29%). Comparable savings on every scenario except `all` (which exercises every lazy path). * Add `pnpm bundle:visualize` to generate treemaps using rollup-plugin-visualizer * Fix BarChart legend toggle test for lazy-loaded Legend The test queried `.lc-legend-swatch-button` synchronously, but `Legend` is now dynamically imported inside `ChartChildren` and isn't in the DOM until the chunk resolves. Wrap the query in `vi.waitFor` so the test waits for the buttons to mount before clicking. * Fix analyzer footgun: don't overwrite latest.json on filtered runs A filtered run (e.g. `pnpm bundle:visualize -- core`) was overwriting `bundle-reports/latest.json` with just the filtered scenarios, causing the PR comparison comment to show "0 KB" for every scenario the filtered run didn't cover. Now `latest.json` is only updated when no `--components`, scenario, or component filters are passed; filtered runs still get a timestamped report. Also regenerate the full baseline against the current lazy-loaded code. * Fix CI test failure by avoiding tooltip namespace barrel in DefaultTooltip DefaultTooltip is dynamically imported from ChartChildren. It was using `import * as Tooltip from '../tooltip/index.js'`, which dragged the entire tooltip barrel — including TooltipContext.svelte (already in the static graph via Chart.svelte) — into its lazy chunk. Under CI's resource-constrained dev server this broke the DefaultTooltip browser test ("Failed to fetch dynamically imported module"). Replace the namespace import with explicit named imports of just the 5 components actually used (no Context). The local `const Tooltip = { Root, Header, List, Item, Separator }` keeps the existing template syntax (`<Tooltip.Root>`, etc.) unchanged. Bonus: tightens tree-shaking — `all` scenario drops ~3 KB gz. * Revert DefaultTooltip lazy-load to fix vitest-browser CI test The dynamic import of `DefaultTooltip` from `ChartChildren` caused a CI-only "Failed to fetch dynamically imported module" failure in `DefaultTooltip.svelte.test.ts`. Local tests passed; only the Linux/playwright runner reproduced. Switching the inner `import * as Tooltip` namespace to named imports (commit 7e5d6e7) didn't help. The savings were small (~5 KB gz on `core`) and not worth the test instability — the other lazy-loads (Voronoi, Arc, ChartAnnotations, Labels, Legend, Points) remain. Net Phase 2 gain on `core` is now -39 KB gz (-25%) instead of -45 KB gz (-29%). * Revert ChartChildren lazy-loads; keep TooltipContext lazy-loads The DefaultTooltip vitest-browser test still failed in CI ("Failed to fetch dynamically imported module") even after reverting just DefaultTooltip's lazy-load and after switching the inner namespace import to named imports. Rather than continue narrowing, revert all 4 ChartChildren lazy-loads (Labels, Legend, Points, ChartAnnotations). The TooltipContext lazy-loads (Voronoi, Arc) stay — they're the biggest win (~17 KB gz on core from removing d3-geo-voronoi + d3-geo from the static graph) and aren't in the test failure path. Net Phase 2 gain on `core` is now -17 KB gz (-11%). * Switch ChartChildren lazy-loads from `{#await}` to `$effect` The `{#await import('./X.svelte')}` template pattern broke `DefaultTooltip.svelte.test.ts` in CI ("Failed to fetch dynamically imported module" on the test file). Local tests passed; only the Linux/playwright runner reproduced. Move the dynamic imports back into `$effect` blocks (script-side `import()`), which Vite/vitest-browser appears to handle differently. Same chunks, same bundle savings, but the dynamic imports live in regular JS rather than Svelte template syntax. TooltipContext keeps `{#await}` for Voronoi/Arc — those weren't in the test failure path. Net Phase 2 gain on `core` recovered to -40 KB gz (-25%). * Refactor lazy-loading to a `<Lazy>` wrapper with prop-spread + `then` snippet Replace per-component `$state`/`$effect`/conditional-render boilerplate in ChartChildren with a generic `<Lazy>` component that takes a `load` factory and either spreads remaining props to the loaded component (single-render case: ChartAnnotations, Legend) or passes it via a `then` snippet (loop case: Points, Labels). Conditional gating uses standard `{#if}` outside `<Lazy>` rather than a `when` prop. The snippet is named `then` (not `children`) to avoid collisions with loaded components that have their own `children` prop. Same bundle behavior and CI-friendly `$effect`-under-the-hood as the previous explicit pattern; ~half the lines per lazy-load. * Use `{#await import(...)}` for lazy-loads; remove `<Lazy>` wrapper Now that `optimizeDeps.include: ['d3-interpolate']` prevents the mid-test Vite reload (the actual cause of the CI flake), the cleaner inline `{#await import('./X.svelte') then { default: X }}` pattern works fine in CI. Revert ChartChildren back to that pattern, matching what TooltipContext already does. Remove `Lazy.svelte` since it's no longer needed. Same chunks, same ~25% savings on `core` (115.60 KB gz). * Add `build` script alias so CI's `build:packages` actually builds layerchart The bundle analysis CI workflow runs `pnpm build:packages` (which calls `pnpm --filter './packages/*' build`) before `pnpm bundle:analyze`. layerchart only had a `package` script (svelte-package convention), no `build`, so the filter call did nothing in CI — the analyzer ran against an empty `dist/` and produced 0-byte sizes for every scenario. That's why the PR comment showed "0.00 KB" for "New". Add a `build` alias for `svelte-package` (kept alongside existing `package` for back-compat) so CI's existing build step now actually rebuilds layerchart's `dist/`. * Add percentage change to bundle analysis PR comment The script already computed `sizePercent` and `gzipSizePercent`; now display them inline in the Change column alongside the raw KB deltas (e.g. `-160.00 KB (-25.1%)`). Easier to scan relative impact across scenarios than raw byte counts alone. Same for the Individual Components table. * Group bundle analysis scenarios in PR comment by category Add a `group` field to scenarios (Foundation, Cartesian charts, Geo, Hierarchy, Graph / network, Worst case) and render the PR comment with one sub-table per group instead of one alphabetized list. Reorder `define-scenarios.ts` to put `core` first and scenarios within their category. Remove the alphabetical sort in `analyzeChanges` so the comment preserves the natural order. * Move heavy-dep components into sub-path exports Remove from root `layerchart`: `Geo*` + `Graticule` + `TileImage`, `Tree`/`Treemap`/`Pack`/`Partition`, `ForceSimulation`, `Dagre`/`Sankey`/`Chord`/`Ribbon`. Each group now lives in its own folder + sub-path entry: `layerchart/geo`, `layerchart/hierarchy`, `layerchart/force`, `layerchart/graph`. Defends against bundlers that don't tree-shake the root barrel cleanly — `@dagrejs/dagre` (~22 KB), `d3-geo` (~15 KB), `d3-force` (~7 KB), `d3-hierarchy` (~6 KB), `d3-sankey` (~6 KB), and `d3-chord` (~2 KB) are now reachable only via opt-in imports. Per-scenario bundle sizes are unchanged for already-good consumers; the worst-case `all` scenario drops 241.8 → 235.5 KB gz. `Voronoi`/`Hull` stay at root (already lazy via `TooltipContext`). `Contour`/`Density`/`Raster`/`BoxPlot`/`Violin`/`Threshold` also stay (not category-specific). High-level charts (`LineChart`, `BarChart`, etc.) remain at root. Breaking: imports for the moved components must move to the new sub-paths. * Lazy-load opt-in features in core path Three components that everyone pays for in `core` today, but only some users actually need: - `Spline` in `Grid` (radial linear grid lines only — non-radial users never render it) - `Bar` in `Highlight` (only when user sets `bar` prop, default `false`) - `BrushContext` in `Chart` (only when user sets `brush` prop, default `undefined`) — required splitting the inner `<TooltipContext>` tree across the brush/no-brush branches; brush tests now wait for the lazy chunk via a new `awaitBrushReady` helper Saves ~4 KB gz on `core` (115.60 → 111.31 KB) and similar on every cartesian/geo/graph/hierarchy scenario. ~28% total reduction on `core` vs the pre-Phase-1 baseline. Also switch `@layerstack/svelte-actions` imports from the barrel to sub-paths (`/styles`, `/portal`). No bundle effect since tree-shaking already stripped the unused `popover.js`, but it stops the Svelte REPL/CDN from eagerly fetching `@floating-ui/dom` (popover's transitive dep) when users load `layerchart` from a CDN. * add changeset for lazy loading opt-in features (brush, radial spline, etc) * lazy load d3-quadtree and DefaultTooltip based on usage * Render chart subtree during BrushContext lazy load The previous structure put `<TooltipContext>` + `<ChartChildren>` *inside* the `{#await import('./BrushContext.svelte')}` block, so on slow networks the entire chart was blocked on the chunk fetch (~300-1000ms on Fast 4G). Move the same subtree into the `{#await}`'s pending branch as well so the chart paints immediately; the `{:then}` branch then re-mounts it inside `BrushContext` once the chunk arrives. Trade-off: brief one-time re-mount of `TooltipContext` + `ChartChildren` (~50ms) when the chunk lands. Acceptable because it happens before any user interaction (no tooltip/series state to lose) and brush is opt-in. Bundle savings preserved (core +0.1 KB from the duplicated template, since the actual modules are deduped). The other lazy-load sites (`Voronoi`/`Arc` in `TooltipContext`, `DefaultTooltip`, `Bar`, `Points`/`Labels`/`Legend`/`ChartAnnotations` in `ChartChildren`, `Spline` in `Grid`) don't need the same treatment — none of them block visible chart content from rendering.
1 parent 2cdf99d commit 888c990

355 files changed

Lines changed: 1537 additions & 1161 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'layerchart': patch
3+
---
4+
5+
perf: Lazy-load opt-in features in `core` path
6+
7+
5 components/dependencies that previously sat in every `<Chart>` user's sync graph are now dynamically imported only when the corresponding feature is used:
8+
9+
- `BrushContext` in `Chart` — only loads when `<Chart brush={...}>` is set (default `undefined`)
10+
- `DefaultTooltip` in `ChartChildren` — only loads when `tooltipContext` is set and no custom `tooltip` snippet is provided
11+
- `d3-quadtree` in `TooltipContext` — only loads when `mode` is `'quadtree'`, `'quadtree-x'`, or `'quadtree-y'`
12+
- `Spline` in `Grid` — only loads when rendering radial linear grid lines (`<Chart radial>` with `radialY="linear"`)
13+
- `Bar` in `Highlight` — only loads when `<Chart highlight={{ bar: ... }}>` is set (default `false`)
14+
15+
Result: **~10 KB gz off `core`** (115.6 → 105.25 KB) and comparable savings on every cartesian/geo/graph/hierarchy scenario, with no impact on rendered output for users who already opt into these features.
16+
17+
Also switches internal `@layerstack/svelte-actions` imports from the barrel (`@layerstack/svelte-actions`) to sub-paths (`@layerstack/svelte-actions/styles`, `@layerstack/svelte-actions/portal`). No production bundle effect — bundlers already tree-shake the unused `popover.js` — but it stops the Svelte REPL/CDN from eagerly fetching `@floating-ui/dom` (popover's transitive dep) when consumers load `layerchart` from a CDN.

.changeset/subpath-exports.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'layerchart': minor
3+
---
4+
5+
breaking: Move heavy-dep components into sub-path exports
6+
7+
The following components are no longer re-exported from `'layerchart'` and must be imported from new sub-paths:
8+
9+
- `'layerchart/geo'``GeoCircle`, `GeoClipPath`, `GeoEdgeFade`, `GeoLegend`, `GeoPath`, `GeoPoint`, `GeoProjection`, `GeoRaster`, `GeoSpline`, `GeoTile`, `GeoVisible`, `Graticule`, `TileImage`
10+
- `'layerchart/hierarchy'``Tree`, `Treemap`, `Pack`, `Partition`
11+
- `'layerchart/force'``ForceSimulation`
12+
- `'layerchart/graph'``Dagre`, `Sankey`, `Chord`, `Ribbon`
13+
14+
This isolates each group's external d3 dependency (`@dagrejs/dagre` ~22 KB, `d3-geo` ~15 KB, `d3-force` ~7 KB, `d3-hierarchy` ~6 KB, `d3-sankey` ~6 KB, `d3-chord` ~2 KB) behind an opt-in import — defending against bundlers that don't tree-shake the root barrel cleanly.
15+
16+
`Voronoi`/`Hull` stay at root (already lazy-loaded via `TooltipContext`). `Contour`/`Density`/`Raster`/`BoxPlot`/`Violin`/`Threshold` and high-level charts (`LineChart`, `BarChart`, etc.) remain at root.
17+
18+
**Migration:** update affected imports, e.g.
19+
20+
```diff
21+
-import { Tree, GeoPath, ForceSimulation } from 'layerchart';
22+
+import { Tree } from 'layerchart/hierarchy';
23+
+import { GeoPath } from 'layerchart/geo';
24+
+import { ForceSimulation } from 'layerchart/force';
25+
```

bundle-analyzer/bundle-analyzer.ts

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const __dirname = dirname(__filename);
2424
interface BundleResult {
2525
scenario: string;
2626
description: string;
27+
group?: string;
2728
size: number;
2829
gzipSize: number;
2930
imports: string[];
@@ -34,6 +35,40 @@ interface BundleReport {
3435
results: BundleResult[];
3536
}
3637

38+
/**
39+
* Maps a component name to the layerchart sub-path it lives under.
40+
* Components not listed here import from the bare `layerchart` package.
41+
* Keep in sync with the package.json `exports` map and src/lib/components/<sub>/index.ts.
42+
*/
43+
const SUBPATH_FOR_COMPONENT: Record<string, string> = {
44+
// geo
45+
GeoCircle: "geo",
46+
GeoClipPath: "geo",
47+
GeoEdgeFade: "geo",
48+
GeoLegend: "geo",
49+
GeoPath: "geo",
50+
GeoPoint: "geo",
51+
GeoProjection: "geo",
52+
GeoRaster: "geo",
53+
GeoSpline: "geo",
54+
GeoTile: "geo",
55+
GeoVisible: "geo",
56+
Graticule: "geo",
57+
TileImage: "geo",
58+
// hierarchy
59+
Tree: "hierarchy",
60+
Treemap: "hierarchy",
61+
Pack: "hierarchy",
62+
Partition: "hierarchy",
63+
// force
64+
ForceSimulation: "force",
65+
// graph
66+
Chord: "graph",
67+
Dagre: "graph",
68+
Ribbon: "graph",
69+
Sankey: "graph",
70+
};
71+
3772

3873
class BundleAnalyzer {
3974
tempDir: string;
@@ -81,7 +116,14 @@ class BundleAnalyzer {
81116
results,
82117
};
83118

84-
this.saveReport(report);
119+
// Only update `latest.json` (the committed baseline) on full runs.
120+
// Filtered/component runs would otherwise drop scenarios from the
121+
// baseline and break the PR comparison comment.
122+
const isFullRun =
123+
!options.scenarios &&
124+
!options.components &&
125+
!options.componentFilter;
126+
this.saveReport(report, isFullRun);
85127
this.printReport(report);
86128

87129
if (options.visualize) {
@@ -127,6 +169,7 @@ class BundleAnalyzer {
127169
return {
128170
scenario: scenario.name,
129171
description: scenario.description,
172+
group: scenario.group,
130173
size,
131174
gzipSize,
132175
imports: scenario.imports,
@@ -137,13 +180,34 @@ class BundleAnalyzer {
137180
let content: string;
138181

139182
if (scenario.imports.length === 1 && scenario.imports[0] === "*") {
140-
// Wildcard import: import everything
183+
// Wildcard import: import everything (root + each sub-path)
141184
content = `import * as LayerChart from "layerchart";
142-
;(globalThis.__lc_keep ||= []).push(LayerChart);
185+
import * as LayerChartGeo from "layerchart/geo";
186+
import * as LayerChartHierarchy from "layerchart/hierarchy";
187+
import * as LayerChartForce from "layerchart/force";
188+
import * as LayerChartGraph from "layerchart/graph";
189+
;(globalThis.__lc_keep ||= []).push(LayerChart, LayerChartGeo, LayerChartHierarchy, LayerChartForce, LayerChartGraph);
143190
`;
144191
} else {
145-
const importList = scenario.imports.join(", ");
146-
content = `import { ${importList} } from "layerchart";
192+
// Group imports by source module (root vs each sub-path).
193+
const groups = new Map<string, string[]>([["layerchart", []]]);
194+
for (const name of scenario.imports) {
195+
const sub = SUBPATH_FOR_COMPONENT[name];
196+
const mod = sub ? `layerchart/${sub}` : "layerchart";
197+
const list = groups.get(mod) ?? [];
198+
list.push(name);
199+
groups.set(mod, list);
200+
}
201+
202+
const importLines: string[] = [];
203+
for (const [mod, names] of groups) {
204+
if (names.length === 0) continue;
205+
importLines.push(
206+
`import { ${names.join(", ")} } from "${mod}";`
207+
);
208+
}
209+
210+
content = `${importLines.join("\n")}
147211
148212
const refs = [
149213
${scenario.imports.join(",\n\t")}
@@ -302,16 +366,23 @@ const refs = [
302366
return { size, gzipSize };
303367
}
304368

305-
private saveReport(report: BundleReport): void {
369+
private saveReport(report: BundleReport, updateLatest = true): void {
306370
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
307371
const filepath = join(this.outputDir, `bundle-report-${timestamp}.json`);
308372

309373
writeFileSync(filepath, JSON.stringify(report, null, 2));
310374

311-
const latestPath = join(this.outputDir, "latest.json");
312-
writeFileSync(latestPath, JSON.stringify(report, null, 2));
375+
if (updateLatest) {
376+
const latestPath = join(this.outputDir, "latest.json");
377+
writeFileSync(latestPath, JSON.stringify(report, null, 2));
378+
}
313379

314380
console.log(`\nReport saved to ${filepath}`);
381+
if (!updateLatest) {
382+
console.log(
383+
`(Skipped updating latest.json because this was a filtered run)`
384+
);
385+
}
315386
}
316387

317388
private printReport(report: BundleReport): void {

0 commit comments

Comments
 (0)