Skip to content

Commit 7906c09

Browse files
committed
perf(navigation): 화면 전환 대기와 초기 로딩 개선
1 parent 1d45d56 commit 7906c09

34 files changed

Lines changed: 805 additions & 229 deletions

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,5 @@ jobs:
3131
- run: pnpm type-check
3232
- run: pnpm test:post-dates
3333
- run: pnpm build
34+
- run: pnpm test:loading
3435
- run: pnpm test:sitemap-lastmod

‎AGENTS.md‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ React + TypeScript + Vite로 구축된 개인 기술 블로그. GitHub Pages 커
2525
│ ├── main.tsx # 엔트리포인트 (ThemeProvider > LanguageProvider > App)
2626
│ ├── app-shell.tsx # SSR/CSR 공통 Provider shell
2727
│ ├── App.tsx # RouterProvider 생성
28-
│ ├── routes.tsx # 클라이언트 라우트 + 현재 경로 preload
28+
│ ├── routes.tsx # 클라이언트 라우트 (lib/route-modules.ts에서 preload 공유)
2929
│ ├── routes.server.tsx # SSG용 정적 라우트
3030
│ ├── entry-server.tsx # React SSR 렌더링 + prerender route metadata
3131
│ ├── layouts/
@@ -122,9 +122,11 @@ publishDate: "2025-12-01" # optional - 예약 발행
122122
- 컬러 테마: `document.documentElement.dataset.color`로 관리
123123

124124
### Post Data
125-
- `content/posts/*.md` 파일을 `import.meta.glob`으로 eager 로드
125+
- `scripts/post-assets.ts`의 Vite 플러그인으로 목록 메타데이터(`?post-meta`)만 eager 로드
126+
- 글 본문(`?post-body`)은 필요할 때 로드하고, 본문 검색 인덱스는 검색 시 해당 언어만 로드
126127
- `src/lib/posts.ts`에서 frontmatter 파싱, 정렬, 필터링, 검색 제공
127-
- draft/scheduled 글은 production 빌드에서 자동 필터링
128+
- `usePostContent`, `usePostSearchIndex`로 비동기 로드 상태와 데이터 갱신 구독
129+
- draft/scheduled 글은 production 메타데이터·본문·검색 인덱스에서 자동 필터링
128130

129131
### SEO
130132
- `useMetaTags()` 훅으로 페이지별 title, description, OG 태그 설정
@@ -135,7 +137,9 @@ publishDate: "2025-12-01" # optional - 예약 발행
135137
### Hydrated SSG
136138
- 서버 렌더는 `src/routes.server.tsx`의 정적 라우트로 실제 React UI HTML을 생성
137139
- 글 상세와 프로젝트 상세 경로는 빌드 시 개별 HTML로 프리렌더
138-
- 클라이언트는 `src/routes.tsx`에서 현재 경로 컴포넌트만 hydration 전에 preload하고, 나머지 라우트는 lazy 유지
140+
- `src/lib/route-modules.ts`에서 현재 경로 컴포넌트를 hydration 전에 preload하고, 메뉴 의도/idle 시 다른 라우트를 사전 로드
141+
- 글 상세는 빌드 시 본문을 준비하고 해당 글 하나의 JSON을 HTML에 넣어 hydration 시 본문을 다시 다운로드하지 않음
142+
- 분석 차트는 hydration 이후 로드해 차트 다운로드가 화면 전체 진입을 막지 않음
139143
- `PostPage` 코드블록은 서버에서 안정적인 `<pre>` fallback을 렌더하고 hydration 후 `CodeBlock`/Shiki를 로드
140144
- hydration 관련 변경 후에는 `pnpm build`, `pnpm preview`로 `Loading...`, Suspense hidden segment, React hydration warning이 없는지 확인
141145

