conventions-perf
GitHub规范性能守卫检查,涵盖网络、Bundle、React编译器回退等基线。当CI失败或修改热点路由时应用,核心原则是优先修复回归而非重置基线。
Trigger Scenarios
Install
npx skills add stella/stella --skill conventions-perf -g -y
SKILL.md
Frontmatter
{
"name": "conventions-perf",
"description": "Apply when a performance-guard check (network baseline, bundle baseline, DB query count, loader-prefetch lint, RC bailouts) fails or when touching a hot route\/endpoint."
}
Performance Guard Conventions
Apply when a performance-guard check fails in CI, or when touching a route or
endpoint flagged by the live bun scripts/perf-hotspots.ts report.
Overview
Stella guards performance with recorded budgets, diffed on every run. A regression either fails CI outright or shows up as a reviewable diff in the PR. Six guards exist today:
- Network baseline (
apps/web/e2e/network-baseline.json, checked byapps/web/e2e/helpers/network.tsfromapps/web/e2e/specs/route-smoke.spec.ts): per-route request manifest, waterfall depth, per-request repeat budget, and per-request DB query budget. - Bundle baseline (
scripts/bundle-baseline.ts+scripts/bundle-baseline.json): gzipped size per vendor/entry/route chunk group, reported by the web-build CI job via--checkand listed nightly bybundle-size-nightly.yml. Growth past the headroom is a warning annotation plus a job-summary table, never a failing check; it fails only when it cannot measure (missing build or baseline). - React Compiler bailout guard (
scripts/rc-bailouts.ts+scripts/react-compiler-bailouts.json): tracks every component the compiler cannot memoize, so a bailout losing its manualuseMemo/useCallbackfails CI instead of silently reintroducing an infinite-update-loop risk. - DB query counter (
apps/api/src/lib/db-query-counter.ts): the runtime half of the network baseline's DB-query budget. Dev/test only. require-loader-prefetchoxlint rule (.oxlint-plugins/require-loader-prefetch.ts): static, not baseline-based; flags the waterfall pattern the network baseline would otherwise only catch after the fact.- Per-iteration I/O checks:
scripts/db-await-in-loop.ts(type-aware, CI) flags a database call awaited once per loop iteration (the N+1), recognizing handles by type rather than name and suppressed with// db-await-in-loop: <reason>. A single DB hit in a constant-sized batch round (canonicalchunkedor an array slice-step loop, size >= 2), a fixed set of at most 16 elements, or a nonnegative constant-start counter (constant bound <= 16) is accepted when no enclosing loop or fan-out exists. Multiple hits, nested per-row work, variable sizes, mutated counters and keyset walks still require batching or an explicit reason.no-network-await-in-loopflags an HTTP request, AWS SDK command dispatch, or API-client method awaited the same way (iterations x RTT). Both are static and both name the owner, so the fix is concrete: batch the calls, or record in the suppression reason why the sequence is required. There is no generic await-in-loop rule; a sequential await with no I/O behind it costs nothing to guard.
The Core Norm
Fix the regression first. A red guard means the change made something slower, heavier, or chattier than before. Reseeding the baseline to make CI green is not a mechanical step; it is a product decision that the regression is acceptable, and it must be justified in the PR description (why the extra request, the deeper wait, or the bigger chunk is worth it).
Main owns network recordings. The main-only recorder runs after route/runtime
merges and nightly; the delivery workflow validates and publishes a baseline
artifact keyed by the recorded commit. PR and merge-queue checks load the
newest published recording at or before their merge base (or the committed
file at that same base when recording is unavailable). They never edit the shared JSON or request a
baseline:record delivery.
New and removed routes are scoped from changed route sources and reported in
the job summary. Existing routes retain every allowance, including routes
whose source changed. An intentional increase requires a reviewed JSON file
in apps/web/e2e/network-budgets/<change>.json with route, a nonempty
reason, and a complete budget entry (depth, requests, and applicable
requestCounts, dbQueries, responseSizes). Only declarations added or
modified since the merge base apply; inherited declarations cannot repeatedly
widen future checks. Use one file per change, so unrelated changes do not
share a generated baseline conflict.
E2E_NETWORK_BASELINE=write and rewrite remain local measurement modes;
main recording defaults to write; dispatch with mode=rewrite after a
performance fix to tighten it. Do not commit measurement output on a PR.
The bundle baseline mirrors this with --write-baseline (regenerate) and a
RATCHET_DOWN prompt (not a failure) when a chunk shrinks by more than 3%,
so a real win gets locked in rather than silently drifting back up. The RC
bailout guard and the query counter follow the same "commit the smaller
number, don't just silence the check" norm.
Failure Playbook
New request on route
network.ts reports New API request(s) on <route>. The route now
calls an endpoint it did not before. If intentional, declare the reviewed budget. If
not, find what changed (a new hook mount, a widened select, an added
useQuery) and remove the call.
Request waterfall got deeper
Request waterfall got deeper on <route>: N -> M. Each extra level is one
more sequential network round the user waits through. The fix is almost
always to start the query in the route loader instead of the component:
prefetch it with ensureRouteQueryData (blocking, critical data) or
prefetchRouteQuery (non-blocking warmup) from apps/web/src/lib/react-query.ts,
so the fetch starts during navigation in parallel with code-split chunk
loading, and the component's useSuspenseQuery consumes an already-warm
cache instead of opening a new round. require-loader-prefetch catches the
same pattern statically before it ever reaches the baseline: it flags
useSuspenseQuery(factory(...)) when the route has no loader, or has one
that never references factory.
Request repeated more than budgeted
API request repeated on <route>: <key> ran N -> M times. Baseline budgets
per-request-key repeat counts (default 1 unless the committed
requestCounts says otherwise). Duplicate firing usually comes from
duplicate component mounts, normalized UUID fan-out (multiple ids hitting the
same :id-normalized key), or a refetch policy that lets the same endpoint
fire twice. Reuse the in-flight query instead of issuing a second one.
DB query count grew
DB queries per request grew on <route>: <key> ran N -> M queries. The
classic cause is an N+1: a per-row query inside a loop, or a lazy relation
loaded once per item instead of preloaded. Batch it (joins, IN lists,
Drizzle relation preloading); see /conventions-db for indexing and batching
patterns. The allowance (dbQueryAllowance, budget + max(2, 15%)) already
absorbs normal noise (auth session-refresh piggybacks, cache variance); a
failure here is a real regression, not jitter.
If instead the check reports DB query count missing on <route>, the
response stopped exposing the dev/test x-db-queries header — restore the
query counter wiring before trusting the route's N+1 budget again.
Bundle group over budget
The bundle report warns (it does not block) when a named group (entry, a
vendor-* chunk, or routes/largest-route) exceeds its committed gzip size
by more than 3% (or 1 KiB, whichever is larger, per
HEADROOM/HEADROOM_FLOOR_BYTES). Treat the warning as work: find what grew
and trim it, or re-measure with --write-baseline and a rationale. Two specific
shapes to look for:
- A dependency escaped its
manualChunksbucket and landed inentry(paid on every cold visit) instead of a lazy route chunk orvendor-*group. Dynamic-import()it, or fix themanualChunksrule inapps/web/vite.config.ts. vendor-anonymize-dataorwasm-vendorshow up nonzero. These are tracked at 0 because they should only ever load inside a web worker, never the main client bundle. A nonzero value means a worker-only dependency leaked into the client graph; keep it worker-only instead of widening the baseline.
require-loader-prefetch lint failure
Same underlying problem as "waterfall got deeper," caught statically instead
of at e2e time: a route component calls useSuspenseQuery(factory(...)) but
the route's loader either doesn't exist or never references factory.
Prefetch factory(...) in the loader via ensureRouteQueryData or
prefetchRouteQuery.
How Depth Is Measured
The measurement navigation uses round-barrier-v1: page.route holds every
API request selected by the network collector. The round controller releases
all currently held requests together and assigns each the same round level.
It releases the next round only after every released request has finished or
failed and the page has been quiet for NETWORK_ROUND_SETTLE_MS (150 ms)
after the last response, with no new tracked request during that window. The
navigation also uses the route smoke spec's existing page-settle check.
Requests arriving while a round is in flight wait for the next round.
Long-lived event streams complete at response headers so an open channel
cannot block the barrier.
Depth is the number of nonempty released rounds; depthChain lists the
request keys in each round. Response completion order cannot put independent
branches at different levels: children of slow and fast parents released in
the same round wait at the same barrier. A request arriving after the route's
existing settled point is idle prefetch and cannot open a new round. Depth
comes from scheduled release levels, never inferred launch gaps or response
timing. Measurement adds approximately one settle window plus the slowest
server response per round; report measured shard wall time when changing it.
The recorder writes depthMethod: "round-barrier-v1" in the baseline. When
the selected recording has a missing or different method, compare requests,
request counts, DB queries, and response sizes normally, but report depth
only as a notice. Enforce depth again once main publishes a recording with
the same method. Do not widen depth budgets to bridge measurement methods.
Keep the round controller testable as a pure state machine over held requests, in-flight requests, events, and a clock. Tests cover independent branches with unequal response durations, genuine dependencies across rounds, and randomized request graphs and completion orders whose depth matches the graph's longest path. Replacing the barrier with timing-derived levels must fail those tests.
Live Hotspot Burn-Down
Do not embed a dated hotspot snapshot in instructions; it becomes false while remaining authoritative-looking. Before touching a hot route or endpoint, run:
bun scripts/perf-hotspots.ts
Treat the reported budgets as recorded debt, not acceptable targets. Capture the relevant before value, fix or avoid worsening the access path, then run the same command and affected guard again. When a real improvement lowers a baseline, commit the tighter value. When an intentional product capability raises one, document the measured tradeoff in the PR rather than hiding it in a generic baseline refresh.
For database changes, pair the route/query counter with the actual query plan and cardinality. A lower request count can still conceal a slower scan, and a fast development database does not validate a production-size access path.
Cross-Links
/conventions-scale— pagination and tenant-scoped queries; a query that ignores these will also blow the DB-query budget./conventions-db— indexes, batching, relation preloading; the concrete fix for most N+1 failures above./conventions-ux— GPU-friendly animation and skeleton conventions; a waterfall fix that adds a loading state should use a real structural skeleton, not a spinner.
Version History
-
0f3714b
Current 2026-10-19 03:35
优化route-smoke深度测量逻辑,通过控制请求启动和聚类时间解决时序抖动问题,移除深度重采样及未用辅助函数。
-
d37ba69
2026-09-27 20:51
将数据库循环等待检查升级为基于TypeScript类型检查的实现,替换原有的名称匹配规则,提供更精确的N+1查询检测。
-
7b52e43
2026-09-09 03:26
新增 no-network-await-in-loop 规则以替代通用的无等待循环规则,细化对数据库和网络调用的检测,减少误报。
-
f4b61c7
2026-08-19 20:01
重构网络基线的水深计算逻辑以解决负载下的非单调增长问题,确保指标稳定性。
-
dd81665
2026-08-16 07:08
更新开发工具文档,改进技能隔离并解决代码审查反馈
- 85792bd 2026-07-24 16:11