@@ -156,6 +160,7 @@ pnpm build # 프로덕션 빌드 (tsc + client build + SSR build + hydrated
156160
pnpm preview # 빌드 결과 미리보기
157161
pnpm lint # ESLint 실행
158162
pnpm type-check # TypeScript 타입 체크
163+
pnpm test:loading # 빌드 후 로딩 분리, 재시도, SSG 회귀 검사
159164
```
160165

161166
## Deployment

‎docs/navigation-performance.md‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# 화면 전환 성능 개선과 검증 기준
2+
3+
## 기준
4+
5+
사용자가 확인한 첫 메뉴 이동 지연을 개선한다. 기존 레이아웃과 기능, 한·영 라우트, 검색 범위, 정적 HTML 본문을 유지한다. 차트 파일 수신 지연이 분석 페이지 전체 진입을 막지 않게 하고, 전환 요청의 진행·실패 상태를 알 수 있게 한다.
6+
7+
## UI 계약
8+
9+
| 대상 | 동작과 검증 기준 |
10+
|---|---|
11+
| 분석 화면 | 제목, 기간 선택, 요약과 글 목록은 차트 모듈을 기다리지 않고 표시한다. 차트 영역의 높이·순서는 유지한다. |
12+
| 차트 로딩 | 기존 Skeleton과 색상 토큰을 사용한다. 한국어 `차트를 불러오는 중…`, 영어 `Loading chart…`를 접근 가능한 상태로 제공한다. |
13+
| 차트 실패 | 차트 영역에 오류와 `다시 시도` / `Try again`을 표시한다. 메뉴 이동과 다른 데이터 조회는 계속 가능해야 한다. |
14+
| 사전 로딩 | 메뉴에 포인터·키보드 포커스가 오면 해당 화면을 준비한다. 요청은 중복하지 않고, 절전·느린 연결에서는 불필요한 백그라운드 다운로드를 줄인다. |
15+
| 전환 대기 | 상단 진행 표시와 스크린리더 상태를 제공한다. 빠른 전환에는 깜박임을 피한다. 오래 걸리면 해당 경로를 새로 열 수 있다. |
16+
| 전환 실패 | 새로 고침 재시도와 홈 이동을 제공한다. 자동 새로 고침 반복은 하지 않는다. |
17+
| 모바일 | 메뉴 닫힘 후 대상 화면 이동, 키보드·터치 동작, 390px 폭에서 가로 넘침을 확인한다. |
18+
| 모션 | 사이드바 메뉴는 스냅샷 전환 없이 즉시 갱신한다. 본문 링크의 짧은 전환 효과는 유지하고 reduced-motion에서는 새 진행 애니메이션을 끈다. |
19+
| 글 데이터 | 목록용 메타데이터와 본문 로드를 분리한다. 본문 검색, 번역 유무, draft·예약 발행 필터, 글 상세 SSG를 보존한다. |
20+
21+
## 검증
22+
23+
1. 타입·lint·기존 날짜/사이트맵/링크 검사를 실행한다.
24+
2. 로드 중복, 실패 후 재시도, 시간 초과를 단위 테스트한다.
25+
3. 프로덕션 빌드에서 분석 라우트의 초기 의존성에 차트 라이브러리가 없는지 확인한다.
26+
4. Chrome의 로컬 preview에서 차트 파일을 20초 지연시키고 그동안 제목·메뉴·본문이 동작하는지 확인한다.
27+
5. 라우트·차트 요청 실패, 복구, 연속 클릭, 뒤로가기, 한국어·영어, 모바일, reduced-motion을 확인한다.
28+
6. 초기 로드의 글 본문 요청 수와 정적 HTML의 글 본문·메타데이터를 비교한다.
29+
7. CDN은 실제 정상 요청의 Cache-Control·HIT/MISS·응답 시간을 읽기 전용으로 점검한다. 확인되지 않은 관리 권한이나 캐시 규칙 변경을 완료로 주장하지 않는다.
30+
31+
## 구현 메모
32+
33+
- 차트 모듈은 화면이 렌더링된 다음 불러온다. 30초 시간 초과는 같은 문서에서 재시도하고, 브라우저가 실패를 캐시하는 모듈 다운로드 오류는 현재 URL을 새로 열어 복구한다.
34+
- 글 상세의 정적 HTML에는 해당 글 하나의 hydration 데이터만 안전하게 직렬화한다. 직접 진입에서 본문을 다시 다운로드할 필요가 없고, 다른 글의 본문은 포함하지 않는다.
35+
- 본문 검색 인덱스는 검색이 필요할 때 해당 언어만 불러온다. 실패하면 제목·설명·태그 기준 결과임을 표시한다.
36+
- 운영 응답 점검: HTML `max-age=600`, Cloudflare `DYNAMIC`; 해시 자산 `max-age=14400`, `REVALIDATED`. 이 관측만으로 과거 21~28초 지연의 원인을 특정할 수 없으며 원격 캐시 정책은 변경하지 않았다.
37+
38+
## 확인 결과 (2026-09-22)
39+
40+
초기 HTML의 module script와 modulepreload JS 파일을 중복 없이 합산했다. 비교 기준은 변경 전 `1d45d565aff349f375c19e5f9d7a7bf3c68380a7`의 Vite 프로덕션 빌드이며, 압축 전 파일 크기다. 실제 네트워크 전송량이나 로딩 시간의 감소율로 해석하지 않는다.
41+
42+
| 항목 | 변경 전 | 변경 후 |
43+
|---|---:|---:|
44+
| 초기 JS 파일 | 85 | 15 |
45+
| 초기 JS 크기 | 1,350,376 bytes | 579,536 bytes |
46+
47+
초기 JS 크기는 57.1% 줄었다. 차트와 글 본문·검색 인덱스는 초기 정적 의존성에서 제외됐다.
48+
49+
Chrome에서 로컬 production preview로 아래 시나리오를 확인했다.
50+
51+
- 차트 파일 요청을 20초 지연: 분석 제목·요약·기간 선택이 표시되고, 7일 선택이 즉시 반영됐다. 요청을 풀면 차트 3개가 표시됐다.
52+
- 차트 실제 다운로드 실패 및 30초 시간 초과: 오류와 재시도를 표시하고 복구했다.
53+
- 메뉴 이동 및 첫 진입의 라우트 파일 실패: 오류 화면에서 마우스로 재시도해 소개 화면으로 복구했다.
54+
- 라우트 대기: 진행 표시, 5초 이후 지연 안내와 대상 경로 링크를 확인했다. 다른 메뉴로 이동하면 이전 진행 표시가 사라졌다.
55+
- 연속 메뉴 클릭: 분석 → 시리즈 → 소개 순서에서 마지막 대상인 소개로 이동했다.
56+
- 본문에만 있는 `Executors.newVirtualThreadPerTaskExecutor`로 한국어·영어 목록 검색과 빠른 검색 결과를 확인했다.
57+
- 글 상세 SPA 이동 시 본문과 목차를 표시했다. 직접 새로고침 시 해당 글의 hydration JSON을 사용하고 본문 JS를 추가 요청하지 않았다. 뒤로가기 시 검색어를 유지했다.
58+
- 영어 번역이 없는 글은 한국어 원문 링크를 안내했다.
59+
- 모바일 390px에서 메뉴를 닫고 글 목록으로 이동했다. 문서 폭도 390px로 가로 넘침이 없었다.
60+
- `prefers-reduced-motion: reduce`에서 진행 표시의 animation이 `none`이었다.
61+
- 빌드로 116개 정적 페이지를 생성했다. 로딩 테스트 7개, 날짜 테스트 6개, 사이트맵 테스트 6개와 내부 글 링크 검사가 통과했다. 타입 검사 통과, lint 오류 0개(기존 경고 16개).

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"type-check": "tsc --noEmit",
1313
"check:internal-links": "node scripts/check-internal-post-links.mjs",
1414
"test:post-dates": "node --test scripts/post-dates.test.mjs",
15+
"test:loading": "node --test scripts/async-loader.test.mjs scripts/loading.test.mjs",
1516
"test:sitemap-lastmod": "node --test scripts/sitemap-lastmod.test.mjs"
1617
},
1718
"dependencies": {

‎scripts/async-loader.test.mjs‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import { createRetryableLoader } from "../src/lib/async-loader.ts"
4+
5+
test("prefetch and navigation share one request and reuse the result", async () => {
6+
let calls = 0
7+
let resolve
8+
const load = createRetryableLoader(() => {
9+
calls++
10+
return new Promise((done) => { resolve = done })
11+
})
12+
const prefetch = load()
13+
const navigation = load()
14+
assert.equal(prefetch, navigation)
15+
await Promise.resolve()
16+
resolve("ready")
17+
assert.equal(await navigation, "ready")
18+
assert.equal(await load(), "ready")
19+
assert.equal(calls, 1)
20+
})
21+
22+
test("a rejected loader promise is cleared before retry", async () => {
23+
let calls = 0
24+
const load = createRetryableLoader(async () => {
25+
if (++calls === 1) throw new Error("offline")
26+
return "recovered"
27+
})
28+
await assert.rejects(load(), /offline/)
29+
assert.equal(await load(), "recovered")
30+
})
31+
32+
test("a stalled request times out and can be retried", async () => {
33+
let calls = 0
34+
const load = createRetryableLoader(() => ++calls === 1 ? new Promise(() => {}) : Promise.resolve("ready"), 20)
35+
await assert.rejects(load(), /timed out/)
36+
assert.equal(await load(), "ready")
37+
})

‎scripts/loading.test.mjs‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import assert from "node:assert/strict"
2+
import fs from "node:fs"
3+
import test from "node:test"
4+
import { isPublished, postMetadataSource } from "./post-assets.ts"
5+
import { parseFrontmatter } from "../src/lib/post-frontmatter.ts"
6+
7+
test("metadata keeps headers and reading time without article content", () => {
8+
const body = "본문에만있는검색어 ".repeat(450)
9+
const raw = `---\ntitle: "A title"\ndate: "2026-09-01"\nupdated: "2026-09-02"\ntags: ["a"]\n---\n${body}`
10+
const metadata = postMetadataSource(raw)
11+
const parsed = parseFrontmatter(metadata)
12+
assert.equal(parsed.data.title, "A title")
13+
assert.deepEqual(parsed.data.tags, ["a"])
14+
assert.equal(parsed.data.readingMinutes, "3")
15+
assert.equal(parsed.content, "")
16+
assert.ok(!metadata.includes("본문에만있는검색어"))
17+
})
18+
19+
test("production indices exclude drafts and future posts", () => {
20+
const raw = (extra) => `---\ntitle: "Post"\n${extra}\n---\ncontent`
21+
assert.equal(isPublished(raw('draft: true'), "2026-09-22"), false)
22+
assert.equal(isPublished(raw('draft: "true"'), "2026-09-22"), false)
23+
assert.equal(isPublished(raw('publishDate: "2026-09-23"'), "2026-09-22"), false)
24+
assert.equal(isPublished(raw('publishDate: "2026-09-22"'), "2026-09-22"), true)
25+
assert.equal(isPublished(raw('draft: false'), "2026-09-22"), true)
26+
})
27+
28+
const manifest = JSON.parse(fs.readFileSync(new URL("../dist/.vite/manifest.json", import.meta.url), "utf8"))
29+
function staticImports(key, visited = new Set()) {
30+
if (visited.has(key)) return visited
31+
assert.ok(manifest[key], `Missing build manifest entry: ${key}`)
32+
visited.add(key)
33+
for (const dependency of manifest[key].imports ?? []) staticImports(dependency, visited)
34+
return visited
35+
}
36+
37+
test("initial page and analytics shell do not wait for chart or body/search chunks", () => {
38+
for (const entry of ["index.html", "src/pages/AnalyticsPage.tsx"]) {
39+
const imports = [...staticImports(entry)]
40+
assert.deepEqual(imports.filter((key) => /AnalyticsCharts|post-body|post-search|_chart-/.test(key)), [])
41+
}
42+
assert.ok(manifest["src/components/AnalyticsCharts.tsx"]?.isDynamicEntry)
43+
assert.ok(manifest["virtual:post-search/ko"]?.isDynamicEntry)
44+
assert.ok(manifest["virtual:post-search/en"]?.isDynamicEntry)
45+
})
46+
47+
test("prerendered article keeps its body while analytics has stable chart placeholders", () => {
48+
const article = fs.readFileSync(new URL("../dist/posts/spring-ai-cs-automation/index.html", import.meta.url), "utf8")
49+
assert.match(article, /<h1/)
50+
assert.match(article, /class="prose/)
51+
assert.match(article, /배경과 운영 경계/)
52+
assert.match(article, /id="post-hydration-data"/)
53+
assert.ok(!article.includes("본문을 불러오는 중"))
54+
const analytics = fs.readFileSync(new URL("../dist/analytics/index.html", import.meta.url), "utf8")
55+
assert.match(analytics, /차트를 불러오는 중/)
56+
assert.ok(!analytics.includes('id="S:'))
57+
})

‎scripts/post-assets.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import fs from "node:fs"
2+
import path from "node:path"
3+
import type { Plugin } from "vite"
4+
import { parseFrontmatter } from "../src/lib/post-frontmatter.ts"
5+
import { getReadingMinutes } from "../src/lib/reading-time.ts"
6+
7+
export function isPublished(raw: string, today: string) {
8+
const { data } = parseFrontmatter(raw)
9+
return data.draft !== "true" && data.draft !== true && !(data.publishDate && String(data.publishDate) > today)
10+
}
11+
12+
export function postMetadataSource(raw: string) {
13+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/)
14+
if (!match) return raw
15+
const { content } = parseFrontmatter(raw)
16+
return `---\n${match[1]}\nreadingMinutes: ${getReadingMinutes(content)}\n---\n`
17+
}
18+
19+
export function postAssetsPlugin(): Plugin {
20+
let root = ""
21+
let production = false
22+
return {
23+
name: "post-metadata-and-search",
24+
enforce: "pre",
25+
configResolved(config) { root = config.root; production = config.isProduction },
26+
resolveId(id) {
27+
if (/^virtual:post-search\/(ko|en)$/.test(id)) return `\0${id}`
28+
},
29+
load(id) {
30+
const language = id.match(/^\0virtual:post-search\/(ko|en)$/)?.[1]
31+
const today = new Date().toISOString().slice(0, 10)
32+
if (language) {
33+
const folder = path.join(root, "content/posts", language)
34+
const texts: Record<string, string> = {}
35+
for (const file of fs.readdirSync(folder).filter((file) => file.endsWith(".md"))) {
36+
const filename = path.join(folder, file)
37+
this.addWatchFile(filename)
38+
const raw = fs.readFileSync(filename, "utf8")
39+
if (!production || isPublished(raw, today)) texts[file.slice(0, -3)] = parseFrontmatter(raw).content.toLowerCase()
40+
}
41+
return `export default ${JSON.stringify(texts)}`
42+
}
43+
const [filename, query = ""] = id.split("?")
44+
if (!filename?.endsWith(".md")) return
45+
const params = new URLSearchParams(query)
46+
if (!params.has("post-meta") && !params.has("post-body")) return
47+
this.addWatchFile(filename)
48+
const raw = fs.readFileSync(filename, "utf8")
49+
const result = production && !isPublished(raw, today) ? "" : params.has("post-meta") ? postMetadataSource(raw) : raw
50+
return `export default ${JSON.stringify(result)}`
51+
},
52+
handleHotUpdate(context) {
53+
if (!context.file.endsWith(".md")) return
54+
for (const language of ["ko", "en"]) {
55+
const module = context.server.moduleGraph.getModuleById(`\0virtual:post-search/${language}`)
56+
if (module) context.server.moduleGraph.invalidateModule(module)
57+
}
58+
context.server.ws.send({ type: "full-reload" })
59+
},
60+
}
61+
}

‎scripts/prerender.mjs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from "node:fs"
22
import path from "node:path"
33
import { fileURLToPath } from "node:url"
4-
import { getPrerenderRoutes, render } from "../dist-ssr/entry-server.js"
4+
import { getPrerenderRoutes, render, preparePostContentForPrerender, getPostHydrationData } from "../dist-ssr/entry-server.js"
55

66
const __dirname = path.dirname(fileURLToPath(import.meta.url))
77
const rootDir = path.resolve(__dirname, "..")
@@ -95,11 +95,16 @@ function injectAppHtml(templateHtml, appHtml) {
9595
)
9696
}
9797

98+
await preparePostContentForPrerender()
9899
const routes = getPrerenderRoutes()
99100

100101
for (const route of routes) {
101102
const appHtml = await render(route.path)
102-
const html = injectAppHtml(withHead(template, route), appHtml)
103+
let html = injectAppHtml(withHead(template, route), appHtml)
104+
const postData = getPostHydrationData(route.path)
105+
if (postData) {
106+
html = html.replace("</body>", `<script id="post-hydration-data" type="application/json">${safeJsonLd(postData)}</script>\n</body>`)
107+
}
103108
const outPath = outputPathFor(route.path)
104109

105110
fs.mkdirSync(path.dirname(outPath), { recursive: true })

‎src/components/AnalyticsCharts.tsx‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Bar, BarChart, CartesianGrid, Label, Pie, PieChart, XAxis, YAxis } from "recharts"
2+
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart"
3+
import { useT } from "@/i18n"
4+
5+
export { DailyVisitsChart } from "@/components/DailyVisitsChart"
6+
7+
export function TagDistributionChart({ data }: { data: { tag: string; count: number; fill: string }[] }) {
8+
const t = useT()
9+
const config: ChartConfig = { count: { label: t.analytics.postsCount } }
10+
for (const item of data) config[item.tag] = { label: item.tag, color: item.fill }
11+
12+
return (
13+
<ChartContainer config={config} className="mx-auto aspect-square max-h-[300px]">
14+
<PieChart>
15+
<ChartTooltip content={<ChartTooltipContent nameKey="tag" hideLabel />} />
16+
<Pie data={data} dataKey="count" nameKey="tag" innerRadius={60} strokeWidth={2} stroke="var(--background)">
17+
<Label content={({ viewBox }) => {
18+
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
19+
return (
20+
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
21+
<tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-3xl font-bold">
22+
{data.reduce((sum, item) => sum + item.count, 0)}
23+
</tspan>
24+
<tspan x={viewBox.cx} y={(viewBox.cy ?? 0) + 24} className="fill-muted-foreground text-sm">
25+
{t.analytics.postsCount}
26+
</tspan>
27+
</text>
28+
)
29+
}
30+
}} />
31+
</Pie>
32+
</PieChart>
33+
</ChartContainer>
34+
)
35+
}
36+
37+
export function MonthlyPostsChart({ data }: { data: { month: string; count: number }[] }) {
38+
const t = useT()
39+
const config = { count: { label: t.analytics.postsCount, color: "var(--primary)" } } satisfies ChartConfig
40+
return (
41+
<ChartContainer config={config} className="h-[200px] w-full">
42+
<BarChart data={data} margin={{ left: 0, right: 0, top: 4, bottom: 0 }}>
43+
<CartesianGrid vertical={false} strokeDasharray="3 3" />
44+
<XAxis dataKey="month" tickFormatter={(value: string) => {
45+
const [year, month] = value.split("-")
46+
return `${year!.slice(2)}/${month}`
47+
}} tickLine={false} axisLine={false} tickMargin={8} minTickGap={30} />
48+
<YAxis allowDecimals={false} tickLine={false} axisLine={false} tickMargin={4} width={24} />
49+
<ChartTooltip content={<ChartTooltipContent hideLabel={false} />} labelFormatter={(value) => value} />
50+
<Bar dataKey="count" fill="var(--color-count)" radius={[4, 4, 0, 0]} maxBarSize={60} />
51+
</BarChart>
52+
</ChartContainer>
53+
)
54+
}

0 commit comments

Comments
 (0)