@ogygia/devtools 0.0.0-stage → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +25 -2
- package/dist/client.d.ts +3 -0
- package/dist/client.js +24 -0
- package/dist/devtools/BoundaryOverlay.svelte +396 -0
- package/dist/devtools/BoundaryOverlay.svelte.d.ts +19 -0
- package/dist/devtools/Devtools.svelte +663 -0
- package/dist/devtools/Devtools.svelte.d.ts +15 -0
- package/dist/devtools/HubTab.svelte +206 -0
- package/dist/devtools/HubTab.svelte.d.ts +11 -0
- package/dist/devtools/HydrationTab.svelte +237 -0
- package/dist/devtools/HydrationTab.svelte.d.ts +13 -0
- package/dist/devtools/IslandDetail.svelte +615 -0
- package/dist/devtools/IslandDetail.svelte.d.ts +15 -0
- package/dist/devtools/LedgerTab.svelte +287 -0
- package/dist/devtools/LedgerTab.svelte.d.ts +11 -0
- package/dist/devtools/LensTab.svelte +290 -0
- package/dist/devtools/LensTab.svelte.d.ts +19 -0
- package/dist/devtools/NavTab.svelte +229 -0
- package/dist/devtools/NavTab.svelte.d.ts +11 -0
- package/dist/devtools/PageTab.svelte +1122 -0
- package/dist/devtools/PageTab.svelte.d.ts +15 -0
- package/dist/devtools/ProfilerTab.svelte +755 -0
- package/dist/devtools/ProfilerTab.svelte.d.ts +15 -0
- package/dist/devtools/PropsTree.svelte +155 -0
- package/dist/devtools/PropsTree.svelte.d.ts +15 -0
- package/dist/devtools/SessionTab.svelte +796 -0
- package/dist/devtools/SessionTab.svelte.d.ts +11 -0
- package/dist/devtools/TimelineTab.svelte +267 -0
- package/dist/devtools/TimelineTab.svelte.d.ts +11 -0
- package/dist/devtools/WireTab.svelte +149 -0
- package/dist/devtools/WireTab.svelte.d.ts +11 -0
- package/dist/devtools/bytes.d.ts +70 -0
- package/dist/devtools/bytes.js +82 -0
- package/dist/devtools/cache-headers.d.ts +20 -0
- package/dist/devtools/cache-headers.js +81 -0
- package/dist/devtools/highlight.d.ts +7 -0
- package/dist/devtools/highlight.js +83 -0
- package/dist/devtools/hydration.d.ts +48 -0
- package/dist/devtools/hydration.js +93 -0
- package/dist/devtools/index.d.ts +3 -0
- package/dist/devtools/index.js +3 -0
- package/dist/devtools/ledger-dom.d.ts +7 -0
- package/dist/devtools/ledger-dom.js +151 -0
- package/dist/devtools/leftovers.d.ts +77 -0
- package/dist/devtools/leftovers.js +319 -0
- package/dist/devtools/names.d.ts +1 -0
- package/dist/devtools/names.js +34 -0
- package/dist/devtools/page.d.ts +96 -0
- package/dist/devtools/page.js +957 -0
- package/dist/devtools/profile-anchors.d.ts +22 -0
- package/dist/devtools/profile-anchors.js +135 -0
- package/dist/devtools/profile-store.d.ts +175 -0
- package/dist/devtools/profile-store.js +239 -0
- package/dist/devtools/regions.d.ts +77 -0
- package/dist/devtools/regions.js +132 -0
- package/dist/devtools/session.d.ts +31 -0
- package/dist/devtools/session.js +455 -0
- package/dist/devtools/since-load.d.ts +50 -0
- package/dist/devtools/since-load.js +89 -0
- package/dist/devtools/source-lines.d.ts +24 -0
- package/dist/devtools/source-lines.js +106 -0
- package/dist/devtools/styles.d.ts +52 -0
- package/dist/devtools/styles.js +248 -0
- package/dist/devtools/testing-engine.d.ts +83 -0
- package/dist/devtools/testing-engine.js +220 -0
- package/dist/devtools/ui-app.d.ts +5 -0
- package/dist/devtools/ui-app.js +69 -0
- package/dist/devtools/ui.d.ts +50 -0
- package/dist/devtools/ui.js +175 -0
- package/dist/profiler/alloc.d.ts +75 -0
- package/dist/profiler/alloc.js +76 -0
- package/dist/profiler/analyze.d.ts +437 -0
- package/dist/profiler/analyze.js +1941 -0
- package/dist/profiler/app-path.d.ts +7 -0
- package/dist/profiler/app-path.js +26 -0
- package/dist/profiler/async-io.d.ts +58 -0
- package/dist/profiler/async-io.js +187 -0
- package/dist/profiler/beacon-standalone.d.ts +4 -0
- package/dist/profiler/beacon-standalone.js +409 -0
- package/dist/profiler/browser-findings.d.ts +25 -0
- package/dist/profiler/browser-findings.js +260 -0
- package/dist/profiler/byte-strip.d.ts +58 -0
- package/dist/profiler/byte-strip.js +228 -0
- package/dist/profiler/client-files.d.ts +16 -0
- package/dist/profiler/client-files.js +60 -0
- package/dist/profiler/client-windows.d.ts +49 -0
- package/dist/profiler/client-windows.js +95 -0
- package/dist/profiler/client.d.ts +40 -0
- package/dist/profiler/client.js +82 -0
- package/dist/profiler/compare.d.ts +308 -0
- package/dist/profiler/compare.js +733 -0
- package/dist/profiler/compress.d.ts +10 -0
- package/dist/profiler/compress.js +36 -0
- package/dist/profiler/contention.d.ts +60 -0
- package/dist/profiler/contention.js +86 -0
- package/dist/profiler/crypto.d.ts +19 -0
- package/dist/profiler/crypto.js +101 -0
- package/dist/profiler/doc-diff.d.ts +47 -0
- package/dist/profiler/doc-diff.js +214 -0
- package/dist/profiler/dom-diff.d.ts +43 -0
- package/dist/profiler/dom-diff.js +121 -0
- package/dist/profiler/drill.d.ts +89 -0
- package/dist/profiler/drill.js +553 -0
- package/dist/profiler/embedded-maps.d.ts +37 -0
- package/dist/profiler/embedded-maps.js +135 -0
- package/dist/profiler/env.d.ts +6 -0
- package/dist/profiler/env.js +10 -0
- package/dist/profiler/forecast.d.ts +76 -0
- package/dist/profiler/forecast.js +225 -0
- package/dist/profiler/frames.d.ts +11 -0
- package/dist/profiler/frames.js +42 -0
- package/dist/profiler/gc.d.ts +144 -0
- package/dist/profiler/gc.js +271 -0
- package/dist/profiler/hole-slots.d.ts +31 -0
- package/dist/profiler/hole-slots.js +90 -0
- package/dist/profiler/index.d.ts +288 -0
- package/dist/profiler/index.js +4921 -0
- package/dist/profiler/insights.d.ts +96 -0
- package/dist/profiler/insights.js +239 -0
- package/dist/profiler/ledger.d.ts +121 -0
- package/dist/profiler/ledger.js +440 -0
- package/dist/profiler/lineage.d.ts +81 -0
- package/dist/profiler/lineage.js +359 -0
- package/dist/profiler/module-map.d.ts +12 -0
- package/dist/profiler/module-map.js +94 -0
- package/dist/profiler/net.d.ts +169 -0
- package/dist/profiler/net.js +586 -0
- package/dist/profiler/on-demand.d.ts +11 -0
- package/dist/profiler/on-demand.js +15 -0
- package/dist/profiler/own-requests.d.ts +9 -0
- package/dist/profiler/own-requests.js +17 -0
- package/dist/profiler/patterns.d.ts +476 -0
- package/dist/profiler/patterns.js +2203 -0
- package/dist/profiler/profiler-router.d.ts +245 -0
- package/dist/profiler/profiler-router.js +113 -0
- package/dist/profiler/refresh-stub.d.ts +2 -0
- package/dist/profiler/refresh-stub.js +37 -0
- package/dist/profiler/report.d.ts +1404 -0
- package/dist/profiler/report.js +2638 -0
- package/dist/profiler/river.d.ts +65 -0
- package/dist/profiler/river.js +298 -0
- package/dist/profiler/route-files.d.ts +22 -0
- package/dist/profiler/route-files.js +55 -0
- package/dist/profiler/route-imports.d.ts +14 -0
- package/dist/profiler/route-imports.js +83 -0
- package/dist/profiler/saving.d.ts +22 -0
- package/dist/profiler/saving.js +42 -0
- package/dist/profiler/score.d.ts +109 -0
- package/dist/profiler/score.js +261 -0
- package/dist/profiler/session-cookie.d.ts +28 -0
- package/dist/profiler/session-cookie.js +45 -0
- package/dist/profiler/sink.d.ts +81 -0
- package/dist/profiler/sink.js +124 -0
- package/dist/profiler/site-fixes.d.ts +25 -0
- package/dist/profiler/site-fixes.js +62 -0
- package/dist/profiler/source-scan.d.ts +210 -0
- package/dist/profiler/source-scan.js +1724 -0
- package/dist/profiler/span.d.ts +90 -0
- package/dist/profiler/span.js +109 -0
- package/dist/profiler/stacks.d.ts +43 -0
- package/dist/profiler/stacks.js +107 -0
- package/dist/profiler/standalone.d.ts +27 -0
- package/dist/profiler/standalone.js +98 -0
- package/dist/profiler/steps.d.ts +39 -0
- package/dist/profiler/steps.js +74 -0
- package/dist/profiler/storage/index.d.ts +87 -0
- package/dist/profiler/storage/index.js +76 -0
- package/dist/profiler/storage/postgres.d.ts +12 -0
- package/dist/profiler/storage/postgres.js +94 -0
- package/dist/profiler/storage/redis.d.ts +19 -0
- package/dist/profiler/storage/redis.js +148 -0
- package/dist/profiler/storage/sqlite.d.ts +2 -0
- package/dist/profiler/storage/sqlite.js +85 -0
- package/dist/profiler/storage/visits.d.ts +4 -0
- package/dist/profiler/storage/visits.js +6 -0
- package/dist/profiler/stream-tail.d.ts +26 -0
- package/dist/profiler/stream-tail.js +74 -0
- package/dist/profiler/timeline.d.ts +279 -0
- package/dist/profiler/timeline.js +690 -0
- package/dist/profiler/ui/AllocStrip.svelte +146 -0
- package/dist/profiler/ui/AllocStrip.svelte.d.ts +17 -0
- package/dist/profiler/ui/Awaits.svelte +363 -0
- package/dist/profiler/ui/Awaits.svelte.d.ts +16 -0
- package/dist/profiler/ui/ByteStrip.svelte +182 -0
- package/dist/profiler/ui/ByteStrip.svelte.d.ts +18 -0
- package/dist/profiler/ui/Compare.svelte +24 -0
- package/dist/profiler/ui/Compare.svelte.d.ts +16 -0
- package/dist/profiler/ui/CompareBody.svelte +429 -0
- package/dist/profiler/ui/CompareBody.svelte.d.ts +8 -0
- package/dist/profiler/ui/ComponentsTable.svelte +265 -0
- package/dist/profiler/ui/ComponentsTable.svelte.d.ts +20 -0
- package/dist/profiler/ui/Dashboard.svelte +492 -0
- package/dist/profiler/ui/Dashboard.svelte.d.ts +31 -0
- package/dist/profiler/ui/DataRiver.svelte +206 -0
- package/dist/profiler/ui/DataRiver.svelte.d.ts +13 -0
- package/dist/profiler/ui/DomTravel.svelte +207 -0
- package/dist/profiler/ui/DomTravel.svelte.d.ts +9 -0
- package/dist/profiler/ui/DownloadHtml.svelte +52 -0
- package/dist/profiler/ui/DownloadHtml.svelte.d.ts +10 -0
- package/dist/profiler/ui/Drill.svelte +276 -0
- package/dist/profiler/ui/Drill.svelte.d.ts +12 -0
- package/dist/profiler/ui/ExportButton.svelte +59 -0
- package/dist/profiler/ui/ExportButton.svelte.d.ts +8 -0
- package/dist/profiler/ui/Flame.svelte +222 -0
- package/dist/profiler/ui/Flame.svelte.d.ts +12 -0
- package/dist/profiler/ui/FrameDetails.svelte +308 -0
- package/dist/profiler/ui/FrameDetails.svelte.d.ts +16 -0
- package/dist/profiler/ui/FunctionsTable.svelte +156 -0
- package/dist/profiler/ui/FunctionsTable.svelte.d.ts +17 -0
- package/dist/profiler/ui/IslandHeatmap.svelte +151 -0
- package/dist/profiler/ui/IslandHeatmap.svelte.d.ts +16 -0
- package/dist/profiler/ui/IslandsTable.svelte +448 -0
- package/dist/profiler/ui/IslandsTable.svelte.d.ts +16 -0
- package/dist/profiler/ui/KeepLocal.svelte +46 -0
- package/dist/profiler/ui/KeepLocal.svelte.d.ts +8 -0
- package/dist/profiler/ui/Ledger.svelte +196 -0
- package/dist/profiler/ui/Ledger.svelte.d.ts +18 -0
- package/dist/profiler/ui/LocalCompare.svelte +48 -0
- package/dist/profiler/ui/LocalCompare.svelte.d.ts +8 -0
- package/dist/profiler/ui/LocalReports.svelte +68 -0
- package/dist/profiler/ui/LocalReports.svelte.d.ts +7 -0
- package/dist/profiler/ui/Login.svelte +13 -0
- package/dist/profiler/ui/Login.svelte.d.ts +12 -0
- package/dist/profiler/ui/LoginForm.svelte +42 -0
- package/dist/profiler/ui/LoginForm.svelte.d.ts +7 -0
- package/dist/profiler/ui/Message.svelte +10 -0
- package/dist/profiler/ui/Message.svelte.d.ts +12 -0
- package/dist/profiler/ui/OnThisPage.svelte +137 -0
- package/dist/profiler/ui/OnThisPage.svelte.d.ts +3 -0
- package/dist/profiler/ui/OneClock.svelte +297 -0
- package/dist/profiler/ui/OneClock.svelte.d.ts +27 -0
- package/dist/profiler/ui/Panel.svelte +10 -0
- package/dist/profiler/ui/Panel.svelte.d.ts +16 -0
- package/dist/profiler/ui/Paths.svelte +269 -0
- package/dist/profiler/ui/Paths.svelte.d.ts +15 -0
- package/dist/profiler/ui/Patterns.svelte +364 -0
- package/dist/profiler/ui/Patterns.svelte.d.ts +15 -0
- package/dist/profiler/ui/PermalinkGate.svelte +143 -0
- package/dist/profiler/ui/PermalinkGate.svelte.d.ts +10 -0
- package/dist/profiler/ui/RenderSteps.svelte +218 -0
- package/dist/profiler/ui/RenderSteps.svelte.d.ts +13 -0
- package/dist/profiler/ui/Report.svelte +28 -0
- package/dist/profiler/ui/Report.svelte.d.ts +20 -0
- package/dist/profiler/ui/ReportBody.svelte +2592 -0
- package/dist/profiler/ui/ReportBody.svelte.d.ts +21 -0
- package/dist/profiler/ui/Run.svelte +17 -0
- package/dist/profiler/ui/Run.svelte.d.ts +14 -0
- package/dist/profiler/ui/RunView.svelte +146 -0
- package/dist/profiler/ui/RunView.svelte.d.ts +10 -0
- package/dist/profiler/ui/ScoreCard.svelte +197 -0
- package/dist/profiler/ui/ScoreCard.svelte.d.ts +7 -0
- package/dist/profiler/ui/Scrub.svelte +293 -0
- package/dist/profiler/ui/Scrub.svelte.d.ts +18 -0
- package/dist/profiler/ui/SeedExplainer.svelte +193 -0
- package/dist/profiler/ui/SeedExplainer.svelte.d.ts +16 -0
- package/dist/profiler/ui/ShareLink.svelte +148 -0
- package/dist/profiler/ui/ShareLink.svelte.d.ts +8 -0
- package/dist/profiler/ui/Shell.svelte +50 -0
- package/dist/profiler/ui/Shell.svelte.d.ts +27 -0
- package/dist/profiler/ui/Sidebar.svelte +312 -0
- package/dist/profiler/ui/Sidebar.svelte.d.ts +7 -0
- package/dist/profiler/ui/Site.svelte +20 -0
- package/dist/profiler/ui/Site.svelte.d.ts +23 -0
- package/dist/profiler/ui/SiteView.svelte +385 -0
- package/dist/profiler/ui/SiteView.svelte.d.ts +27 -0
- package/dist/profiler/ui/ThemeSwitch.svelte +78 -0
- package/dist/profiler/ui/ThemeSwitch.svelte.d.ts +3 -0
- package/dist/profiler/ui/Timeline.svelte +672 -0
- package/dist/profiler/ui/Timeline.svelte.d.ts +22 -0
- package/dist/profiler/ui/Treemap.svelte +278 -0
- package/dist/profiler/ui/Treemap.svelte.d.ts +20 -0
- package/dist/profiler/ui/Upload.svelte +19 -0
- package/dist/profiler/ui/Upload.svelte.d.ts +10 -0
- package/dist/profiler/ui/UploadForm.svelte +59 -0
- package/dist/profiler/ui/UploadForm.svelte.d.ts +6 -0
- package/dist/profiler/ui/WaitingTable.svelte +55 -0
- package/dist/profiler/ui/WaitingTable.svelte.d.ts +14 -0
- package/dist/profiler/ui/Waterfall.svelte +371 -0
- package/dist/profiler/ui/Waterfall.svelte.d.ts +7 -0
- package/dist/profiler/ui/WhatIf.svelte +87 -0
- package/dist/profiler/ui/WhatIf.svelte.d.ts +18 -0
- package/dist/profiler/ui/fake-progress.svelte.d.ts +15 -0
- package/dist/profiler/ui/fake-progress.svelte.js +38 -0
- package/dist/profiler/ui/format.d.ts +20 -0
- package/dist/profiler/ui/format.js +71 -0
- package/dist/profiler/ui/permalink.d.ts +9 -0
- package/dist/profiler/ui/permalink.js +108 -0
- package/dist/profiler/ui/press.d.ts +5 -0
- package/dist/profiler/ui/press.js +19 -0
- package/dist/profiler/ui/profiler.css +650 -0
- package/dist/profiler/ui/report-data.d.ts +189 -0
- package/dist/profiler/ui/report-data.js +393 -0
- package/dist/profiler/ui/report-request.d.ts +6 -0
- package/dist/profiler/ui/report-request.js +50 -0
- package/dist/profiler/ui/row-anchor.svelte.d.ts +12 -0
- package/dist/profiler/ui/row-anchor.svelte.js +44 -0
- package/dist/profiler/ui/sort.svelte.d.ts +14 -0
- package/dist/profiler/ui/sort.svelte.js +37 -0
- package/dist/profiler/ui/store.d.ts +45 -0
- package/dist/profiler/ui/store.js +92 -0
- package/dist/profiler/visit.d.ts +389 -0
- package/dist/profiler/visit.js +1002 -0
- package/dist/report.d.ts +5 -0
- package/dist/report.js +4 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.js +38 -0
- package/dist/vite.d.ts +12 -0
- package/dist/vite.js +78 -0
- package/package.json +103 -3
|
@@ -0,0 +1,2203 @@
|
|
|
1
|
+
import { REGEXP_FRAME, backtracks, calls_name, function_body, import_aliases, needs_previous, promise_all_rewrite, regex_literals, tokens, wrapper_aliases } from "./source-scan.js";
|
|
2
|
+
// src/profiler/patterns.ts
|
|
3
|
+
/** a two-or-three-word tag for each kind, for a badge beside a line */
|
|
4
|
+
const PATTERN_LABEL = {
|
|
5
|
+
"formatter-per-call": "formatter per call",
|
|
6
|
+
"rescan-in-loop": "rescan per item",
|
|
7
|
+
"lookup-in-loop": "search per item",
|
|
8
|
+
"table-per-call": "rebuilt each call",
|
|
9
|
+
"regexp-per-call": "regex per call",
|
|
10
|
+
"slow-regex": "slow regex",
|
|
11
|
+
"deep-copy": "deep copy",
|
|
12
|
+
"spread-accumulate": "copy per step",
|
|
13
|
+
"string-build": "string growth",
|
|
14
|
+
"sort-per-call": "sort per call",
|
|
15
|
+
"date-parse": "date parsing",
|
|
16
|
+
"sync-io": "blocking disk",
|
|
17
|
+
"yield-per-item": "yields per item",
|
|
18
|
+
"scan-for-key": "walks to find a key",
|
|
19
|
+
"waits-in-a-row": "waits in a row",
|
|
20
|
+
"repeat-request": "same request again",
|
|
21
|
+
"same-answer": "same answer every render",
|
|
22
|
+
"almost-same-answer": "same answer but for a stamp",
|
|
23
|
+
"kept-per-render": "kept after render",
|
|
24
|
+
"seed-whole-read": "ships all page data",
|
|
25
|
+
"cache-never-hits": "cache never hits",
|
|
26
|
+
"library-per-item": "library call per item",
|
|
27
|
+
"same-every-request": "same work every request",
|
|
28
|
+
"render-per-item": "renders per item",
|
|
29
|
+
"late-island-wait": "waits for a late island",
|
|
30
|
+
"batch-straggler": "one slow call in a batch",
|
|
31
|
+
"same-document": "same page every request",
|
|
32
|
+
"almost-same-document": "same page but for a few parts"
|
|
33
|
+
};
|
|
34
|
+
/** lines each side of a site shown with it */
|
|
35
|
+
const CONTEXT = 2;
|
|
36
|
+
/** a context line longer than this is cut: it is there to be recognised, not read in full */
|
|
37
|
+
const CONTEXT_COLS = 160;
|
|
38
|
+
/** `{ context }` for a site, or nothing when the source is not available */
|
|
39
|
+
function ctx_field(source, path, line) {
|
|
40
|
+
const got = source?.(path, Math.max(1, line - CONTEXT), line + CONTEXT);
|
|
41
|
+
if (!got?.lines.length) return {};
|
|
42
|
+
return { context: {
|
|
43
|
+
start: got.start,
|
|
44
|
+
lines: got.lines.map((l) => l.length > CONTEXT_COLS ? l.slice(0, 159) + "…" : l)
|
|
45
|
+
} };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* THE CRITICAL PATH, not the sum: a chain of waits started together ends when its longest call
|
|
49
|
+
* does, but the render goes on only once whatever ran BESIDE the chain is done too (Kit waits for
|
|
50
|
+
* a layout load running alongside a page load; a Promise.all waits for all). So the time that
|
|
51
|
+
* really comes off is the chain's end minus the later of its new end and the end (up to the old
|
|
52
|
+
* chain end) of any call that overlapped it. Only calls that overlapped count: a call that started
|
|
53
|
+
* after the chain ended comes later and moves up with it, it is not beside it. Each chain call is
|
|
54
|
+
* found in the render's await graph by its start and length; undefined when the graph does not
|
|
55
|
+
* hold the chains (another window, a capped graph).
|
|
56
|
+
*/
|
|
57
|
+
function wait_save_on_path(p, awaits, _window_ms, w0) {
|
|
58
|
+
if (!awaits?.nodes.length || !p.chains?.length) return void 0;
|
|
59
|
+
let save = 0;
|
|
60
|
+
let bound_by;
|
|
61
|
+
for (const chain of p.chains) {
|
|
62
|
+
if (chain.length < 2) continue;
|
|
63
|
+
const mine = /* @__PURE__ */ new Set();
|
|
64
|
+
for (const c of chain) {
|
|
65
|
+
const t0 = c.start - w0;
|
|
66
|
+
const node = awaits.nodes.find((n) => !mine.has(n) && Math.abs(n.t0 - t0) <= .6 && Math.abs(n.t1 - n.t0 - c.ms) <= 1);
|
|
67
|
+
if (!node) return void 0;
|
|
68
|
+
mine.add(node);
|
|
69
|
+
}
|
|
70
|
+
const nodes = [...mine];
|
|
71
|
+
const start = Math.min(...nodes.map((n) => n.t0));
|
|
72
|
+
const end = Math.max(...nodes.map((n) => n.t1));
|
|
73
|
+
let beside = start + Math.max(...nodes.map((n) => n.t1 - n.t0));
|
|
74
|
+
let who;
|
|
75
|
+
for (const n of awaits.nodes) {
|
|
76
|
+
if (mine.has(n) || n.t0 >= end || n.t1 <= start) continue;
|
|
77
|
+
const until = Math.min(n.t1, end);
|
|
78
|
+
if (until > beside) {
|
|
79
|
+
beside = until;
|
|
80
|
+
who = n.label;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
save += Math.max(0, end - beside);
|
|
84
|
+
if (who && !bound_by) bound_by = who;
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
save_ms: Math.round(save * 100) / 100,
|
|
88
|
+
...bound_by ? { bound_by } : {}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* THE SAME ANSWER EVERY TIME: the GET calls that ran in EVERY profiled render and got the same bytes
|
|
93
|
+
* back each time (the body fingerprint net.ts takes). Across requests the render asks for data that
|
|
94
|
+
* did not change: an answer to cache (a CMS entry, a config, a price list, flags). Keyed like a
|
|
95
|
+
* wait's `exact` (`GET <url>`). Needs two renders or more; a call whose body was not read (no
|
|
96
|
+
* fingerprint) or that failed never counts.
|
|
97
|
+
*/
|
|
98
|
+
function stable_answers(calls, runs) {
|
|
99
|
+
const out = /* @__PURE__ */ new Set();
|
|
100
|
+
if (runs.length < 2) return out;
|
|
101
|
+
const by = /* @__PURE__ */ new Map();
|
|
102
|
+
for (const c of calls) {
|
|
103
|
+
if (c.method !== "GET" || c.status < 200 || c.status >= 300 || !c.body_hash) continue;
|
|
104
|
+
const run = runs.findIndex((r) => c.start >= r.start && c.start <= r.end);
|
|
105
|
+
if (run === -1) continue;
|
|
106
|
+
const k = `GET ${c.url}`;
|
|
107
|
+
const g = by.get(k) ?? by.set(k, {
|
|
108
|
+
runs: /* @__PURE__ */ new Set(),
|
|
109
|
+
hashes: /* @__PURE__ */ new Set()
|
|
110
|
+
}).get(k);
|
|
111
|
+
g.runs.add(run);
|
|
112
|
+
g.hashes.add(c.body_hash);
|
|
113
|
+
}
|
|
114
|
+
for (const [k, g] of by) if (g.runs.size === runs.length && g.hashes.size === 1) out.add(k);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
/** the GETs made in EVERY render whose answers were NOT the same bytes each time (`GET <url>`):
|
|
118
|
+
* the candidates for "almost the same answer" (net.ts `answer_diff` says how little changed) */
|
|
119
|
+
function varied_answers(calls, runs) {
|
|
120
|
+
const out = /* @__PURE__ */ new Set();
|
|
121
|
+
if (runs.length < 2) return out;
|
|
122
|
+
const by = /* @__PURE__ */ new Map();
|
|
123
|
+
for (const c of calls) {
|
|
124
|
+
if (c.method !== "GET" || c.status < 200 || c.status >= 300 || !c.body_hash) continue;
|
|
125
|
+
const run = runs.findIndex((r) => c.start >= r.start && c.start <= r.end);
|
|
126
|
+
if (run === -1) continue;
|
|
127
|
+
const k = `GET ${c.url}`;
|
|
128
|
+
const g = by.get(k) ?? by.set(k, {
|
|
129
|
+
runs: /* @__PURE__ */ new Set(),
|
|
130
|
+
hashes: /* @__PURE__ */ new Set()
|
|
131
|
+
}).get(k);
|
|
132
|
+
g.runs.add(run);
|
|
133
|
+
g.hashes.add(c.body_hash);
|
|
134
|
+
}
|
|
135
|
+
for (const [k, g] of by) if (g.runs.size === runs.length && g.hashes.size > 1) out.add(k);
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* THE SAME ANSWER, EVERY RENDER — the pattern: the calls of one render whose exact request (`stable`,
|
|
140
|
+
* from `stable_answers`) answered the same bytes in every profiled render. Each place that makes one
|
|
141
|
+
* is listed with what it waits, how big the answer is, and the line to cache at. The saving is what
|
|
142
|
+
* comes off the render when the calls are answered from a cache: each one's end moves to its start,
|
|
143
|
+
* except where another call (not cached) overlapped it and still sets the end — read off the
|
|
144
|
+
* render's await graph when it holds the calls, else each call's own wait.
|
|
145
|
+
*/
|
|
146
|
+
function find_same_answers(input) {
|
|
147
|
+
const groups = /* @__PURE__ */ new Map();
|
|
148
|
+
for (const c of input.calls) {
|
|
149
|
+
if (!c.exact || !input.stable.has(c.exact) || !c.at || c.ms < 0) continue;
|
|
150
|
+
const k = c.target + "\0" + c.at.path + ":" + c.at.line;
|
|
151
|
+
(groups.get(k) ?? groups.set(k, []).get(k)).push(c);
|
|
152
|
+
}
|
|
153
|
+
if (!groups.size) return void 0;
|
|
154
|
+
const cached = [...groups.values()].flat();
|
|
155
|
+
const on_path = (c) => {
|
|
156
|
+
const nodes = input.awaits?.nodes;
|
|
157
|
+
if (!nodes?.length || input.w0 === void 0) return c.ms;
|
|
158
|
+
const s = c.start - input.w0;
|
|
159
|
+
const e = s + c.ms;
|
|
160
|
+
let bound = s;
|
|
161
|
+
for (const n of nodes) {
|
|
162
|
+
if (n.t0 >= e || n.t1 <= s) continue;
|
|
163
|
+
if (cached.some((x) => Math.abs(x.start - input.w0 - n.t0) <= .6 && Math.abs(x.ms - (n.t1 - n.t0)) <= 1)) continue;
|
|
164
|
+
bound = Math.max(bound, Math.min(n.t1, e));
|
|
165
|
+
}
|
|
166
|
+
return Math.max(0, e - bound);
|
|
167
|
+
};
|
|
168
|
+
const sites = [];
|
|
169
|
+
let save = 0;
|
|
170
|
+
for (const g of groups.values()) {
|
|
171
|
+
const urls = new Set(g.map((c) => c.exact));
|
|
172
|
+
const wait = g.reduce((s, c) => s + c.ms, 0);
|
|
173
|
+
const off = g.reduce((s, c) => s + on_path(c), 0);
|
|
174
|
+
save += off;
|
|
175
|
+
const at = g[0].at;
|
|
176
|
+
const code = input.source?.(at.path, at.line, at.line)?.lines[0]?.trim() ?? "";
|
|
177
|
+
const asked = /* @__PURE__ */ new Map();
|
|
178
|
+
for (const c of g) if (c.outer) asked.set(c.outer.path + ":" + c.outer.line, c.outer);
|
|
179
|
+
const via = [...asked.values()].slice(0, VIA_MAX).map((o) => {
|
|
180
|
+
const oc = input.source?.(o.path, o.line, o.line)?.lines[0]?.trim() ?? "";
|
|
181
|
+
return {
|
|
182
|
+
path: o.path,
|
|
183
|
+
file: short_file(o.path),
|
|
184
|
+
line: o.line,
|
|
185
|
+
code: oc.length > 140 ? oc.slice(0, 139) + "…" : oc,
|
|
186
|
+
in_loop: false
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
sites.push({
|
|
190
|
+
path: at.path,
|
|
191
|
+
file: short_file(at.path),
|
|
192
|
+
line: at.line,
|
|
193
|
+
code: code.length > 140 ? code.slice(0, 139) + "…" : code,
|
|
194
|
+
cpu_ms: 0,
|
|
195
|
+
alloc_bytes: 0,
|
|
196
|
+
gc_ms: 0,
|
|
197
|
+
calls: g.length,
|
|
198
|
+
in_loop: false,
|
|
199
|
+
wait_ms: r2(wait),
|
|
200
|
+
target: urls.size === 1 ? g[0].exact : `${g[0].target} (${urls.size} URLs)`,
|
|
201
|
+
...g.some((c) => c.bytes !== void 0) ? { answer_bytes: g.reduce((s, c) => s + (c.bytes ?? 0), 0) } : {},
|
|
202
|
+
...g.some((c) => c.personal) ? { personal: true } : {},
|
|
203
|
+
...(() => {
|
|
204
|
+
const rules = cache_rules(g.find((c) => c.cache)?.cache);
|
|
205
|
+
return rules ? { upstream_cache: rules } : {};
|
|
206
|
+
})(),
|
|
207
|
+
...via.length ? { via } : {},
|
|
208
|
+
...ctx_field(input.source, at.path, at.line)
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
sites.sort((a, b) => (b.wait_ms ?? 0) - (a.wait_ms ?? 0));
|
|
212
|
+
const cost = sites.reduce((s, x) => s + (x.wait_ms ?? 0), 0);
|
|
213
|
+
if (save < MIN_SAVE_MS) return void 0;
|
|
214
|
+
const top = sites[0];
|
|
215
|
+
const personal = sites.filter((s) => s.personal).length;
|
|
216
|
+
const lines = new Set(sites.map((s) => s.path + ":" + s.line)).size;
|
|
217
|
+
const where = sites.length < 2 ? "" : lines === sites.length ? ` (${lines} places)` : ` (${sites.length} requests from ${lines} line${lines === 1 ? "" : "s"})`;
|
|
218
|
+
const more = sites.length < 2 ? "" : `; ${sites.length - 1} more ${lines === sites.length ? `place${sites.length === 2 ? "" : "s"}` : `request${sites.length === 2 ? "" : "s"}`} do${sites.length === 2 ? "es" : ""} the same`;
|
|
219
|
+
if (input.almost) {
|
|
220
|
+
const changes = input.almost.get((top.target ?? "").split(" (")[0]) ?? [...input.almost.values()][0];
|
|
221
|
+
return {
|
|
222
|
+
kind: "almost-same-answer",
|
|
223
|
+
title: `Almost the same answer is fetched on every render${where}`,
|
|
224
|
+
fix: `Every render asked for this and got the same answer back but for a small part that changes each time (${changes ? `\`${changes}\`` : "a timestamp or an id"}): the data itself did not change. Cache the answer across requests like any unchanging one, and fill the changing part in yourself, or ask the API to leave it out (many add a request id or a generation time). If the changing part matters to the page, keep only that part live.`,
|
|
225
|
+
example: {
|
|
226
|
+
before: "const catalog = await (await fetch(API + \"/catalog\")).json(); // { items, generatedAt }",
|
|
227
|
+
after: "let cached, at = 0;\nif (Date.now() - at > 60_000) (cached = await (await fetch(API + \"/catalog\")).json()), (at = Date.now());\nconst catalog = { ...cached, generatedAt: new Date().toISOString() };"
|
|
228
|
+
},
|
|
229
|
+
sites,
|
|
230
|
+
cost_ms: r2(cost),
|
|
231
|
+
alloc_bytes: 0,
|
|
232
|
+
save_ms: r2(save),
|
|
233
|
+
evidence: `${top.target} answered the same in every render but for${changes ? ` \`${changes}\`` : " a few bytes"}, ${r2(top.wait_ms ?? 0)} ms of waiting each time${more}`,
|
|
234
|
+
wait: true
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
kind: "same-answer",
|
|
239
|
+
title: `The same answer is fetched on every render${where}`,
|
|
240
|
+
fix: "Every render asked for this and got the same bytes back each time: nothing it depends on changed between requests. Cache it across requests (a module-level cache with a time limit, the platform’s data cache, or a CDN in front of the API), refreshed on a schedule or when the data changes. If it must be fresh, ask the API for a cheaper “has it changed” check (ETag, If-None-Match) instead of the whole answer." + (personal ? ` ${personal === sites.length ? "These requests" : `${personal} of these requests`} carried who is asking (a cookie or an authorization header), and the recording rendered as one user: cache ${personal === sites.length ? "them" : "those"} per user, keyed by the session, never for everyone.` : ""),
|
|
241
|
+
example: {
|
|
242
|
+
before: "export async function load({ fetch }) {\n const config = await (await fetch(API + \"/config\")).json();",
|
|
243
|
+
after: "let config_at = 0, config;\nexport async function load({ fetch }) {\n if (Date.now() - config_at > 60_000) (config = await (await fetch(API + \"/config\")).json()), (config_at = Date.now());"
|
|
244
|
+
},
|
|
245
|
+
sites,
|
|
246
|
+
cost_ms: r2(cost),
|
|
247
|
+
alloc_bytes: 0,
|
|
248
|
+
save_ms: r2(save),
|
|
249
|
+
evidence: `${top.target} answered the same ${top.answer_bytes !== void 0 ? fmt_mb(top.answer_bytes) + " " : ""}in every render${top.personal ? " (as the one user this recording rendered as)" : ""}, ${r2(top.wait_ms ?? 0)} ms of waiting each time${more}`,
|
|
250
|
+
wait: true
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** How many caller lines a wrapper site lists: enough to see the loop, never a wall */
|
|
254
|
+
const VIA_MAX = 4;
|
|
255
|
+
/** The lines that call `f` in its callers (its heaviest stacks' nearest app frame), found by name:
|
|
256
|
+
* its own name, what its module wraps it as (`const x = instrument(f)`), and what the caller's
|
|
257
|
+
* module imports those as. Each says whether that call runs inside a loop. */
|
|
258
|
+
function call_sites_of(f, by_loc, source) {
|
|
259
|
+
if (f.name.startsWith("(")) return [];
|
|
260
|
+
const own = [f.name, ...wrapper_aliases(source, f.path).get(f.name) ?? []];
|
|
261
|
+
const out = [];
|
|
262
|
+
const seen = /* @__PURE__ */ new Set();
|
|
263
|
+
for (const st of f.stacks ?? []) {
|
|
264
|
+
const fr = st.frames.find((x) => x.c === "app" || x.c === "component");
|
|
265
|
+
if (!fr?.f || seen.has(fr.f)) continue;
|
|
266
|
+
seen.add(fr.f);
|
|
267
|
+
const caller = by_loc.get(fr.f);
|
|
268
|
+
if (!caller || !caller.path || caller.line <= 0) continue;
|
|
269
|
+
let names = own;
|
|
270
|
+
if (caller.path !== f.path) {
|
|
271
|
+
const imp = import_aliases(source, caller.path);
|
|
272
|
+
names = [...own, ...own.flatMap((n) => imp.get(n) ?? [])];
|
|
273
|
+
}
|
|
274
|
+
const body = function_body(source, caller.path, caller.line);
|
|
275
|
+
if (!body) continue;
|
|
276
|
+
for (let i = 0; i < body.lines.length && out.length < VIA_MAX; i++) {
|
|
277
|
+
if (!calls_name(body.lines[i], names)) continue;
|
|
278
|
+
const line = body.start + i;
|
|
279
|
+
const code = body.lines[i].trim();
|
|
280
|
+
out.push({
|
|
281
|
+
path: caller.path,
|
|
282
|
+
file: caller.url,
|
|
283
|
+
line,
|
|
284
|
+
code: code.length > 140 ? code.slice(0, 139) + "…" : code,
|
|
285
|
+
fn: caller.key,
|
|
286
|
+
in_loop: loop_from(body.lines.slice(0, i + 1), caller.path.endsWith(".svelte")) >= 0
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
/** `.name(` or `?.name(` in the tokens, at index `from` or later */
|
|
293
|
+
function calls_method(t, names, from = 0) {
|
|
294
|
+
for (let k = Math.max(0, from); k + 2 < t.length; k++) if ((t[k] === "." || t[k] === "?.") && names.has(t[k + 1]) && t[k + 2] === "(") return t[k + 1];
|
|
295
|
+
}
|
|
296
|
+
/** `new Name(` (or `new Intl.Name(` when `ns` is given) */
|
|
297
|
+
function constructs(t, names, ns) {
|
|
298
|
+
for (let k = 0; k + 1 < t.length; k++) {
|
|
299
|
+
if (t[k] !== "new") continue;
|
|
300
|
+
if (ns && t[k + 1] === ns && t[k + 2] === "." && names.has(t[k + 3])) return t[k + 3];
|
|
301
|
+
if (!ns && names.has(t[k + 1])) return t[k + 1];
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/** a plain call `name(` not preceded by a `.` */
|
|
305
|
+
function calls_fn(t, names) {
|
|
306
|
+
for (let k = 0; k + 1 < t.length; k++) if (names.has(t[k]) && t[k + 1] === "(" && t[k - 1] !== "." && t[k - 1] !== "?." && t[k - 1] !== "function") return t[k];
|
|
307
|
+
}
|
|
308
|
+
/** `Array.from({ length: 60 }` — an array sized by a number literal */
|
|
309
|
+
function literal_length(t) {
|
|
310
|
+
for (let k = 0; k + 7 < t.length; k++) if (t[k] === "Array" && t[k + 1] === "." && t[k + 2] === "from" && t[k + 3] === "(" && t[k + 4] === "{" && t[k + 5] === "length" && t[k + 6] === ":" && t[k + 7] === "0" && t[k + 8] === "}") return self_contained(t, k + 9);
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
/** names a fixed table's builder may use without depending on the call: builtins */
|
|
314
|
+
const GLOBALS = /* @__PURE__ */ new Set([
|
|
315
|
+
"Math",
|
|
316
|
+
"String",
|
|
317
|
+
"Number",
|
|
318
|
+
"JSON",
|
|
319
|
+
"Object",
|
|
320
|
+
"Array",
|
|
321
|
+
"Boolean",
|
|
322
|
+
"Symbol",
|
|
323
|
+
"undefined",
|
|
324
|
+
"null",
|
|
325
|
+
"true",
|
|
326
|
+
"false",
|
|
327
|
+
"length"
|
|
328
|
+
]);
|
|
329
|
+
/** The callback that follows `from` (at or after token `k`) uses only its own parameters and
|
|
330
|
+
* builtins: nothing from the call it sits in (`(_, k) => \`t${i + k}\`` reads the outer `i`, so
|
|
331
|
+
* the table is NOT fixed). Property names (`.x`) and object keys (`x:`) are not reads. */
|
|
332
|
+
function self_contained(t, k) {
|
|
333
|
+
const arrow = t.indexOf("=>", k);
|
|
334
|
+
if (arrow === -1) return true;
|
|
335
|
+
const params = /* @__PURE__ */ new Set();
|
|
336
|
+
for (let j = k; j < arrow; j++) if (is_name(t[j])) params.add(t[j]);
|
|
337
|
+
let depth = 0;
|
|
338
|
+
for (let j = arrow + 1; j < t.length; j++) {
|
|
339
|
+
const x = t[j];
|
|
340
|
+
if (x === "(" || x === "{" || x === "[") depth++;
|
|
341
|
+
else if (x === ")" || x === "}" || x === "]") depth--;
|
|
342
|
+
if (!is_name(x) || params.has(x) || GLOBALS.has(x)) continue;
|
|
343
|
+
if (t[j - 1] === "." || t[j - 1] === "?." || t[j + 1] === ":") continue;
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
return depth <= 0;
|
|
347
|
+
}
|
|
348
|
+
const is_name = (x) => !!x && x !== "\"\"" && x !== "0" && is_word_start(x.charCodeAt(0));
|
|
349
|
+
const is_word_start = (c) => c >= 65 && c <= 90 || c >= 97 && c <= 122 || c === 36 || c === 95 || c > 127;
|
|
350
|
+
/** the identifier a method is called on: `html` in `html.replace(` (from token `from` on) */
|
|
351
|
+
function receiver_of(t, method, from) {
|
|
352
|
+
for (let k = Math.max(1, from); k + 1 < t.length; k++) if (t[k] === method && (t[k - 1] === "." || t[k - 1] === "?.")) return t[k - 2];
|
|
353
|
+
}
|
|
354
|
+
/** `name = …` or `name += …` on any of the token lines */
|
|
355
|
+
function reassigned(name, lines) {
|
|
356
|
+
if (!name) return false;
|
|
357
|
+
for (const t of lines) for (let k = 0; k + 1 < t.length; k++) if (t[k] === name && (t[k + 1] === "=" || t[k + 1] === "+=") && t[k - 1] !== ".") return true;
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
/** `A . b` in sequence */
|
|
361
|
+
/** the formatter on this line is built only when a cache MISSES — the fix itself, the shape this
|
|
362
|
+
* profiler's own written change has: `if (!f) cache.set(key, (f = new Intl.X(…)))`, `f ??= new
|
|
363
|
+
* Intl.X(…)`, `cache.get(k) ?? cache.set(k, new Intl.X(…))`. Its time is the first build per key,
|
|
364
|
+
* not a build per call */
|
|
365
|
+
function built_on_miss(t) {
|
|
366
|
+
const k = t.findIndex((x, i) => x === "new" && t[i + 1] === "Intl");
|
|
367
|
+
if (k === -1) return false;
|
|
368
|
+
const before = t.slice(0, k);
|
|
369
|
+
if (before.includes("??=") || before.includes("||=") || before.includes("??")) return true;
|
|
370
|
+
if (before[0] === "if" && before[1] === "(" && before[2] === "!") return true;
|
|
371
|
+
let depth = 0;
|
|
372
|
+
for (let i = k - 1; i >= 0; i--) if (t[i] === ")") depth++;
|
|
373
|
+
else if (t[i] === "(") {
|
|
374
|
+
if (depth === 0 && t[i - 1] === "set" && t[i - 2] === ".") return true;
|
|
375
|
+
if (depth > 0) depth--;
|
|
376
|
+
}
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
/** calls whose answer is text: added with `+=`, they grow a string */
|
|
380
|
+
const TEXT_CALLS = /* @__PURE__ */ new Set([
|
|
381
|
+
"String",
|
|
382
|
+
"join",
|
|
383
|
+
"toString",
|
|
384
|
+
"toFixed",
|
|
385
|
+
"padStart",
|
|
386
|
+
"padEnd",
|
|
387
|
+
"repeat",
|
|
388
|
+
"stringify",
|
|
389
|
+
"slice",
|
|
390
|
+
"substring",
|
|
391
|
+
"trim",
|
|
392
|
+
"replace",
|
|
393
|
+
"replaceAll",
|
|
394
|
+
"toUpperCase",
|
|
395
|
+
"toLowerCase",
|
|
396
|
+
"toISOString",
|
|
397
|
+
"esc",
|
|
398
|
+
"escape"
|
|
399
|
+
]);
|
|
400
|
+
/** a `+=` at or after `from` that adds TEXT: a string or template literal, or a call answering
|
|
401
|
+
* text, at the top level of what is added (`out += '<li>' + x`). Not a sum of numbers — `s +=
|
|
402
|
+
* (JSON.stringify(x).length) % 7` adds a number, though a string call sits inside it */
|
|
403
|
+
function grows_text(t, from, text = /* @__PURE__ */ new Set()) {
|
|
404
|
+
for (let k = t.indexOf("+=", Math.max(0, from)); k !== -1; k = t.indexOf("+=", k + 1)) {
|
|
405
|
+
if (text.has(t[k - 1]) && t[k - 2] !== ".") return true;
|
|
406
|
+
let d = 0;
|
|
407
|
+
for (let j = k + 1; j < t.length && t[j] !== ";"; j++) {
|
|
408
|
+
const x = t[j];
|
|
409
|
+
if (x === "(" || x === "[" || x === "{") {
|
|
410
|
+
d++;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (x === ")" || x === "]" || x === "}") {
|
|
414
|
+
if (--d < 0) break;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (d !== 0) continue;
|
|
418
|
+
if (x === "\"\"") return true;
|
|
419
|
+
if (TEXT_CALLS.has(x) && t[j + 1] === "(") {
|
|
420
|
+
let e = j + 1;
|
|
421
|
+
for (let dd = 0; e < t.length; e++) if (t[e] === "(") dd++;
|
|
422
|
+
else if (t[e] === ")" && --dd === 0) break;
|
|
423
|
+
if (t[e + 1] !== ".") return true;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
function has_member(t, obj, prop) {
|
|
430
|
+
for (let k = 0; k + 2 < t.length; k++) if (t[k] === obj && t[k + 1] === "." && t[k + 2] === prop) return true;
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
const LOOP_METHODS = /* @__PURE__ */ new Set([
|
|
434
|
+
"forEach",
|
|
435
|
+
"map",
|
|
436
|
+
"flatMap",
|
|
437
|
+
"reduce",
|
|
438
|
+
"reduceRight",
|
|
439
|
+
"filter",
|
|
440
|
+
"some",
|
|
441
|
+
"every",
|
|
442
|
+
"find",
|
|
443
|
+
"findIndex"
|
|
444
|
+
]);
|
|
445
|
+
/** Where the LAST line of `lines` (a function's body, from its first line) runs in a loop: the
|
|
446
|
+
* token index on that line from which code is inside one. 0 when the whole line is (an enclosing
|
|
447
|
+
* loop body), the index after a loop header on the line itself (`for (…) x = f(x)`, `xs.map(`),
|
|
448
|
+
* -1 when none of it is. Tracks brace blocks: a block opened after a loop header (`for`, `while`,
|
|
449
|
+
* `do`, a `.map(` / `.forEach(` callback) is a loop body. A Svelte file counts `{#each}` too. */
|
|
450
|
+
function loop_from(lines, svelte = false) {
|
|
451
|
+
const blocks = [];
|
|
452
|
+
let pending = false;
|
|
453
|
+
let paren = 0;
|
|
454
|
+
let each = 0;
|
|
455
|
+
for (let li = 0; li < lines.length; li++) {
|
|
456
|
+
const t = tokens(lines[li]);
|
|
457
|
+
const last = li === lines.length - 1;
|
|
458
|
+
if (last && (pending || each > 0 || blocks.includes(true))) return 0;
|
|
459
|
+
for (let k = 0; k < t.length; k++) {
|
|
460
|
+
const x = t[k];
|
|
461
|
+
if (svelte && x === "{" && (t[k + 1] === "#" || t[k + 1] === "/") && t[k + 2] === "each") {
|
|
462
|
+
each = Math.max(0, each + (t[k + 1] === "#" ? 1 : -1));
|
|
463
|
+
while (k < t.length && t[k] !== "}") k++;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (x === "for" || x === "while" || x === "do") {
|
|
467
|
+
if (last) return k + 1;
|
|
468
|
+
pending = true;
|
|
469
|
+
} else if ((x === "." || x === "?.") && LOOP_METHODS.has(t[k + 1]) && t[k + 2] === "(") {
|
|
470
|
+
if (last) return k + 3;
|
|
471
|
+
pending = true;
|
|
472
|
+
} else if (x === "(") paren++;
|
|
473
|
+
else if (x === ")") paren = Math.max(0, paren - 1);
|
|
474
|
+
else if (x === "{") {
|
|
475
|
+
const outer = blocks[blocks.length - 1] === true;
|
|
476
|
+
const prev = t[k - 1];
|
|
477
|
+
if (prev === ")" || prev === "=>" || prev === "else" || prev === "do" || prev === "try" || prev === "finally" || k === 0 && pending) {
|
|
478
|
+
blocks.push(pending || outer);
|
|
479
|
+
pending = false;
|
|
480
|
+
} else blocks.push(outer);
|
|
481
|
+
} else if (x === "}") blocks.pop();
|
|
482
|
+
else if (x === ";" && paren === 0) pending = false;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return -1;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* ONE LINE, CALLS ONE AFTER ANOTHER: independent only when the line runs in a loop (`for (…) await
|
|
489
|
+
* fetch(…)`, a loop body a few lines up). Otherwise the line nests them (`f(await g(await h()))`:
|
|
490
|
+
* a build without maps folds single-use constants into one such line) or its code is not the
|
|
491
|
+
* code that ran: never a missed `Promise.all`.
|
|
492
|
+
*/
|
|
493
|
+
function loops_here(source, path, line) {
|
|
494
|
+
const w = source(path, Math.max(1, line - 20), line);
|
|
495
|
+
if (!w?.lines.length) return false;
|
|
496
|
+
return loop_from(w.lines) >= 0;
|
|
497
|
+
}
|
|
498
|
+
/** Does any of the last line of `lines` run inside a loop? (`loop_from` ≥ 0) */
|
|
499
|
+
const in_loop = (lines, svelte = false) => loop_from(lines, svelte) >= 0;
|
|
500
|
+
/** the line sits in a Svelte `<script module>` (runs once per module, not per render) */
|
|
501
|
+
function in_module_script(lines) {
|
|
502
|
+
let inside = false;
|
|
503
|
+
for (const l of lines) {
|
|
504
|
+
const s = l.trimStart();
|
|
505
|
+
if (s.startsWith("<script")) inside = s.slice(0, s.indexOf(">") + 1 || void 0).includes("module");
|
|
506
|
+
if (s.startsWith("<\/script>")) inside = false;
|
|
507
|
+
}
|
|
508
|
+
return inside;
|
|
509
|
+
}
|
|
510
|
+
const INTL = /* @__PURE__ */ new Set([
|
|
511
|
+
"NumberFormat",
|
|
512
|
+
"DateTimeFormat",
|
|
513
|
+
"Collator",
|
|
514
|
+
"PluralRules",
|
|
515
|
+
"RelativeTimeFormat",
|
|
516
|
+
"ListFormat",
|
|
517
|
+
"Segmenter",
|
|
518
|
+
"DisplayNames"
|
|
519
|
+
]);
|
|
520
|
+
const LOCALE_METHODS = /* @__PURE__ */ new Set([
|
|
521
|
+
"toLocaleString",
|
|
522
|
+
"toLocaleDateString",
|
|
523
|
+
"toLocaleTimeString",
|
|
524
|
+
"localeCompare"
|
|
525
|
+
]);
|
|
526
|
+
const RESCAN = /* @__PURE__ */ new Set([
|
|
527
|
+
"replace",
|
|
528
|
+
"replaceAll",
|
|
529
|
+
"split",
|
|
530
|
+
"indexOf",
|
|
531
|
+
"lastIndexOf",
|
|
532
|
+
"includes",
|
|
533
|
+
"match",
|
|
534
|
+
"matchAll",
|
|
535
|
+
"search",
|
|
536
|
+
"slice",
|
|
537
|
+
"substring",
|
|
538
|
+
"concat",
|
|
539
|
+
"trim",
|
|
540
|
+
"toLowerCase",
|
|
541
|
+
"toUpperCase"
|
|
542
|
+
]);
|
|
543
|
+
const STRING_REBUILD = /* @__PURE__ */ new Set([
|
|
544
|
+
"replace",
|
|
545
|
+
"replaceAll",
|
|
546
|
+
"slice",
|
|
547
|
+
"substring",
|
|
548
|
+
"concat"
|
|
549
|
+
]);
|
|
550
|
+
const LOOKUP = /* @__PURE__ */ new Set([
|
|
551
|
+
"find",
|
|
552
|
+
"findIndex",
|
|
553
|
+
"findLast",
|
|
554
|
+
"filter",
|
|
555
|
+
"indexOf",
|
|
556
|
+
"includes",
|
|
557
|
+
"some"
|
|
558
|
+
]);
|
|
559
|
+
const COLLECTIONS = /* @__PURE__ */ new Set([
|
|
560
|
+
"Map",
|
|
561
|
+
"Set",
|
|
562
|
+
"WeakMap"
|
|
563
|
+
]);
|
|
564
|
+
const SORT = /* @__PURE__ */ new Set(["sort", "toSorted"]);
|
|
565
|
+
const SYNC_IO = /* @__PURE__ */ new Set([
|
|
566
|
+
"readFileSync",
|
|
567
|
+
"writeFileSync",
|
|
568
|
+
"existsSync",
|
|
569
|
+
"statSync",
|
|
570
|
+
"lstatSync",
|
|
571
|
+
"readdirSync",
|
|
572
|
+
"execSync",
|
|
573
|
+
"spawnSync",
|
|
574
|
+
"accessSync",
|
|
575
|
+
"realpathSync"
|
|
576
|
+
]);
|
|
577
|
+
const REGEXP = /* @__PURE__ */ new Set(["RegExp"]);
|
|
578
|
+
const DATE = /* @__PURE__ */ new Set(["Date"]);
|
|
579
|
+
const CLONE = /* @__PURE__ */ new Set(["structuredClone", "cloneDeep"]);
|
|
580
|
+
const YIELDS = /* @__PURE__ */ new Set([
|
|
581
|
+
"setImmediate",
|
|
582
|
+
"setTimeout",
|
|
583
|
+
"queueMicrotask"
|
|
584
|
+
]);
|
|
585
|
+
const MB = 1048576;
|
|
586
|
+
/** the smallest estimated saving a pattern must offer to be reported, ms */
|
|
587
|
+
const MIN_SAVE_MS = 1;
|
|
588
|
+
const places = (n) => n === 1 ? "" : ` (${n} places)`;
|
|
589
|
+
/** one run of a pattern this slow is worth a card on its own (a cheap one runs in about a µs) */
|
|
590
|
+
const SLOW_REGEX_RUN_MS = .02;
|
|
591
|
+
/** the pattern a site's line ran, from its library callees (`RegExp: ^(\w+\s?)*$ (Node)`) */
|
|
592
|
+
const regex_of = (s) => {
|
|
593
|
+
const lib = s.libs?.find((x) => x.startsWith(REGEXP_FRAME));
|
|
594
|
+
if (!lib) return void 0;
|
|
595
|
+
const src = lib.slice(REGEXP_FRAME.length);
|
|
596
|
+
return src.endsWith(" (Node)") ? src.slice(0, -7) : src;
|
|
597
|
+
};
|
|
598
|
+
/** the names in a function body that hold a formatter built right there: `const rules = new Intl.PluralRules(…)` */
|
|
599
|
+
function intl_locals(body) {
|
|
600
|
+
const out = /* @__PURE__ */ new Set();
|
|
601
|
+
for (const line of body) {
|
|
602
|
+
const t = tokens(line);
|
|
603
|
+
if (built_on_miss(t)) continue;
|
|
604
|
+
for (let k = 0; k + 4 < t.length; k++) if (t[k + 1] === "=" && t[k + 2] === "new" && t[k + 3] === "Intl") out.add(t[k]);
|
|
605
|
+
}
|
|
606
|
+
return out;
|
|
607
|
+
}
|
|
608
|
+
/** the names in a function body that hold a RegExp built right there (`const re = new RegExp(…)`),
|
|
609
|
+
* each with its line: `first` is the body's first line number */
|
|
610
|
+
function regexp_locals(body, first) {
|
|
611
|
+
const out = /* @__PURE__ */ new Map();
|
|
612
|
+
for (let i = 0; i < body.length; i++) {
|
|
613
|
+
if (!body[i].includes("RegExp")) continue;
|
|
614
|
+
const t = tokens(body[i]);
|
|
615
|
+
for (let k = 0; k + 3 < t.length; k++) if (t[k + 1] === "=" && (t[k + 2] === "new" && t[k + 3] === "RegExp" || t[k + 2] === "RegExp" && t[k + 3] === "(")) out.set(t[k], {
|
|
616
|
+
line: first + i,
|
|
617
|
+
code: body[i].trim()
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
return out;
|
|
621
|
+
}
|
|
622
|
+
/** (a check, whose own ticks are the build's: a `replace` or a `split` with it makes new text, work
|
|
623
|
+
* that stays after the fix — its line is not moved) */
|
|
624
|
+
const REGEXP_USE = /* @__PURE__ */ new Set(["test", "exec"]);
|
|
625
|
+
/** the RegExp the same function built that this line checks with: `re.test(s)`, `re.exec(s)` — V8
|
|
626
|
+
* often charges a `new RegExp` to its first use, the line below it */
|
|
627
|
+
function used_local_regexp(t, locals) {
|
|
628
|
+
if (!locals.size) return void 0;
|
|
629
|
+
for (let k = 0; k + 3 < t.length; k++) {
|
|
630
|
+
const at = locals.get(t[k]);
|
|
631
|
+
if (at && t[k + 1] === "." && REGEXP_USE.has(t[k + 2]) && t[k + 3] === "(") return at;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/** the names in a function body declared as text: `let out = ''`, `` let html = `<ul>` ``, `let s: string` */
|
|
635
|
+
function text_locals(body) {
|
|
636
|
+
const out = /* @__PURE__ */ new Set();
|
|
637
|
+
for (const line of body) {
|
|
638
|
+
const t = tokens(line);
|
|
639
|
+
for (let k = 0; k + 2 < t.length; k++) {
|
|
640
|
+
if (t[k] !== "let" && t[k] !== "var") continue;
|
|
641
|
+
if (t[k + 2] === "=" && t[k + 3] === "\"\"") out.add(t[k + 1]);
|
|
642
|
+
else if (t[k + 2] === ":" && t[k + 3] === "string") out.add(t[k + 1]);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return out;
|
|
646
|
+
}
|
|
647
|
+
const INTL_USE = /* @__PURE__ */ new Set([
|
|
648
|
+
"format",
|
|
649
|
+
"formatToParts",
|
|
650
|
+
"formatRange",
|
|
651
|
+
"select",
|
|
652
|
+
"selectRange",
|
|
653
|
+
"compare",
|
|
654
|
+
"of",
|
|
655
|
+
"segment"
|
|
656
|
+
]);
|
|
657
|
+
/** A locale method given a locale or options (`n.toLocaleString('de')`, `a.localeCompare(b, 'de', …)`):
|
|
658
|
+
* that builds a formatter inside on every call. With no locale (`a.localeCompare(b)`,
|
|
659
|
+
* `n.toLocaleString()`) V8 reuses its cached default one, which is cheap. */
|
|
660
|
+
function locale_call_with_args(t) {
|
|
661
|
+
for (let k = 0; k + 2 < t.length; k++) {
|
|
662
|
+
if (t[k] !== "." && t[k] !== "?." || !LOCALE_METHODS.has(t[k + 1]) || t[k + 2] !== "(") continue;
|
|
663
|
+
const needs = t[k + 1] === "localeCompare" ? 2 : 1;
|
|
664
|
+
let depth = 0;
|
|
665
|
+
let args = 0;
|
|
666
|
+
let any = false;
|
|
667
|
+
for (let j = k + 2; j < t.length; j++) {
|
|
668
|
+
const x = t[j];
|
|
669
|
+
if (x === "(" || x === "[" || x === "{") depth++;
|
|
670
|
+
else if (x === ")" || x === "]" || x === "}") {
|
|
671
|
+
if (--depth === 0) break;
|
|
672
|
+
} else if (depth === 1) {
|
|
673
|
+
if (x === ",") args++;
|
|
674
|
+
else if (!any) {
|
|
675
|
+
any = true;
|
|
676
|
+
args++;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (args >= needs) return true;
|
|
681
|
+
}
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
const DATE_FORMAT_CALLS = /* @__PURE__ */ new Set([
|
|
685
|
+
"format",
|
|
686
|
+
"formatToParts",
|
|
687
|
+
"formatRange",
|
|
688
|
+
"toLocaleDateString",
|
|
689
|
+
"toLocaleString",
|
|
690
|
+
"toLocaleTimeString"
|
|
691
|
+
]);
|
|
692
|
+
/** the line formats a value for display: `fmt.format(d)`, `d.toLocaleDateString(…)` */
|
|
693
|
+
function formats_date(t) {
|
|
694
|
+
for (let k = 0; k + 2 < t.length; k++) if ((t[k] === "." || t[k] === "?.") && DATE_FORMAT_CALLS.has(t[k + 1]) && t[k + 2] === "(") return true;
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
/** the line calls a method of a formatter the same function built: `rules.select(n)` */
|
|
698
|
+
function uses_local_intl(t, locals) {
|
|
699
|
+
if (!locals.size) return false;
|
|
700
|
+
for (let k = 0; k + 3 < t.length; k++) if (locals.has(t[k]) && t[k + 1] === "." && INTL_USE.has(t[k + 2]) && t[k + 3] === "(") return true;
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
703
|
+
const RULES = [
|
|
704
|
+
{
|
|
705
|
+
kind: "formatter-per-call",
|
|
706
|
+
saves: .9,
|
|
707
|
+
title: (n) => `A new number or date formatter is built on every call${places(n)}`,
|
|
708
|
+
fix: "Building an Intl formatter is slow; using one is fast. Make it once per locale and reuse it, for example in a Map keyed by locale and options.",
|
|
709
|
+
example: {
|
|
710
|
+
before: "return new Intl.NumberFormat(locale, opts).format(n);",
|
|
711
|
+
after: "const fmts = new Map();\nconst fmt = (locale) => fmts.get(locale) ?? fmts.set(locale, new Intl.NumberFormat(locale, opts)).get(locale);\nreturn fmt(locale).format(n);"
|
|
712
|
+
},
|
|
713
|
+
match: (t, c) => c.repeated && (!!constructs(t, INTL, "Intl") && !built_on_miss(t) || locale_call_with_args(t) || uses_local_intl(t, c.intl_locals))
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
kind: "scan-for-key",
|
|
717
|
+
saves: .9,
|
|
718
|
+
title: (n) => `A key is found by walking every entry${places(n)}`,
|
|
719
|
+
fix: "Walking Object.entries / Object.keys to find one key reads the whole table and builds a new array every time. Read it directly (table[key]), or keep the table in a Map.",
|
|
720
|
+
example: {
|
|
721
|
+
before: "for (const [k, v] of Object.entries(table)) if (k === key) return v;",
|
|
722
|
+
after: "return Object.hasOwn(table, key) ? table[key] : undefined;"
|
|
723
|
+
},
|
|
724
|
+
match: (t, c) => {
|
|
725
|
+
if (c.cpu_ms < .5 && c.alloc_bytes < MB) return false;
|
|
726
|
+
if (!(has_member(t, "Object", "entries") || has_member(t, "Object", "keys"))) return false;
|
|
727
|
+
return t.includes("for") && t.includes("==") || !!calls_method(t, LOOKUP);
|
|
728
|
+
}
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
kind: "regexp-per-call",
|
|
732
|
+
saves: .7,
|
|
733
|
+
title: (n) => `A regular expression is built on every call${places(n)}`,
|
|
734
|
+
fix: "Each call builds a new RegExp (and often escapes its text first). V8 reuses the compiled pattern when the text repeats, so each call is cheap, but it runs once per item; the time measured here is what building it once saves. A fixed pattern: build it outside the function. One per request, like a search term: build it once where the term arrives and pass the RegExp in.",
|
|
735
|
+
example: {
|
|
736
|
+
before: "const hits = items.filter((p) => new RegExp(escape(term), \"i\").test(p.name));",
|
|
737
|
+
after: "const re = new RegExp(escape(term), \"i\"); // once per request\nconst hits = items.filter((p) => re.test(p.name));"
|
|
738
|
+
},
|
|
739
|
+
match: (t, c) => c.repeated && (!!constructs(t, REGEXP) || !!used_local_regexp(t, c.regexp_locals))
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
kind: "slow-regex",
|
|
743
|
+
saves: .8,
|
|
744
|
+
title: (n, sites) => sites.some((s) => backtracks(regex_of(s)) || regex_literals(s.code).some(backtracks)) ? `A regular expression can backtrack, and runs slowly${places(n)}` : `A regular expression takes long to run${places(n)}`,
|
|
745
|
+
fix: "The time is inside the pattern itself. A repeat inside another repeat (`(\\w+\\s?)*`, `(a|aa)+`) makes the engine try every way to split the text before it gives up, and that grows fast with the text. Rewrite it so each character can match only one way (`\\w+(?:\\s\\w+)*`), anchor it, or cap how long the text may be. Often a plain check (`includes`, `startsWith`, one `split`) is all the pattern was for.",
|
|
746
|
+
example: {
|
|
747
|
+
before: "const ok = /^(\\w+\\s?)*$/.test(title);",
|
|
748
|
+
after: "const ok = /^\\w+(?:\\s\\w+)*$/.test(title);"
|
|
749
|
+
},
|
|
750
|
+
match: (_t, c) => c.literal_backtracks && c.cpu_ms >= .5 || c.regex_ms >= .5 && c.regex_ms >= c.cpu_ms * .3 && (c.regex_backtracks || !c.loop && (c.per_call_ms === void 0 || c.per_call_ms * (c.regex_ms / c.cpu_ms) >= SLOW_REGEX_RUN_MS))
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
kind: "rescan-in-loop",
|
|
754
|
+
saves: .9,
|
|
755
|
+
title: (n) => `The whole string is scanned and copied once per item${places(n)}`,
|
|
756
|
+
fix: "Each pass reads the full string and, for a replace, builds a new copy of it. Do one pass: find every spot first, then build the result once (collect the parts in an array and join them, or use one replace with a callback that looks each match up in a Map).",
|
|
757
|
+
example: {
|
|
758
|
+
before: "for (const { tag, el } of items) html = html.replace(tag, el);",
|
|
759
|
+
after: "const by_tag = new Map(items.map((i) => [i.tag, i.el]));\nhtml = html.replace(TAGS, (m) => by_tag.get(m) ?? m);"
|
|
760
|
+
},
|
|
761
|
+
match: (t, c) => {
|
|
762
|
+
if (!c.loop) return false;
|
|
763
|
+
const m = calls_method(t, RESCAN, c.from);
|
|
764
|
+
if (!m) return false;
|
|
765
|
+
const cb = t.indexOf("=>", c.from);
|
|
766
|
+
if (!STRING_REBUILD.has(m) && cb !== -1 && calls_method(t, LOOKUP, cb)) return false;
|
|
767
|
+
return STRING_REBUILD.has(m) && c.alloc_bytes >= MB && (c.per_call_bytes !== void 0 ? c.per_call_bytes >= 16384 : c.alloc_per_render >= 4 * MB) || c.cpu_ms >= 1 && reassigned(receiver_of(t, m, c.from), [t, ...c.after.map(tokens)]);
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
{
|
|
771
|
+
kind: "lookup-in-loop",
|
|
772
|
+
saves: .75,
|
|
773
|
+
title: (n) => `A list is searched from the start for every item${places(n)}`,
|
|
774
|
+
fix: "A find / filter / includes inside a loop reads the whole list each time, so the work grows with the square of the list. Build a Map or Set once before the loop and look items up by key.",
|
|
775
|
+
example: {
|
|
776
|
+
before: "for (const p of products) p.brand = brands.find((b) => b.id === p.brand_id);",
|
|
777
|
+
after: "const by_id = new Map(brands.map((b) => [b.id, b]));\nfor (const p of products) p.brand = by_id.get(p.brand_id);"
|
|
778
|
+
},
|
|
779
|
+
match: (t, c) => c.loop && c.cpu_ms >= .5 && !!calls_method(t, LOOKUP, c.from)
|
|
780
|
+
},
|
|
781
|
+
{
|
|
782
|
+
kind: "table-per-call",
|
|
783
|
+
saves: .9,
|
|
784
|
+
title: (n) => `The same fixed table is rebuilt on every call${places(n)}`,
|
|
785
|
+
fix: "This line builds a lookup table, list or set from fixed data each time the function (or component) runs. Move it outside the function so it is built once. In a Svelte component, put it in <script module>.",
|
|
786
|
+
match: (t, c) => {
|
|
787
|
+
if (!c.repeated || c.module_scope) return false;
|
|
788
|
+
if (c.alloc_bytes < 262144 && c.cpu_ms < .5) return false;
|
|
789
|
+
if (literal_length(t) || has_member(t, "Object", "fromEntries") && t[t.indexOf("fromEntries") + 2] === "[") return true;
|
|
790
|
+
const col = constructs(t, COLLECTIONS);
|
|
791
|
+
if (col) {
|
|
792
|
+
const k = t.indexOf(col);
|
|
793
|
+
return t[k + 1] === "(" && t[k + 2] === "[";
|
|
794
|
+
}
|
|
795
|
+
for (let k = 0; k + 3 < t.length; k++) if (t[k] === "\"\"" && t[k + 1] === "." && t[k + 2] === "split") return true;
|
|
796
|
+
return false;
|
|
797
|
+
}
|
|
798
|
+
},
|
|
799
|
+
{
|
|
800
|
+
kind: "deep-copy",
|
|
801
|
+
saves: .8,
|
|
802
|
+
title: (n) => `A whole object is deep-copied${places(n)}`,
|
|
803
|
+
fix: "JSON.parse(JSON.stringify(x)) and structuredClone copy every field, every time. Copy only what you change, or avoid the copy by not mutating the original.",
|
|
804
|
+
match: (t, c) => (c.cpu_ms >= .5 || c.alloc_bytes >= MB) && (has_member(t, "JSON", "parse") && has_member(t, "JSON", "stringify") || !!calls_fn(t, CLONE) || !!calls_method(t, CLONE))
|
|
805
|
+
},
|
|
806
|
+
{
|
|
807
|
+
kind: "spread-accumulate",
|
|
808
|
+
saves: .8,
|
|
809
|
+
title: (n) => `A list or object is copied on each step of a loop${places(n)}`,
|
|
810
|
+
fix: "Spreading the accumulator ([...acc, x] or {...acc, k: v}) copies everything built so far at every step, so the work grows with the square of the size. Push into one array, or set keys on one object.",
|
|
811
|
+
example: {
|
|
812
|
+
before: "items.reduce((acc, i) => ({ ...acc, [i.id]: i }), {})",
|
|
813
|
+
after: "const acc = {};\nfor (const i of items) acc[i.id] = i;"
|
|
814
|
+
},
|
|
815
|
+
match: (t, c) => {
|
|
816
|
+
if (!(c.loop || t.includes("reduce"))) return false;
|
|
817
|
+
if (c.alloc_bytes < 262144 && c.cpu_ms < .5) return false;
|
|
818
|
+
const last = t[t.length - 1];
|
|
819
|
+
const all = (last === "(" || last === "{" || last === "[") && c.after[0] !== void 0 ? [...t, ...tokens(c.after[0])] : t;
|
|
820
|
+
const acc = /* @__PURE__ */ new Set();
|
|
821
|
+
for (let k = 0; k + 3 < all.length; k++) if (all[k] === "reduce" && all[k + 1] === "(" && all[k + 2] === "(" && is_word_start((all[k + 3] ?? "").charCodeAt(0))) acc.add(all[k + 3]);
|
|
822
|
+
for (let k = 1; k < all.length; k++) if ((all[k] === "=" || all[k] === "+=") && is_word_start((all[k - 1] ?? "").charCodeAt(0)) && all[k - 2] !== ".") acc.add(all[k - 1]);
|
|
823
|
+
for (let k = 0; k + 2 < all.length; k++) if ((all[k] === "[" || all[k] === "{") && all[k + 1] === "..." && acc.has(all[k + 2])) return true;
|
|
824
|
+
return false;
|
|
825
|
+
}
|
|
826
|
+
},
|
|
827
|
+
{
|
|
828
|
+
kind: "string-build",
|
|
829
|
+
saves: .5,
|
|
830
|
+
title: (n) => `A big string is grown piece by piece in a loop${places(n)}`,
|
|
831
|
+
fix: "Each += on a long string can copy it. Collect the pieces in an array and join once at the end.",
|
|
832
|
+
match: (t, c) => c.loop && c.alloc_bytes >= MB && grows_text(t, c.from, c.text_locals)
|
|
833
|
+
},
|
|
834
|
+
{
|
|
835
|
+
kind: "sort-per-call",
|
|
836
|
+
saves: .7,
|
|
837
|
+
title: (n) => `A list is sorted on every call${places(n)}`,
|
|
838
|
+
fix: "Sort once where the data is loaded (or cache the sorted list) and pass it down, instead of sorting each time this runs.",
|
|
839
|
+
match: (t, c) => c.repeated && c.cpu_ms >= .5 && !!calls_method(t, SORT)
|
|
840
|
+
},
|
|
841
|
+
{
|
|
842
|
+
kind: "date-parse",
|
|
843
|
+
saves: .6,
|
|
844
|
+
title: (n) => `Dates are parsed from text over and over${places(n)}`,
|
|
845
|
+
fix: "Parsing a date string is slow. Parse once when the data arrives and keep the timestamp as a number.",
|
|
846
|
+
match: (t, c) => {
|
|
847
|
+
if (!c.repeated || c.cpu_ms < .5) return false;
|
|
848
|
+
if (formats_date(t)) return false;
|
|
849
|
+
if (has_member(t, "Date", "parse")) return true;
|
|
850
|
+
if (constructs(t, DATE) !== "Date") return false;
|
|
851
|
+
const open = t.indexOf("Date", t.indexOf("new")) + 1;
|
|
852
|
+
if (t[open] !== "(" || t[open + 1] === ")") return false;
|
|
853
|
+
let depth = 0;
|
|
854
|
+
for (let j = open; j < t.length; j++) {
|
|
855
|
+
const x = t[j];
|
|
856
|
+
if (x === "(") depth++;
|
|
857
|
+
else if (x === ")" && --depth === 0) break;
|
|
858
|
+
else if (x === "0" || x === "+" || x === "-" || x === "*" || x === "/" || x === ",") return false;
|
|
859
|
+
}
|
|
860
|
+
return true;
|
|
861
|
+
}
|
|
862
|
+
},
|
|
863
|
+
{
|
|
864
|
+
kind: "sync-io",
|
|
865
|
+
saves: .9,
|
|
866
|
+
title: (n) => `The render waits on the disk${places(n)}`,
|
|
867
|
+
fix: "A *Sync file or process call blocks every request on this server while it runs. Read it once at startup, or use the async version.",
|
|
868
|
+
match: (t, c) => c.cpu_ms >= .2 && !!calls_fn(t, SYNC_IO)
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
kind: "yield-per-item",
|
|
872
|
+
saves: .9,
|
|
873
|
+
title: (n) => `The loop waits for the event loop on every turn${places(n)}`,
|
|
874
|
+
fix: "Each await on a timer (setImmediate, setTimeout, nextTick) hands the thread back and waits to be scheduled again, once per item. If the loop must yield, yield every few hundred items, or do the work in one pass.",
|
|
875
|
+
example: {
|
|
876
|
+
before: "for (let i = 0; i < n; i++) await new Promise((r) => setImmediate(r));",
|
|
877
|
+
after: "for (let i = 0; i < n; i++) {\n work(i);\n if (i % 500 === 499) await new Promise((r) => setImmediate(r));\n}"
|
|
878
|
+
},
|
|
879
|
+
match: (t, c) => c.loop && c.cpu_ms >= 1 && (!!calls_fn(t, YIELDS) || has_member(t, "process", "nextTick"))
|
|
880
|
+
},
|
|
881
|
+
{
|
|
882
|
+
kind: "library-per-item",
|
|
883
|
+
saves: .5,
|
|
884
|
+
title: (n, sites) => {
|
|
885
|
+
const lib = sites[0]?.libs?.[0];
|
|
886
|
+
return lib ? `${lib} is called once per item${places(n)}` : `A library is called once per item${places(n)}`;
|
|
887
|
+
},
|
|
888
|
+
fix: "Every call pays the library’s full cost again. If many items share the same input, cache the result by input. If the library can take them all at once, call it once for the whole batch. Skip items that do not need it.",
|
|
889
|
+
example: {
|
|
890
|
+
before: "await Promise.all(tags.map((tag) => render(tag)));",
|
|
891
|
+
after: "const cache = new Map();\nconst once = (tag) => cache.get(tag) ?? cache.set(tag, render(tag)).get(tag);\nawait Promise.all(tags.map(once));"
|
|
892
|
+
},
|
|
893
|
+
match: (_t, c) => c.pkg_ms >= 2 && c.repeated
|
|
894
|
+
}
|
|
895
|
+
];
|
|
896
|
+
const r2 = (n) => Math.round(n * 100) / 100;
|
|
897
|
+
/**
|
|
898
|
+
* SYNC I/O TOO QUICK TO SAMPLE: a small file read on every request finishes under the CPU
|
|
899
|
+
* sampler's interval, so no sample lands on it, yet it blocks every other request on the server
|
|
900
|
+
* while the disk answers (a cold disk or a bigger file makes it long). V8's call counts (the
|
|
901
|
+
* coverage render) count Node's own `readFileSync` like any function: a *Sync call counted in the
|
|
902
|
+
* render, at a line of the route's own files inside a function the render ran (counted too), is
|
|
903
|
+
* one. Module top-level code runs once at start-up and is left out. Pure: the counts and the files'
|
|
904
|
+
* text in, the pattern out (undefined when nothing qualifies). No regex.
|
|
905
|
+
*/
|
|
906
|
+
function counted_sync_io(counts, files) {
|
|
907
|
+
const sync = /* @__PURE__ */ new Map();
|
|
908
|
+
const ran = /* @__PURE__ */ new Set();
|
|
909
|
+
for (const [k, n] of Object.entries(counts)) {
|
|
910
|
+
const z = k.indexOf("\0");
|
|
911
|
+
if (z === -1 || n <= 0) continue;
|
|
912
|
+
const name = k.slice(0, z);
|
|
913
|
+
if (k.slice(z + 1).startsWith("node:")) {
|
|
914
|
+
if (COUNTED_SYNC.has(name)) sync.set(name, Math.max(sync.get(name) ?? 0, n));
|
|
915
|
+
} else ran.add(name);
|
|
916
|
+
}
|
|
917
|
+
if (!sync.size) return void 0;
|
|
918
|
+
const names = [...sync.keys()];
|
|
919
|
+
const sites = [];
|
|
920
|
+
for (const f of files) {
|
|
921
|
+
const lines = f.text.split("\n");
|
|
922
|
+
const stack = [];
|
|
923
|
+
let in_comment = false;
|
|
924
|
+
for (let i = 0; i < lines.length && sites.length < 6; i++) {
|
|
925
|
+
const raw = lines[i];
|
|
926
|
+
if (in_comment) {
|
|
927
|
+
if (raw.includes("*/")) in_comment = false;
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
const trimmed = raw.trimStart();
|
|
931
|
+
if (trimmed.startsWith("/*") && !trimmed.includes("*/")) {
|
|
932
|
+
in_comment = true;
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
const t = tokens(raw);
|
|
936
|
+
let named = null;
|
|
937
|
+
const fn_at = t.indexOf("function");
|
|
938
|
+
if (fn_at !== -1) named = t[fn_at + 1] === "*" ? t[fn_at + 2] ?? null : t[fn_at + 1] ?? null;
|
|
939
|
+
else {
|
|
940
|
+
const decl = t.findIndex((x) => x === "const" || x === "let" || x === "var");
|
|
941
|
+
const eq = decl === -1 ? -1 : t.indexOf("=", decl);
|
|
942
|
+
if (eq !== -1) {
|
|
943
|
+
const j = t[eq + 1] === "async" ? eq + 2 : eq + 1;
|
|
944
|
+
const to = t.indexOf("=>", j);
|
|
945
|
+
if (to !== -1 && (t[j] === "(" || to === j + 1) && !t.slice(j, to).includes(".")) named = t[decl + 1] ?? null;
|
|
946
|
+
} else if (t.length > 2 && t[1] === "(" && t[t.length - 1] === "{" && !BLOCK_WORDS.has(t[0])) named = t[0];
|
|
947
|
+
}
|
|
948
|
+
let arrow = false;
|
|
949
|
+
for (let k = 0; k < t.length; k++) {
|
|
950
|
+
const x = t[k];
|
|
951
|
+
if (x === "{") stack.push(named);
|
|
952
|
+
else if (x === "}") stack.pop();
|
|
953
|
+
else if (x === "=>") arrow = true;
|
|
954
|
+
else if (t[k + 1] === "(" && names.includes(x) && t[k - 1] !== "function") {
|
|
955
|
+
let fn = null;
|
|
956
|
+
for (let s = stack.length - 1; s >= 0 && !fn; s--) fn = stack[s];
|
|
957
|
+
if (!fn && arrow) fn = named;
|
|
958
|
+
if (!fn || !ran.has(fn)) continue;
|
|
959
|
+
if (sites.some((s) => s.path === f.path && s.line === i + 1)) continue;
|
|
960
|
+
const code = raw.trim();
|
|
961
|
+
sites.push({
|
|
962
|
+
path: f.path,
|
|
963
|
+
file: f.file,
|
|
964
|
+
line: i + 1,
|
|
965
|
+
code: code.length > 140 ? code.slice(0, 139) + "…" : code,
|
|
966
|
+
...fn ? { fn_name: fn } : {},
|
|
967
|
+
cpu_ms: 0,
|
|
968
|
+
alloc_bytes: 0,
|
|
969
|
+
gc_ms: 0,
|
|
970
|
+
calls: sync.get(x),
|
|
971
|
+
in_loop: false
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (!sites.length) return void 0;
|
|
978
|
+
const rule = RULES.find((r) => r.kind === "sync-io");
|
|
979
|
+
const said = [...new Set(sites.flatMap((s) => names.filter((n) => calls_name(s.code, [n]))))];
|
|
980
|
+
return {
|
|
981
|
+
kind: "sync-io",
|
|
982
|
+
title: rule.title(sites.length, sites),
|
|
983
|
+
fix: rule.fix,
|
|
984
|
+
sites,
|
|
985
|
+
cost_ms: 0,
|
|
986
|
+
alloc_bytes: 0,
|
|
987
|
+
save_ms: 0,
|
|
988
|
+
evidence: `${said.map((n) => `${n} ×${sync.get(n)}`).join(", ")} per render (V8's call counts): too quick for the CPU sampler here, under a millisecond, but each call stops every other request on this server until the disk answers.`
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
const BLOCK_WORDS = /* @__PURE__ */ new Set([
|
|
992
|
+
"if",
|
|
993
|
+
"for",
|
|
994
|
+
"while",
|
|
995
|
+
"switch",
|
|
996
|
+
"catch",
|
|
997
|
+
"with",
|
|
998
|
+
"return"
|
|
999
|
+
]);
|
|
1000
|
+
/** the *Sync calls V8's coverage counts under their own names (in Node's `node:` modules) */
|
|
1001
|
+
const COUNTED_SYNC = /* @__PURE__ */ new Set([
|
|
1002
|
+
...SYNC_IO,
|
|
1003
|
+
"appendFileSync",
|
|
1004
|
+
"mkdirSync",
|
|
1005
|
+
"openSync",
|
|
1006
|
+
"readSync",
|
|
1007
|
+
"execFileSync",
|
|
1008
|
+
"pbkdf2Sync",
|
|
1009
|
+
"scryptSync"
|
|
1010
|
+
]);
|
|
1011
|
+
function find_patterns(input) {
|
|
1012
|
+
const fns = /* @__PURE__ */ new Map();
|
|
1013
|
+
for (const f of input.functions) fns.set(f.key, f);
|
|
1014
|
+
/** the function a memory-only line belongs to: its `at` line is where that function starts */
|
|
1015
|
+
const starts = /* @__PURE__ */ new Map();
|
|
1016
|
+
for (const f of input.functions) if (f.path && f.line > 0) starts.set(f.path + "\0" + f.line, f);
|
|
1017
|
+
/** a function by the `file:line` its callers' stack frames name it with */
|
|
1018
|
+
const by_loc = /* @__PURE__ */ new Map();
|
|
1019
|
+
for (const f of input.functions) if (f.url && f.line > 0 && !by_loc.has(f.url + ":" + f.line)) by_loc.set(f.url + ":" + f.line, f);
|
|
1020
|
+
/** per file, the start lines of its functions, highest first */
|
|
1021
|
+
const starts_in = /* @__PURE__ */ new Map();
|
|
1022
|
+
for (const f of input.functions) {
|
|
1023
|
+
if (!f.path || f.line <= 0) continue;
|
|
1024
|
+
const list = starts_in.get(f.path);
|
|
1025
|
+
if (list) list.push(f.line);
|
|
1026
|
+
else starts_in.set(f.path, [f.line]);
|
|
1027
|
+
}
|
|
1028
|
+
for (const list of starts_in.values()) list.sort((a, b) => b - a);
|
|
1029
|
+
/** the start of the nearest function above the line whose body reaches it */
|
|
1030
|
+
const enclosing = (l) => {
|
|
1031
|
+
if (!input.source) return void 0;
|
|
1032
|
+
for (const s of starts_in.get(l.path) ?? []) {
|
|
1033
|
+
if (s >= l.line) continue;
|
|
1034
|
+
const body = function_body(input.source, l.path, s);
|
|
1035
|
+
if (body && body.start + body.lines.length - 1 >= l.line) return s;
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
const via_memo = /* @__PURE__ */ new Map();
|
|
1039
|
+
/** a site's caller lines, when the site sits in a THIN WRAPPER: the costly line is the function's
|
|
1040
|
+
* own first line (a one-line arrow), or the function's own time is a small part of the cost */
|
|
1041
|
+
const via_of = (f, l, local_loop) => {
|
|
1042
|
+
if (!f || !input.source || f.category !== "app" && f.category !== "component") return [];
|
|
1043
|
+
const cost = l.cpu_ms + (l.lib_ms ?? 0);
|
|
1044
|
+
if (!(l.line === f.line || f.self_ms < .2 * cost) && local_loop) return [];
|
|
1045
|
+
let v = via_memo.get(f.key);
|
|
1046
|
+
if (!v) via_memo.set(f.key, v = call_sites_of(f, by_loc, input.source));
|
|
1047
|
+
return v;
|
|
1048
|
+
};
|
|
1049
|
+
/** kind → its sites; a library call groups per library function (one renderer called per item
|
|
1050
|
+
* is one problem, a timer in another loop is another) */
|
|
1051
|
+
const found = /* @__PURE__ */ new Map();
|
|
1052
|
+
/** sites a RegExp's first use made at its build's line (the build's own line adds to them) */
|
|
1053
|
+
const moved = /* @__PURE__ */ new Set();
|
|
1054
|
+
for (const l of input.ledger) {
|
|
1055
|
+
if (!l.code) continue;
|
|
1056
|
+
const f = (l.fn ? fns.get(l.fn) : void 0) ?? starts.get(l.path + "\0" + l.line);
|
|
1057
|
+
const svelte = l.path.endsWith(".svelte");
|
|
1058
|
+
const compiled_component = !svelte && !!l.module?.endsWith(".svelte") && !!f && f.line > 0 && !!input.source?.(l.path, f.line, f.line)?.lines[0]?.includes("$$renderer");
|
|
1059
|
+
const own = f && f.line > 0 && f.line <= l.line && l.line - f.line < 400 ? f.line : l.line;
|
|
1060
|
+
const start = f && own === l.line && f.name.startsWith("(") ? enclosing(l) ?? own : own;
|
|
1061
|
+
const body = input.source?.(l.path, svelte ? 1 : start, l.line)?.lines ?? [l.code];
|
|
1062
|
+
let from = loop_from(body, svelte);
|
|
1063
|
+
const calls = f?.calls;
|
|
1064
|
+
const via = via_of(f, l, from >= 0);
|
|
1065
|
+
if (from < 0 && via.some((v) => v.in_loop)) from = 0;
|
|
1066
|
+
const loop = from >= 0;
|
|
1067
|
+
const ctx = {
|
|
1068
|
+
loop,
|
|
1069
|
+
from,
|
|
1070
|
+
repeated: loop || (calls ?? 0) >= 2 || via.some((v) => v.in_loop) || compiled_component,
|
|
1071
|
+
svelte,
|
|
1072
|
+
module_scope: svelte && in_module_script(body),
|
|
1073
|
+
cpu_ms: l.cpu_ms + (l.lib_ms ?? 0),
|
|
1074
|
+
alloc_bytes: l.alloc_bytes,
|
|
1075
|
+
lib_ms: l.lib_ms ?? 0,
|
|
1076
|
+
pkg_ms: (l.libs ?? []).filter((x) => x.pkg !== "Node").reduce((s, x) => s + x.ms, 0),
|
|
1077
|
+
lib_names: (l.libs ?? []).map((x) => x.name),
|
|
1078
|
+
regex_ms: (l.libs ?? []).filter((x) => x.name.startsWith(REGEXP_FRAME)).reduce((s, x) => s + x.ms, 0),
|
|
1079
|
+
regex_backtracks: (l.libs ?? []).some((x) => x.name.startsWith("RegExp: ") && backtracks(x.name.slice(8))),
|
|
1080
|
+
literal_backtracks: regex_literals(l.code).some(backtracks),
|
|
1081
|
+
per_call_ms: calls ? (l.cpu_ms + (l.lib_ms ?? 0)) / Math.max(1, input.renders ?? 1) / calls : void 0,
|
|
1082
|
+
per_call_bytes: calls ? l.alloc_bytes / Math.max(1, input.renders ?? 1) / calls : void 0,
|
|
1083
|
+
alloc_per_render: l.alloc_bytes / Math.max(1, input.renders ?? 1),
|
|
1084
|
+
intl_locals: intl_locals(body),
|
|
1085
|
+
text_locals: text_locals(body),
|
|
1086
|
+
regexp_locals: regexp_locals(body, svelte ? 1 : start),
|
|
1087
|
+
after: input.source?.(l.path, l.line + 1, l.line + 3)?.lines ?? []
|
|
1088
|
+
};
|
|
1089
|
+
const t = tokens(l.code);
|
|
1090
|
+
let matched = false;
|
|
1091
|
+
for (const rule of RULES) {
|
|
1092
|
+
if (!rule.match(t, ctx)) continue;
|
|
1093
|
+
if (rule.kind === "sort-per-call" && input.source && look_inside(input.source, l, t, body, svelte, ctx)) break;
|
|
1094
|
+
const group = rule.kind === "library-per-item" ? rule.kind + "\0" + (ctx.lib_names[0] ?? "") : rule.kind;
|
|
1095
|
+
let e = found.get(group);
|
|
1096
|
+
if (!e) found.set(group, e = {
|
|
1097
|
+
rule,
|
|
1098
|
+
sites: []
|
|
1099
|
+
});
|
|
1100
|
+
matched = true;
|
|
1101
|
+
const built = rule.kind === "regexp-per-call" && !constructs(t, REGEXP) ? used_local_regexp(t, ctx.regexp_locals) : void 0;
|
|
1102
|
+
const line = built?.line ?? l.line;
|
|
1103
|
+
const same = e.sites.find((s) => s.path === l.path && s.line === line);
|
|
1104
|
+
if (same) {
|
|
1105
|
+
if (built || moved.has(same)) {
|
|
1106
|
+
same.cpu_ms += l.cpu_ms;
|
|
1107
|
+
same.alloc_bytes += l.alloc_bytes;
|
|
1108
|
+
same.gc_ms += l.gc_ms;
|
|
1109
|
+
}
|
|
1110
|
+
break;
|
|
1111
|
+
}
|
|
1112
|
+
e.sites.push({
|
|
1113
|
+
path: l.path,
|
|
1114
|
+
file: l.file,
|
|
1115
|
+
line,
|
|
1116
|
+
code: built?.code ?? l.code,
|
|
1117
|
+
...f ? {
|
|
1118
|
+
fn: f.key,
|
|
1119
|
+
fn_name: f.name
|
|
1120
|
+
} : l.fn ? { fn: l.fn } : {},
|
|
1121
|
+
cpu_ms: l.cpu_ms,
|
|
1122
|
+
alloc_bytes: l.alloc_bytes,
|
|
1123
|
+
gc_ms: l.gc_ms,
|
|
1124
|
+
...calls ? { calls } : {},
|
|
1125
|
+
in_loop: loop,
|
|
1126
|
+
...l.lib_ms && !built ? {
|
|
1127
|
+
lib_ms: l.lib_ms,
|
|
1128
|
+
libs: (l.libs ?? []).map((x) => x.pkg ? `${x.name} (${x.pkg})` : x.name)
|
|
1129
|
+
} : {},
|
|
1130
|
+
...via.length ? { via } : {},
|
|
1131
|
+
...ctx_field(input.source, l.path, line)
|
|
1132
|
+
});
|
|
1133
|
+
if (built) moved.add(e.sites[e.sites.length - 1]);
|
|
1134
|
+
matched = true;
|
|
1135
|
+
break;
|
|
1136
|
+
}
|
|
1137
|
+
if (!matched && input.source && l.cpu_ms + (l.lib_ms ?? 0) >= 1) {
|
|
1138
|
+
const inner = look_inside(input.source, l, t, body, svelte, ctx);
|
|
1139
|
+
if (inner) {
|
|
1140
|
+
let e = found.get(inner.rule.kind);
|
|
1141
|
+
if (!e) found.set(inner.rule.kind, e = {
|
|
1142
|
+
rule: inner.rule,
|
|
1143
|
+
sites: []
|
|
1144
|
+
});
|
|
1145
|
+
if (!e.sites.some((s) => s.path === inner.path && s.line === inner.line)) e.sites.push({
|
|
1146
|
+
path: inner.path,
|
|
1147
|
+
file: short_file(inner.path),
|
|
1148
|
+
line: inner.line,
|
|
1149
|
+
code: inner.code,
|
|
1150
|
+
...f ? {
|
|
1151
|
+
fn: f.key,
|
|
1152
|
+
fn_name: f.name
|
|
1153
|
+
} : l.fn ? { fn: l.fn } : {},
|
|
1154
|
+
cpu_ms: l.cpu_ms,
|
|
1155
|
+
alloc_bytes: l.alloc_bytes,
|
|
1156
|
+
gc_ms: l.gc_ms,
|
|
1157
|
+
in_loop: true,
|
|
1158
|
+
...inner.via ? { via: [{
|
|
1159
|
+
path: l.path,
|
|
1160
|
+
file: l.file,
|
|
1161
|
+
line: l.line,
|
|
1162
|
+
code: l.code,
|
|
1163
|
+
in_loop: loop,
|
|
1164
|
+
...f ? { fn: f.key } : {}
|
|
1165
|
+
}] } : {},
|
|
1166
|
+
...ctx_field(input.source, inner.path, inner.line)
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
const out = [];
|
|
1172
|
+
for (const { rule, sites } of found.values()) {
|
|
1173
|
+
const cost = sites.reduce((s, x) => s + x.cpu_ms + x.gc_ms + (x.lib_ms ?? 0), 0);
|
|
1174
|
+
const alloc = sites.reduce((s, x) => s + x.alloc_bytes, 0);
|
|
1175
|
+
out.push({
|
|
1176
|
+
kind: rule.kind,
|
|
1177
|
+
title: rule.title(sites.length, sites),
|
|
1178
|
+
fix: rule.fix,
|
|
1179
|
+
...rule.example ? { example: rule.example } : {},
|
|
1180
|
+
sites,
|
|
1181
|
+
cost_ms: r2(cost),
|
|
1182
|
+
alloc_bytes: Math.round(alloc),
|
|
1183
|
+
save_ms: r2(cost * rule.saves),
|
|
1184
|
+
evidence: evidence_of(sites, input.renders)
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
const timed = out.filter((p) => p.save_ms >= MIN_SAVE_MS).sort((a, b) => b.save_ms - a.save_ms || b.alloc_bytes - a.alloc_bytes);
|
|
1188
|
+
const kept = kept_pattern(input);
|
|
1189
|
+
if (!kept) return timed;
|
|
1190
|
+
return (kept.kept_bytes ?? 0) >= KEPT_SEVERE && kept.growth !== "levels-off" ? [kept, ...timed] : [...timed, kept];
|
|
1191
|
+
}
|
|
1192
|
+
/** "; at that rate its heap (4.0 GB, 1.2 GB used) is full after about 43 more requests" */
|
|
1193
|
+
function countdown(kept, heap) {
|
|
1194
|
+
const gb = (b) => b >= 1024 ** 3 ? `${(b / 1024 ** 3).toFixed(1)} GB` : `${Math.round(b / 1024 ** 2)} MB`;
|
|
1195
|
+
if (heap?.function && kept > 0 && heap.used_bytes >= heap.limit_bytes) return `; this process already uses ${gb(heap.used_bytes)}, more than the function's ${gb(heap.limit_bytes)}: on the platform this instance would already have been killed`;
|
|
1196
|
+
if (!heap || !(heap.limit_bytes > heap.used_bytes) || kept <= 0) return "";
|
|
1197
|
+
const left = Math.floor((heap.limit_bytes - heap.used_bytes) / kept);
|
|
1198
|
+
if (heap.function) return `; at that rate the function's memory (${gb(heap.limit_bytes)}, ${gb(heap.used_bytes)} in use now) runs out after about ${left.toLocaleString("en-US")} more requests on one instance, and the platform then kills it: that request fails and the next one pays a cold start`;
|
|
1199
|
+
return `; at that rate its heap (${gb(heap.limit_bytes)}, ${gb(heap.used_bytes)} used) is full after about ${left.toLocaleString("en-US")} more requests`;
|
|
1200
|
+
}
|
|
1201
|
+
/** kept per render at or past this is an outage waiting (8 MB × a few hundred requests = the heap) */
|
|
1202
|
+
const KEPT_SEVERE = 8388608;
|
|
1203
|
+
/** a line keeping less than this alive after a render is noise (a closure, a small memo) */
|
|
1204
|
+
const KEPT_LINE_MIN = 131072;
|
|
1205
|
+
/** and the pattern needs this much in all */
|
|
1206
|
+
const KEPT_MIN = 262144;
|
|
1207
|
+
/** a place listed holds at least this share of what all of them keep (the biggest always is) */
|
|
1208
|
+
const KEPT_SHARE_MIN = .02;
|
|
1209
|
+
/**
|
|
1210
|
+
* MEMORY KEPT AFTER EVERY RENDER: the retention pass renders once more AFTER the warm runs, then
|
|
1211
|
+
* collects garbage, and the heap sample keeps only what that render allocated and is STILL alive.
|
|
1212
|
+
* A cache would have filled on the first renders, so what one more warm render leaves behind is
|
|
1213
|
+
* growth: memory the server adds with every request. Its own card (not one rule among the line
|
|
1214
|
+
* rules): a line can burn CPU and leak at once. Measured per line; no time saving is claimed.
|
|
1215
|
+
*/
|
|
1216
|
+
function kept_pattern(input) {
|
|
1217
|
+
const sites = [];
|
|
1218
|
+
/** a site's kept memory made INSIDE a library: the library's own function that made it (the
|
|
1219
|
+
* first maker on the line that is neither the app's function nor the library call it makes) */
|
|
1220
|
+
const lib_maker = /* @__PURE__ */ new Map();
|
|
1221
|
+
for (const l of input.ledger) {
|
|
1222
|
+
if (l.retained_bytes < KEPT_LINE_MIN || !l.code) continue;
|
|
1223
|
+
const own = l.fn?.split(" ")[0];
|
|
1224
|
+
const called = new Set((l.libs ?? []).map((x) => x.name));
|
|
1225
|
+
const maker = l.mem_via ? l.who.find((w) => w !== own && !called.has(w) && !w.startsWith("(")) : void 0;
|
|
1226
|
+
const site = {
|
|
1227
|
+
path: l.path,
|
|
1228
|
+
file: l.file,
|
|
1229
|
+
line: l.line,
|
|
1230
|
+
code: l.code,
|
|
1231
|
+
...l.fn ? { fn: l.fn } : {},
|
|
1232
|
+
cpu_ms: 0,
|
|
1233
|
+
alloc_bytes: 0,
|
|
1234
|
+
gc_ms: 0,
|
|
1235
|
+
in_loop: false,
|
|
1236
|
+
kept_bytes: l.retained_bytes,
|
|
1237
|
+
...l.mem_via ? { mem_via: l.mem_via } : {},
|
|
1238
|
+
...l.grows !== void 0 ? { grows: l.grows } : {},
|
|
1239
|
+
...ctx_field(input.source, l.path, l.line)
|
|
1240
|
+
};
|
|
1241
|
+
sites.push(site);
|
|
1242
|
+
if (maker) lib_maker.set(site, maker);
|
|
1243
|
+
}
|
|
1244
|
+
const kept = sites.reduce((s, x) => s + (x.kept_bytes ?? 0), 0);
|
|
1245
|
+
if (kept < KEPT_MIN) return void 0;
|
|
1246
|
+
sites.sort((a, b) => (b.kept_bytes ?? 0) - (a.kept_bytes ?? 0));
|
|
1247
|
+
const floor = kept * KEPT_SHARE_MIN;
|
|
1248
|
+
let cut = sites.findIndex((s, i) => i > 0 && (s.kept_bytes ?? 0) < floor);
|
|
1249
|
+
if (cut === -1) cut = sites.length;
|
|
1250
|
+
const minor = sites.splice(cut);
|
|
1251
|
+
const minor_bytes = minor.reduce((s, x) => s + (x.kept_bytes ?? 0), 0);
|
|
1252
|
+
const minor_note = minor.length ? ` ${minor.length} smaller line${minor.length === 1 ? "" : "s"} hold ${fmt_mb(minor_bytes)} more, each under ${Math.round(KEPT_SHARE_MIN * 100)}% of it: most likely data the render made that is kept along with the rest, so fixing the lines here frees it too.` : "";
|
|
1253
|
+
const g = input.growth;
|
|
1254
|
+
const mb = (b) => (b / MB).toFixed(0);
|
|
1255
|
+
const confirm = !g ? "" : g.levels_off ? ` Over ${g.renders} more renders it levelled off (${mb(g.start_bytes)} → ${mb(g.end_bytes)} MB): most likely a cache with a size limit filling up, not a leak.` : ` Confirmed over ${g.renders} more renders, a full collection after each: the heap went ${mb(g.start_bytes)} → ${mb(g.end_bytes)} MB, about ${fmt_mb(g.per_render_bytes)} a render, and kept climbing.`;
|
|
1256
|
+
const rate = g && !g.levels_off && g.per_render_bytes > 0 ? g.per_render_bytes : kept;
|
|
1257
|
+
const room = input.heap && input.heap.limit_bytes > input.heap.used_bytes && !g?.levels_off ? input.heap : void 0;
|
|
1258
|
+
const top = sites[0];
|
|
1259
|
+
const lib = top?.mem_via && (top.kept_bytes ?? 0) >= kept * .6 ? top.mem_via : void 0;
|
|
1260
|
+
const maker = lib ? lib_maker.get(top) : void 0;
|
|
1261
|
+
const where = g?.levels_off ? "Memory grows, then levels off" : "Memory stays alive after every render";
|
|
1262
|
+
return {
|
|
1263
|
+
kind: "kept-per-render",
|
|
1264
|
+
title: lib ? `${where}, inside ${lib}${places(sites.length)}` : `${where}${places(sites.length)}`,
|
|
1265
|
+
fix: lib ? `Your line only calls ${lib}; what stays alive was made inside it${maker ? ` (${maker})` : ""} and is held by the library, not by your code. The usual causes, most likely first: a new instance or engine made on every call where the library expects one made once and reused; a registry or cache inside it that every call adds to, which an option or a documented cleanup call (destroy, dispose, reset) empties; a bug fixed in a newer version (check its changelog for "memory" or "leak"). If none of those fixes it, give its work a home that is thrown away: a worker thread you replace every few hundred renders.` : "Something outside the render still points at what these lines make: a module-level Map or array that only grows, a cache with no size limit or a key that never hits, a listener or timer holding a closure, or a global regex keeping its last input. Bound the cache (a max size, least-recently-used out), key it so it hits, or keep the value inside the request. One check before you fix: a cache or log that HAS a size limit and is still filling shows here too, and stops growing once full; send the page a few hundred requests and see whether memory levels off.",
|
|
1266
|
+
example: lib ? {
|
|
1267
|
+
before: "export function render(html) {\n const engine = createEngine(); // a new one, and its registry, every call\n return engine.render(html);\n}",
|
|
1268
|
+
after: "const engine = createEngine(); // made once, reused\nexport function render(html) {\n return engine.render(html);\n}"
|
|
1269
|
+
} : {
|
|
1270
|
+
before: "const seen = new Map(); // module scope\nexport function load({ url }) { seen.set(url.href, bigResult); … }",
|
|
1271
|
+
after: "const seen = new Map();\nconst MAX = 500;\nfunction remember(k, v) {\n if (seen.size >= MAX) seen.delete(seen.keys().next().value);\n seen.set(k, v);\n}"
|
|
1272
|
+
},
|
|
1273
|
+
sites,
|
|
1274
|
+
cost_ms: 0,
|
|
1275
|
+
alloc_bytes: 0,
|
|
1276
|
+
save_ms: 0,
|
|
1277
|
+
evidence: `${fmt_mb(kept)} is still alive after one more warm render and a full collection${g?.levels_off ? "" : ": the server grows by about that much with every request"}${countdown(rate, room ?? (input.heap?.function && !g?.levels_off ? input.heap : void 0))}.${confirm}${minor_note}`,
|
|
1278
|
+
kept_bytes: kept,
|
|
1279
|
+
...room ? { requests_left: Math.floor((room.limit_bytes - room.used_bytes) / rate) } : {},
|
|
1280
|
+
...g ? { growth: g.levels_off ? "levels-off" : "grows" } : {}
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
const fmt_mb = (b) => b >= MB ? `${(b / MB).toFixed(b >= 100 * MB ? 0 : 1)} MB` : b >= 1024 ? `${Math.round(b / 1024)} KB` : `${Math.round(b)} bytes`;
|
|
1284
|
+
/** "183 ms of CPU, 1.8 GB made, 53 ms cleanup; formatPrice ran 480 times" */
|
|
1285
|
+
/** UNITS: the CPU, bytes and GC add up every profiled render (`renders`); the run count comes from
|
|
1286
|
+
* V8's coverage of ONE render. The sentence says both, so neither is read against the other. */
|
|
1287
|
+
function evidence_of(sites, renders = 1) {
|
|
1288
|
+
const cpu = sites.reduce((s, x) => s + x.cpu_ms, 0);
|
|
1289
|
+
const alloc = sites.reduce((s, x) => s + x.alloc_bytes, 0);
|
|
1290
|
+
const gc = sites.reduce((s, x) => s + x.gc_ms, 0);
|
|
1291
|
+
const parts = [];
|
|
1292
|
+
if (cpu >= .05) parts.push(`${r2(cpu)} ms of CPU`);
|
|
1293
|
+
if (alloc > 0) parts.push(`${fmt_mb(alloc)} made`);
|
|
1294
|
+
if (gc >= .05) parts.push(`${r2(gc)} ms of cleanup`);
|
|
1295
|
+
const lib = sites.reduce((s, x) => s + (x.lib_ms ?? 0), 0);
|
|
1296
|
+
if (lib >= .05) {
|
|
1297
|
+
const pkgs = [...new Set(sites.flatMap((x) => x.libs ?? []).map((n) => n.includes(" (") ? n.slice(n.indexOf(" (") + 2, -1) : n))];
|
|
1298
|
+
parts.push(`${r2(lib)} ms inside ${pkgs.slice(0, 2).join(", ") || "libraries"}`);
|
|
1299
|
+
}
|
|
1300
|
+
const counted = sites.filter((s) => s.calls && s.fn_name).sort((a, b) => b.cpu_ms + b.gc_ms + (b.lib_ms ?? 0) - (a.cpu_ms + a.gc_ms + (a.lib_ms ?? 0)))[0];
|
|
1301
|
+
let s = parts.join(", ");
|
|
1302
|
+
if (s && renders > 1) s += ` over ${renders} renders`;
|
|
1303
|
+
if (counted) s += `${s ? "; " : ""}${counted.fn_name} ran ${counted.calls} times${renders > 1 ? " in one render" : ""}`;
|
|
1304
|
+
return s;
|
|
1305
|
+
}
|
|
1306
|
+
/** What a service's `Cache-Control` allows: how long a copy may be kept (s-maxage over max-age, for
|
|
1307
|
+
* a shared cache), whether it must not be stored, must be checked with the service before every
|
|
1308
|
+
* reuse (`no-cache`, or a max-age of 0), or is one user's. Split by hand, no regex. */
|
|
1309
|
+
function cache_rules(header) {
|
|
1310
|
+
if (!header) return void 0;
|
|
1311
|
+
const out = {};
|
|
1312
|
+
let shared;
|
|
1313
|
+
for (const part of header.split(",")) {
|
|
1314
|
+
const d = part.trim().toLowerCase();
|
|
1315
|
+
const eq = d.indexOf("=");
|
|
1316
|
+
const name = eq === -1 ? d : d.slice(0, eq).trim();
|
|
1317
|
+
const value = eq === -1 ? "" : d.slice(eq + 1).trim();
|
|
1318
|
+
if (name === "no-store") out.no_store = true;
|
|
1319
|
+
else if (name === "no-cache") out.no_cache = true;
|
|
1320
|
+
else if (name === "private") out.private = true;
|
|
1321
|
+
else if (name === "max-age" && value && Number.isFinite(Number(value))) out.max_age = Number(value);
|
|
1322
|
+
else if (name === "s-maxage" && value && Number.isFinite(Number(value))) shared = Number(value);
|
|
1323
|
+
}
|
|
1324
|
+
if (shared !== void 0) out.max_age = shared;
|
|
1325
|
+
if (out.max_age === 0) {
|
|
1326
|
+
out.no_cache = true;
|
|
1327
|
+
delete out.max_age;
|
|
1328
|
+
}
|
|
1329
|
+
return Object.keys(out).length ? out : void 0;
|
|
1330
|
+
}
|
|
1331
|
+
/** calls closer than this (ms) after the previous one ended still count as "right after it" */
|
|
1332
|
+
const CHAIN_GAP_MS = 10;
|
|
1333
|
+
/** a chain needs this many calls, and this much waiting, to be worth a card */
|
|
1334
|
+
const CHAIN_MIN = 3;
|
|
1335
|
+
const CHAIN_MIN_MS = 20;
|
|
1336
|
+
/**
|
|
1337
|
+
* THE WAITS IN A ROW: calls made from ONE line whose waits did not overlap — each started after
|
|
1338
|
+
* the one before it ended — so the line waited for them one by one (`for (…) await fetch(…)`).
|
|
1339
|
+
* Measured, not guessed: the sum of the waits is what the line cost in wall time, the longest
|
|
1340
|
+
* one is about what it would cost if they ran together, and the difference is the saving.
|
|
1341
|
+
*/
|
|
1342
|
+
function find_wait_patterns(input) {
|
|
1343
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1344
|
+
for (const c of input.calls) {
|
|
1345
|
+
if (!c.at || c.at.line <= 0 || c.ms < 0) continue;
|
|
1346
|
+
const k = c.at.path + "\0" + c.at.line + "\0" + (c.scope ?? "");
|
|
1347
|
+
const g = groups.get(k);
|
|
1348
|
+
if (g) g.push(c);
|
|
1349
|
+
else groups.set(k, [c]);
|
|
1350
|
+
}
|
|
1351
|
+
const sites = [];
|
|
1352
|
+
const longest_of = /* @__PURE__ */ new Map();
|
|
1353
|
+
/** each site's chain, the calls in order (their start on the recording's clock): what the
|
|
1354
|
+
* critical-path check (`wait_save_on_path`) starts together */
|
|
1355
|
+
const chain_of = /* @__PURE__ */ new Map();
|
|
1356
|
+
const needs_memo = /* @__PURE__ */ new Map();
|
|
1357
|
+
const needs = (a, b) => {
|
|
1358
|
+
const pa = a.outer ?? a.at;
|
|
1359
|
+
const pb = b.outer ?? b.at;
|
|
1360
|
+
if (!input.source || !pa || !pb || pa.path !== pb.path) return false;
|
|
1361
|
+
const k = pa.path + "\0" + pa.line + "\0" + pb.line;
|
|
1362
|
+
let v = needs_memo.get(k);
|
|
1363
|
+
if (v === void 0) needs_memo.set(k, v = pa.line === pb.line && !loops_here(input.source, pa.path, pa.line) || needs_previous(input.source, pa.path, pa.line, pb.line));
|
|
1364
|
+
return v;
|
|
1365
|
+
};
|
|
1366
|
+
for (const g of groups.values()) {
|
|
1367
|
+
if (g.length < 2) continue;
|
|
1368
|
+
g.sort((a, b) => a.start - b.start);
|
|
1369
|
+
const runs = [];
|
|
1370
|
+
const batched = (c) => g.some((o) => o !== c && Math.abs(o.start - c.start) < 1 && (o.outer?.line ?? o.at?.line) === (c.outer?.line ?? c.at?.line) && (o.outer?.path ?? o.at?.path) === (c.outer?.path ?? c.at?.path));
|
|
1371
|
+
let run = batched(g[0]) ? [] : [g[0]];
|
|
1372
|
+
for (let i = 1; i < g.length; i++) {
|
|
1373
|
+
if (batched(g[i])) {
|
|
1374
|
+
runs.push(run);
|
|
1375
|
+
run = [];
|
|
1376
|
+
continue;
|
|
1377
|
+
}
|
|
1378
|
+
const prev = run[run.length - 1];
|
|
1379
|
+
const end = prev ? prev.start + prev.ms : -Infinity;
|
|
1380
|
+
if (prev && g[i].start >= end - .5 && g[i].start - end <= CHAIN_GAP_MS && !needs(prev, g[i])) run.push(g[i]);
|
|
1381
|
+
else {
|
|
1382
|
+
runs.push(run);
|
|
1383
|
+
run = [g[i]];
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
runs.push(run);
|
|
1387
|
+
for (const best of runs) {
|
|
1388
|
+
if (best.length < 2) continue;
|
|
1389
|
+
const wait = best.reduce((s, c) => s + c.ms, 0);
|
|
1390
|
+
const outers = /* @__PURE__ */ new Map();
|
|
1391
|
+
for (const c of best) if (c.outer) outers.set(c.outer.path + ":" + c.outer.line, c.outer);
|
|
1392
|
+
const min = outers.size >= 2 ? 2 : CHAIN_MIN;
|
|
1393
|
+
if (best.length < min || wait < CHAIN_MIN_MS) continue;
|
|
1394
|
+
const at = best[0].at;
|
|
1395
|
+
const code = input.source?.(at.path, at.line, at.line)?.lines[0]?.trim() ?? "";
|
|
1396
|
+
const loop_at = (p) => {
|
|
1397
|
+
const above = input.source?.(p.path, Math.max(1, p.line - 40), p.line)?.lines;
|
|
1398
|
+
return above ? loop_from(above, p.path.endsWith(".svelte")) >= 0 : false;
|
|
1399
|
+
};
|
|
1400
|
+
const via = [...outers.values()].slice(0, VIA_MAX).map((o) => {
|
|
1401
|
+
const oc = input.source?.(o.path, o.line, o.line)?.lines[0]?.trim() ?? "";
|
|
1402
|
+
return {
|
|
1403
|
+
path: o.path,
|
|
1404
|
+
file: short_file(o.path),
|
|
1405
|
+
line: o.line,
|
|
1406
|
+
code: oc.length > 140 ? oc.slice(0, 139) + "…" : oc,
|
|
1407
|
+
in_loop: loop_at(o)
|
|
1408
|
+
};
|
|
1409
|
+
});
|
|
1410
|
+
const longest = Math.max(...best.map((c) => c.ms));
|
|
1411
|
+
const targets = [...new Set(best.map((c) => c.target))];
|
|
1412
|
+
const in_loop = loop_at(at) || via.some((v) => v.in_loop);
|
|
1413
|
+
let rewrite;
|
|
1414
|
+
const own = [...outers.values()];
|
|
1415
|
+
if (!in_loop && own.length >= 2 && own.every((o) => o.path === own[0].path) && input.source) {
|
|
1416
|
+
const ls = own.map((o) => o.line);
|
|
1417
|
+
const got = input.source(own[0].path, Math.min(...ls), Math.max(...ls));
|
|
1418
|
+
if (got) rewrite = promise_all_rewrite(got.lines, got.start, ls);
|
|
1419
|
+
}
|
|
1420
|
+
sites.push({
|
|
1421
|
+
path: at.path,
|
|
1422
|
+
file: short_file(at.path),
|
|
1423
|
+
line: at.line,
|
|
1424
|
+
code: code.length > 140 ? code.slice(0, 139) + "…" : code,
|
|
1425
|
+
cpu_ms: 0,
|
|
1426
|
+
alloc_bytes: 0,
|
|
1427
|
+
gc_ms: 0,
|
|
1428
|
+
calls: best.length,
|
|
1429
|
+
in_loop,
|
|
1430
|
+
wait_ms: r2(wait),
|
|
1431
|
+
target: targets.length === 1 ? targets[0] : `${targets[0]} and ${targets.length - 1} more`,
|
|
1432
|
+
...via.length ? { via } : {},
|
|
1433
|
+
...rewrite ? { rewrite: {
|
|
1434
|
+
...rewrite,
|
|
1435
|
+
file: short_file(own[0].path),
|
|
1436
|
+
line: Math.min(...own.map((o) => o.line))
|
|
1437
|
+
} } : {},
|
|
1438
|
+
...ctx_field(input.source, at.path, at.line)
|
|
1439
|
+
});
|
|
1440
|
+
longest_of.set(sites[sites.length - 1], longest);
|
|
1441
|
+
chain_of.set(sites[sites.length - 1], best.map((c) => ({
|
|
1442
|
+
start: c.start,
|
|
1443
|
+
ms: c.ms
|
|
1444
|
+
})));
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
const out = [];
|
|
1448
|
+
const others = [find_repeats(input), find_stragglers(input)].filter((p) => !!p);
|
|
1449
|
+
if (!sites.length) return others;
|
|
1450
|
+
sites.sort((a, b) => (b.wait_ms ?? 0) - (a.wait_ms ?? 0));
|
|
1451
|
+
const cost = sites.reduce((s, x) => s + (x.wait_ms ?? 0), 0);
|
|
1452
|
+
const save = sites.reduce((s, x) => s + (x.wait_ms ?? 0) - (longest_of.get(x) ?? 0), 0);
|
|
1453
|
+
const top = sites[0];
|
|
1454
|
+
out.push({
|
|
1455
|
+
kind: "waits-in-a-row",
|
|
1456
|
+
title: `Calls wait one after another instead of together${sites.length > 1 ? ` (${sites.length} places)` : ""}`,
|
|
1457
|
+
fix: "Each call starts only after the one before it finished, so the waits add up. Start them all first, then await them together (Promise.all), or ask the API for all of them in one request. If the upstream cannot take many at once, run a few at a time.",
|
|
1458
|
+
example: {
|
|
1459
|
+
before: "for (const id of ids) items.push(await getItem(id));",
|
|
1460
|
+
after: "const items = await Promise.all(ids.map((id) => getItem(id)));"
|
|
1461
|
+
},
|
|
1462
|
+
sites,
|
|
1463
|
+
cost_ms: r2(cost),
|
|
1464
|
+
alloc_bytes: 0,
|
|
1465
|
+
save_ms: r2(save),
|
|
1466
|
+
evidence: `${top.calls} calls to ${top.target}, ${r2(top.wait_ms ?? 0)} ms of waiting in a row`,
|
|
1467
|
+
wait: true,
|
|
1468
|
+
chains: sites.map((s) => chain_of.get(s) ?? [])
|
|
1469
|
+
});
|
|
1470
|
+
out.push(...others);
|
|
1471
|
+
return out;
|
|
1472
|
+
}
|
|
1473
|
+
/** a batch's slowest call holds it when it takes this many times the next slowest, and this much longer */
|
|
1474
|
+
const STRAGGLER_RATIO = 3;
|
|
1475
|
+
const STRAGGLER_MIN_MS = 20;
|
|
1476
|
+
/**
|
|
1477
|
+
* ONE SLOW CALL HOLDS A BATCH: calls started together from one line (a `Promise.all`), where one
|
|
1478
|
+
* takes several times as long as the rest. The batch — and everything after it — waits for that
|
|
1479
|
+
* one while the others are long done. Up to the gap between it and the next slowest comes off the
|
|
1480
|
+
* render if it stops holding the batch: started earlier, its result deferred to what needs it, or
|
|
1481
|
+
* given a timeout with a fallback.
|
|
1482
|
+
*/
|
|
1483
|
+
function find_stragglers(input) {
|
|
1484
|
+
const by_line = /* @__PURE__ */ new Map();
|
|
1485
|
+
for (const c of input.calls) {
|
|
1486
|
+
const p = c.outer ?? c.at;
|
|
1487
|
+
if (!p || c.ms < 0) continue;
|
|
1488
|
+
const k = p.path + "\0" + p.line + "\0" + (c.scope ?? "");
|
|
1489
|
+
(by_line.get(k) ?? by_line.set(k, []).get(k)).push(c);
|
|
1490
|
+
}
|
|
1491
|
+
const sites = [];
|
|
1492
|
+
let save = 0;
|
|
1493
|
+
for (const list of by_line.values()) {
|
|
1494
|
+
list.sort((a, b) => a.start - b.start);
|
|
1495
|
+
for (let i = 0; i < list.length;) {
|
|
1496
|
+
let j = i + 1;
|
|
1497
|
+
while (j < list.length && list[j].start - list[i].start < 1) j++;
|
|
1498
|
+
const batch = list.slice(i, j);
|
|
1499
|
+
i = j;
|
|
1500
|
+
if (batch.length < 2) continue;
|
|
1501
|
+
const [slow, next] = [...batch].sort((a, b) => b.ms - a.ms);
|
|
1502
|
+
if (slow.ms < next.ms * STRAGGLER_RATIO || slow.ms - next.ms < STRAGGLER_MIN_MS) continue;
|
|
1503
|
+
const p = slow.outer ?? slow.at;
|
|
1504
|
+
const code = input.source?.(p.path, p.line, p.line)?.lines[0]?.trim() ?? "";
|
|
1505
|
+
const above = input.source?.(p.path, Math.max(1, p.line - 4), p.line)?.lines.join("\n");
|
|
1506
|
+
if (above !== void 0 && !above.includes("Promise.all")) continue;
|
|
1507
|
+
sites.push({
|
|
1508
|
+
path: p.path,
|
|
1509
|
+
file: short_file(p.path),
|
|
1510
|
+
line: p.line,
|
|
1511
|
+
code: code.length > 140 ? code.slice(0, 139) + "…" : code,
|
|
1512
|
+
cpu_ms: 0,
|
|
1513
|
+
alloc_bytes: 0,
|
|
1514
|
+
gc_ms: 0,
|
|
1515
|
+
calls: batch.length,
|
|
1516
|
+
in_loop: false,
|
|
1517
|
+
wait_ms: r2(slow.ms),
|
|
1518
|
+
target: slow.target,
|
|
1519
|
+
...ctx_field(input.source, p.path, p.line)
|
|
1520
|
+
});
|
|
1521
|
+
save += slow.ms - next.ms;
|
|
1522
|
+
straggler_next.set(sites[sites.length - 1], next.ms);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
if (!sites.length) return void 0;
|
|
1526
|
+
sites.sort((a, b) => (b.wait_ms ?? 0) - (a.wait_ms ?? 0));
|
|
1527
|
+
const top = sites[0];
|
|
1528
|
+
return {
|
|
1529
|
+
kind: "batch-straggler",
|
|
1530
|
+
title: `One slow call holds a batch of calls${places(sites.length)}`,
|
|
1531
|
+
fix: `The calls start together, but one takes far longer than the rest, so the batch — and everything after it — waits for that one while the others are long done. Start it earlier (before other work), hand its promise to the part that needs it instead of awaiting it with the rest, or give it a timeout with a fallback.`,
|
|
1532
|
+
example: {
|
|
1533
|
+
before: "const [nav, user, recs] = await Promise.all([getNav(), getUser(), getRecs()]); // recs: 90 ms",
|
|
1534
|
+
after: "const recs = getRecs(); // started, not awaited here\nconst [nav, user] = await Promise.all([getNav(), getUser()]);"
|
|
1535
|
+
},
|
|
1536
|
+
sites,
|
|
1537
|
+
cost_ms: r2(sites.reduce((s, x) => s + (x.wait_ms ?? 0), 0)),
|
|
1538
|
+
alloc_bytes: 0,
|
|
1539
|
+
save_ms: r2(save),
|
|
1540
|
+
wait: true,
|
|
1541
|
+
evidence: `${top.target} takes ${r2(top.wait_ms ?? 0)} ms; the other ${(top.calls ?? 2) - 1} call${(top.calls ?? 2) - 1 === 1 ? "" : "s"} started with it end by ${r2(straggler_next.get(top) ?? 0)} ms`
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
const straggler_next = /* @__PURE__ */ new WeakMap();
|
|
1545
|
+
/**
|
|
1546
|
+
* THE SAME REQUEST, AGAIN: one render made the exact same read (method + URL) more than once —
|
|
1547
|
+
* two parts of the page each fetching the session, a helper called twice. Every call after the
|
|
1548
|
+
* first asked for an answer the render already had (or was already waiting for); their waiting is
|
|
1549
|
+
* the saving. Each place that made one is listed.
|
|
1550
|
+
*/
|
|
1551
|
+
function find_repeats(input) {
|
|
1552
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1553
|
+
for (const c of input.calls) {
|
|
1554
|
+
if (!c.exact || !c.at || c.ms < 0) continue;
|
|
1555
|
+
const k = c.exact + "\0" + (c.scope ?? "");
|
|
1556
|
+
const g = groups.get(k);
|
|
1557
|
+
if (g) g.push(c);
|
|
1558
|
+
else groups.set(k, [c]);
|
|
1559
|
+
}
|
|
1560
|
+
const sites = [];
|
|
1561
|
+
for (const g of groups.values()) {
|
|
1562
|
+
if (g.length < 2) continue;
|
|
1563
|
+
g.sort((a, b) => a.start - b.start);
|
|
1564
|
+
const extra = g.slice(1).reduce((s, c) => s + c.ms, 0);
|
|
1565
|
+
if (extra < 2) continue;
|
|
1566
|
+
const asked = /* @__PURE__ */ new Map();
|
|
1567
|
+
for (const c of g) {
|
|
1568
|
+
const p = c.outer ?? c.at;
|
|
1569
|
+
asked.set(p.path + ":" + p.line, p);
|
|
1570
|
+
}
|
|
1571
|
+
const at = g[0].at;
|
|
1572
|
+
const code = input.source?.(at.path, at.line, at.line)?.lines[0]?.trim() ?? "";
|
|
1573
|
+
const via = [...asked.values()].slice(0, VIA_MAX).map((o) => {
|
|
1574
|
+
const oc = input.source?.(o.path, o.line, o.line)?.lines[0]?.trim() ?? "";
|
|
1575
|
+
return {
|
|
1576
|
+
path: o.path,
|
|
1577
|
+
file: short_file(o.path),
|
|
1578
|
+
line: o.line,
|
|
1579
|
+
code: oc.length > 140 ? oc.slice(0, 139) + "…" : oc,
|
|
1580
|
+
in_loop: false
|
|
1581
|
+
};
|
|
1582
|
+
});
|
|
1583
|
+
sites.push({
|
|
1584
|
+
path: at.path,
|
|
1585
|
+
file: short_file(at.path),
|
|
1586
|
+
line: at.line,
|
|
1587
|
+
code: code.length > 140 ? code.slice(0, 139) + "…" : code,
|
|
1588
|
+
cpu_ms: 0,
|
|
1589
|
+
alloc_bytes: 0,
|
|
1590
|
+
gc_ms: 0,
|
|
1591
|
+
calls: g.length,
|
|
1592
|
+
in_loop: false,
|
|
1593
|
+
wait_ms: r2(extra),
|
|
1594
|
+
target: g[0].exact,
|
|
1595
|
+
...via.length > 1 || via[0] && via[0].line !== at.line ? { via } : {},
|
|
1596
|
+
...ctx_field(input.source, at.path, at.line)
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
if (!sites.length) return void 0;
|
|
1600
|
+
sites.sort((a, b) => (b.wait_ms ?? 0) - (a.wait_ms ?? 0));
|
|
1601
|
+
const save = sites.reduce((s, x) => s + (x.wait_ms ?? 0), 0);
|
|
1602
|
+
const top = sites[0];
|
|
1603
|
+
return {
|
|
1604
|
+
kind: "repeat-request",
|
|
1605
|
+
title: `The same request is made more than once in one render${sites.length > 1 ? ` (${sites.length} requests)` : ""}`,
|
|
1606
|
+
fix: "Each extra call asks for an answer the render already has. Fetch it once per request and share it: keep the promise in a request-scoped cache (event.locals, or a Map made per request), or load it once in the layout and read it from the parent data.",
|
|
1607
|
+
example: {
|
|
1608
|
+
before: "const user = await getSession(); … const locale = (await getSession()).locale;",
|
|
1609
|
+
after: "const session = getSession(); // once, shared\nconst user = await session; … const { locale } = await session;"
|
|
1610
|
+
},
|
|
1611
|
+
sites,
|
|
1612
|
+
cost_ms: r2(save),
|
|
1613
|
+
alloc_bytes: 0,
|
|
1614
|
+
save_ms: r2(save),
|
|
1615
|
+
evidence: `${top.target} was requested ${top.calls} times; ${r2(top.wait_ms ?? 0)} ms went to the repeats`,
|
|
1616
|
+
wait: true
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
/** `src/lib/x.ts` from a full path (the part from `src/`, else the last three segments) */
|
|
1620
|
+
function short_file(path) {
|
|
1621
|
+
const p = path.startsWith("file://") ? path.slice(7) : path;
|
|
1622
|
+
const src = p.lastIndexOf("/src/");
|
|
1623
|
+
if (src !== -1) return p.slice(src + 1);
|
|
1624
|
+
const parts = p.split("/");
|
|
1625
|
+
return parts.length > 3 ? parts.slice(-3).join("/") : p;
|
|
1626
|
+
}
|
|
1627
|
+
/** "Fix these and the render goes from 754 ms to ~300 ms." Each costly line belongs to one pattern,
|
|
1628
|
+
* so the savings add without counting a line twice; the sum is capped at 90 % of the render (an
|
|
1629
|
+
* estimate never promises a free page). Undefined without a render to measure against.
|
|
1630
|
+
*
|
|
1631
|
+
* UNITS: a CPU pattern's numbers add up the whole recording — `renders` renders in page mode —
|
|
1632
|
+
* while a waiting pattern is measured on ONE render (the network log is scoped to one run). CPU
|
|
1633
|
+
* savings are divided by `renders` so both are per render, like `render_ms`. */
|
|
1634
|
+
function fix_impact(patterns, render_ms, renders = 1, wait_ms) {
|
|
1635
|
+
if (!patterns.length || !(render_ms > 0)) return void 0;
|
|
1636
|
+
const n = Math.max(1, renders);
|
|
1637
|
+
let cpu = 0;
|
|
1638
|
+
let wait = 0;
|
|
1639
|
+
for (const p of patterns) if (p.wait) wait += p.save_ms;
|
|
1640
|
+
else cpu += p.save_ms / n;
|
|
1641
|
+
if (wait_ms !== void 0 && wait_ms >= 0) wait = Math.min(wait, wait_ms);
|
|
1642
|
+
const off = Math.min(cpu + wait, render_ms * .9);
|
|
1643
|
+
return {
|
|
1644
|
+
cpu_ms: r2(cpu),
|
|
1645
|
+
wait_ms: r2(wait),
|
|
1646
|
+
render_ms: r2(render_ms),
|
|
1647
|
+
after_ms: r2(render_ms - off),
|
|
1648
|
+
pct: Math.round(off / render_ms * 100)
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
/** A leak grows the same every render; a cache with a size limit grows, then stops. The second
|
|
1652
|
+
* half's rate against the first half's tells them apart; under 64 KB a render is flat either way. */
|
|
1653
|
+
/** A growth series (the settled heap before, then after each render) that is FLAT: under 32 KB a
|
|
1654
|
+
* render (half of `heap_growth`'s line), and every settled reading after the first render within
|
|
1655
|
+
* 256 KB of the others — more renders could not make it grow, so the check may stop. */
|
|
1656
|
+
function is_flat(series) {
|
|
1657
|
+
const n = series.length - 1;
|
|
1658
|
+
if (n < 1) return false;
|
|
1659
|
+
if ((series[n] - series[0]) / n >= 32768) return false;
|
|
1660
|
+
let lo = Infinity;
|
|
1661
|
+
let hi = -Infinity;
|
|
1662
|
+
for (let i = 1; i <= n; i++) {
|
|
1663
|
+
lo = Math.min(lo, series[i]);
|
|
1664
|
+
hi = Math.max(hi, series[i]);
|
|
1665
|
+
}
|
|
1666
|
+
return hi - lo < 262144;
|
|
1667
|
+
}
|
|
1668
|
+
/** A LEAK PAST DOUBT after three renders: each kept 16 MB or more, the largest step at most half again
|
|
1669
|
+
* the smallest — steady growth no further render can turn into "it levels off", and each more render
|
|
1670
|
+
* takes that much of the measured server's heap (/hell: 70 MB a render, six renders, 9 s and 420 MB
|
|
1671
|
+
* of the instance's memory for a verdict the first three gave). A smaller leak keeps the full check:
|
|
1672
|
+
* its halfway read is what tells a growing line from a bounded cache still filling. */
|
|
1673
|
+
function is_clear_leak(series) {
|
|
1674
|
+
const n = series.length - 1;
|
|
1675
|
+
if (n < 3) return false;
|
|
1676
|
+
let lo = Infinity;
|
|
1677
|
+
let hi = -Infinity;
|
|
1678
|
+
for (let i = 1; i <= n; i++) {
|
|
1679
|
+
const step = series[i] - series[i - 1];
|
|
1680
|
+
lo = Math.min(lo, step);
|
|
1681
|
+
hi = Math.max(hi, step);
|
|
1682
|
+
}
|
|
1683
|
+
return lo >= 16777216 && hi <= lo * 1.5;
|
|
1684
|
+
}
|
|
1685
|
+
function heap_growth(series) {
|
|
1686
|
+
const n = series.length - 1;
|
|
1687
|
+
if (n < 2) return void 0;
|
|
1688
|
+
const mid = Math.max(1, Math.floor(n / 2));
|
|
1689
|
+
const first = (series[mid] - series[0]) / mid;
|
|
1690
|
+
const second = (series[n] - series[mid]) / Math.max(1, n - mid);
|
|
1691
|
+
const per = (series[n] - series[0]) / n;
|
|
1692
|
+
return {
|
|
1693
|
+
renders: n,
|
|
1694
|
+
start_bytes: Math.round(series[0]),
|
|
1695
|
+
end_bytes: Math.round(series[n]),
|
|
1696
|
+
per_render_bytes: Math.round(per),
|
|
1697
|
+
levels_off: per < 65536 || first > 0 && second < first * .25,
|
|
1698
|
+
series_mb: series.map((b) => r2(b / MB))
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* THE SEED SHIPS WHOLE BECAUSE OF A LINE: an island that takes `page.data` whole (a spread, a
|
|
1703
|
+
* pass-through, `const d = page.data`) makes every key ship to the browser, in every page view.
|
|
1704
|
+
* The seed explainer knew WHICH islands; this points at the line in each that does it, with the
|
|
1705
|
+
* bytes it costs. A size, not a time: no saving in ms is claimed.
|
|
1706
|
+
*/
|
|
1707
|
+
function seed_whole_pattern(readers, seed_bytes, top) {
|
|
1708
|
+
if (!readers.length || seed_bytes < 16384) return void 0;
|
|
1709
|
+
const names = readers.map((r) => r.name);
|
|
1710
|
+
const who = names.length <= 2 ? names.join(" and ") : `${names.slice(0, 2).join(", ")} and ${names.length - 2} more`;
|
|
1711
|
+
return {
|
|
1712
|
+
kind: "seed-whole-read",
|
|
1713
|
+
title: `An island takes page.data whole, so all of it ships${places(readers.length)}`,
|
|
1714
|
+
fix: "Read the keys by name (page.data.price, not the whole object handed on), or pass the island only what it shows as props. Then seed shaping ships just those keys instead of everything the page loaded.",
|
|
1715
|
+
example: {
|
|
1716
|
+
before: "const d = page.data; // then d.price, d.currency",
|
|
1717
|
+
after: "const { price, currency } = page.data;"
|
|
1718
|
+
},
|
|
1719
|
+
sites: readers.map((r) => ({
|
|
1720
|
+
path: r.file,
|
|
1721
|
+
file: r.file,
|
|
1722
|
+
line: r.line,
|
|
1723
|
+
code: r.code,
|
|
1724
|
+
cpu_ms: 0,
|
|
1725
|
+
alloc_bytes: 0,
|
|
1726
|
+
gc_ms: 0,
|
|
1727
|
+
in_loop: false,
|
|
1728
|
+
fn_name: r.name,
|
|
1729
|
+
...r.context ? { context: r.context } : {}
|
|
1730
|
+
})),
|
|
1731
|
+
cost_ms: 0,
|
|
1732
|
+
alloc_bytes: 0,
|
|
1733
|
+
save_ms: 0,
|
|
1734
|
+
evidence: `${who} take${names.length === 1 ? "s" : ""} page.data whole: the seed ships ${fmt_mb(seed_bytes)} with every page view${top ? ` (the biggest key: ${top.key}, ${fmt_mb(top.bytes)})` : ""}`,
|
|
1735
|
+
seed_bytes
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
/**
|
|
1739
|
+
* THE SAME WORK EVERY REQUEST: load lines whose value depends only on the module (source-scan
|
|
1740
|
+
* `constant_work`), with the CPU they took. The CPU twin of `same-answer`: every request computes
|
|
1741
|
+
* the same result again. Saving: all of it (computed once, at module load or on first use).
|
|
1742
|
+
*/
|
|
1743
|
+
function same_work_pattern(work, renders) {
|
|
1744
|
+
const costly = work.filter((w) => w.cpu_ms / Math.max(1, renders) >= MIN_SAVE_MS).sort((a, b) => b.cpu_ms - a.cpu_ms);
|
|
1745
|
+
if (!costly.length) return void 0;
|
|
1746
|
+
const cost = costly.reduce((s, w) => s + w.cpu_ms, 0);
|
|
1747
|
+
const top = costly[0];
|
|
1748
|
+
const per = (ms) => r2(ms / Math.max(1, renders));
|
|
1749
|
+
return {
|
|
1750
|
+
kind: "same-every-request",
|
|
1751
|
+
title: `Every request computes the same thing again${places(costly.length)}`,
|
|
1752
|
+
fix: "These lines read nothing the request brings — only the module’s own imports and constants — so every request gets the same result. Compute it once: at module level (`const VALID = validateAll(PRODUCTS)` next to the imports), or lazily on first use. If a function in it reads the clock, a random number or a file that changes, keep that part per request.",
|
|
1753
|
+
example: {
|
|
1754
|
+
before: "export const load = () => {\n const valid = validateAll(PRODUCTS);",
|
|
1755
|
+
after: "const VALID = validateAll(PRODUCTS); // once, when the module loads\nexport const load = () => {"
|
|
1756
|
+
},
|
|
1757
|
+
sites: costly.map((w) => ({
|
|
1758
|
+
path: w.path,
|
|
1759
|
+
file: w.file,
|
|
1760
|
+
line: w.line,
|
|
1761
|
+
code: w.code,
|
|
1762
|
+
cpu_ms: r2(w.cpu_ms),
|
|
1763
|
+
alloc_bytes: 0,
|
|
1764
|
+
gc_ms: 0,
|
|
1765
|
+
in_loop: false,
|
|
1766
|
+
...w.calls.length ? {
|
|
1767
|
+
fn_name: w.calls[0],
|
|
1768
|
+
moves: w.calls
|
|
1769
|
+
} : {},
|
|
1770
|
+
...w.context ? { context: w.context } : {}
|
|
1771
|
+
})),
|
|
1772
|
+
cost_ms: r2(cost),
|
|
1773
|
+
alloc_bytes: 0,
|
|
1774
|
+
save_ms: r2(cost),
|
|
1775
|
+
evidence: `${top.names.join(", ")} (${top.calls.join(", ") || "this line"}) reads only the module, yet runs on every request: ${per(top.cpu_ms)} ms a render${costly.length > 1 ? `; ${costly.length} such lines, ${per(cost)} ms a render in all` : ""}`
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* A COMPONENT RENDERED ONCE PER ITEM: its render count (V8's count of one render) comes from an
|
|
1780
|
+
* `{#each}` over a page-data key, and that key is made on one load line (source). The chain says
|
|
1781
|
+
* where its time is decided: render fewer items (a page of them, a smaller slice), or make each
|
|
1782
|
+
* one cheaper. The cost scales with the count, so no saving is claimed: it is the lever, not a fix.
|
|
1783
|
+
*/
|
|
1784
|
+
function render_per_item_pattern(items, renders) {
|
|
1785
|
+
const costly = items.filter((i) => i.total_ms / Math.max(1, renders) >= 5 && i.instances >= 20).sort((a, b) => b.total_ms - a.total_ms);
|
|
1786
|
+
if (!costly.length) return void 0;
|
|
1787
|
+
const per = (ms) => r2(ms / Math.max(1, renders));
|
|
1788
|
+
const top = costly[0];
|
|
1789
|
+
return {
|
|
1790
|
+
kind: "render-per-item",
|
|
1791
|
+
title: `A component renders once per item of a list${places(costly.length)}`,
|
|
1792
|
+
fix: "Its time is its count times its cost. The count is set by the list the loop walks, which the load makes: send a page of items (pagination, a smaller slice, \"load more\" in an island) and the time drops in step. Or make each render cheaper: the slow patterns inside it say where.",
|
|
1793
|
+
example: {
|
|
1794
|
+
before: "const results = sorted.slice(0, 120);",
|
|
1795
|
+
after: "const results = sorted.slice(0, 24); // one page; the rest on request"
|
|
1796
|
+
},
|
|
1797
|
+
sites: costly.map((i) => ({
|
|
1798
|
+
path: i.each.path,
|
|
1799
|
+
file: i.each.file,
|
|
1800
|
+
line: i.each.line,
|
|
1801
|
+
code: i.each.code,
|
|
1802
|
+
cpu_ms: r2(i.total_ms),
|
|
1803
|
+
alloc_bytes: 0,
|
|
1804
|
+
gc_ms: 0,
|
|
1805
|
+
calls: i.instances,
|
|
1806
|
+
in_loop: true,
|
|
1807
|
+
fn_name: i.component,
|
|
1808
|
+
...i.made_at ? { via: [{
|
|
1809
|
+
path: i.made_at.path,
|
|
1810
|
+
file: i.made_at.file,
|
|
1811
|
+
line: i.made_at.line,
|
|
1812
|
+
code: i.made_at.code,
|
|
1813
|
+
in_loop: false
|
|
1814
|
+
}] } : {},
|
|
1815
|
+
...i.each.context ? { context: i.each.context } : {}
|
|
1816
|
+
})),
|
|
1817
|
+
cost_ms: r2(costly.reduce((s, i) => s + i.total_ms, 0)),
|
|
1818
|
+
alloc_bytes: 0,
|
|
1819
|
+
save_ms: 0,
|
|
1820
|
+
evidence: `${top.component} renders ${top.instances} times a page (${per(top.total_ms)} ms, ${r2(per(top.total_ms) / top.instances)} ms each)${top.key ? `: once per item of ${top.key}` : ""}${top.made_at ? `, made at ${top.made_at.file}:${top.made_at.line}` : ""}`
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
/**
|
|
1824
|
+
* THE SAME DOCUMENT EVERY REQUEST: every profiled render returned the same bytes, and nothing it
|
|
1825
|
+
* reads is the visitor's (no load takes cookies, locals or the raw request; no outbound call carried
|
|
1826
|
+
* a cookie or an authorization header). The whole render can be skipped: a CDN keeps the document
|
|
1827
|
+
* (`Cache-Control: s-maxage`, keyed by its URL), or ogygia's postrender stores it. Saving: the
|
|
1828
|
+
* render itself.
|
|
1829
|
+
*/
|
|
1830
|
+
function same_document_pattern(input) {
|
|
1831
|
+
const blocks = [...input.blocked?.cookie ? ["sets a cookie (Set-Cookie)"] : [], ...input.blocked?.cache_control ? [`answers Cache-Control: ${input.blocked.cache_control}`] : []];
|
|
1832
|
+
const first = blocks.length ? ` First, the response ${blocks.join(" and ")}: no shared cache (a CDN, a proxy) keeps such a page, so the header below does nothing until that goes. ${input.blocked?.cookie ? "Set the cookie where it is needed (an endpoint the page calls, a hook that skips this path) rather than on every page; " : ""}${input.blocked?.cache_control ? "drop private / no-store from a page that holds nothing per visitor; " : ""}then` : "";
|
|
1833
|
+
return {
|
|
1834
|
+
kind: "same-document",
|
|
1835
|
+
title: "Every request renders the same page again",
|
|
1836
|
+
fix: `Every profiled render returned the same document byte for byte, and nothing it reads belongs to the visitor. Let a cache serve it instead of rendering it.${first} ${first ? "set" : "Set"} Cache-Control with s-maxage (a CDN keeps it per URL; stale-while-revalidate refreshes it in the background), or store the page with ogygia's postrender. Only a change in its data needs a new render. (A \`handle\` hook that reads cookies and changes the page would make it per visitor: the loads cannot show that, so check yours first.)`,
|
|
1837
|
+
example: {
|
|
1838
|
+
before: "export const load = async ({ fetch }) => { … }",
|
|
1839
|
+
after: "export const load = async ({ fetch, setHeaders }) => {\n setHeaders({ 'cache-control': 'public, s-maxage=60, stale-while-revalidate=300' });\n …"
|
|
1840
|
+
},
|
|
1841
|
+
sites: [{
|
|
1842
|
+
path: input.file,
|
|
1843
|
+
file: input.file,
|
|
1844
|
+
line: 1,
|
|
1845
|
+
code: input.code ?? "",
|
|
1846
|
+
cpu_ms: 0,
|
|
1847
|
+
alloc_bytes: 0,
|
|
1848
|
+
gc_ms: 0,
|
|
1849
|
+
in_loop: false
|
|
1850
|
+
}],
|
|
1851
|
+
cost_ms: r2(input.render_ms),
|
|
1852
|
+
alloc_bytes: 0,
|
|
1853
|
+
save_ms: r2(input.render_ms),
|
|
1854
|
+
wait: true,
|
|
1855
|
+
evidence: `every render returned the same ${fmt_mb(input.bytes)} document; no load reads cookies, locals or the request, and no call carried a cookie: ~${Math.round(input.render_ms)} ms a request a cache would answer instead${blocks.length ? `, once the response no longer ${blocks.join(" and ")}` : ""}`
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
/**
|
|
1859
|
+
* THE SAME PAGE BUT FOR A FEW PARTS: the renders' documents differ only in a few small groups (an
|
|
1860
|
+
* island's props, an id stamped per render, numbered comment markers) and nothing read is the
|
|
1861
|
+
* visitor's. Those parts are all that stands between the page and a cached copy: make them stop
|
|
1862
|
+
* changing (an id derived from the data, not a counter or a random; a live value fetched in the
|
|
1863
|
+
* browser or in a deferred region) and the whole render comes off.
|
|
1864
|
+
*/
|
|
1865
|
+
function almost_same_document_pattern(input) {
|
|
1866
|
+
const g = input.diff.groups;
|
|
1867
|
+
const origin = (x) => x.sources?.length ? `, set at ${x.sources.map((s) => `${s.file}:${s.line}`).join(", ")}` : x.library ? ", stamped by a library as it renders (no file of yours sets it)" : "";
|
|
1868
|
+
const parts = g.map((x) => `${x.what} (${x.count} place${x.count === 1 ? "" : "s"}: ${x.a || "\"\""} → ${x.b || "\"\""}${origin(x)})`);
|
|
1869
|
+
const set_at = g.flatMap((x) => x.sources ?? []);
|
|
1870
|
+
return {
|
|
1871
|
+
kind: "almost-same-document",
|
|
1872
|
+
title: `The page is the same every render but for ${g.length} small part${g.length === 1 ? "" : "s"}`,
|
|
1873
|
+
fix: "Only these parts change from one render to the next; everything else is byte for byte the same, and nothing read belongs to the visitor. Make them stop changing — an id derived from the data instead of a counter or a random, a live value fetched in the browser or moved into a deferred region (`render: 'deferred'`) — and the whole page can be a cached copy (then see \"same page every request\").",
|
|
1874
|
+
example: {
|
|
1875
|
+
before: "<a data-track={crypto.randomUUID()}>",
|
|
1876
|
+
after: "<a data-track={product.id}> <!-- the same every render -->"
|
|
1877
|
+
},
|
|
1878
|
+
sites: set_at.length ? set_at.map((s) => ({
|
|
1879
|
+
path: s.file,
|
|
1880
|
+
file: s.file,
|
|
1881
|
+
line: s.line,
|
|
1882
|
+
code: s.code,
|
|
1883
|
+
cpu_ms: 0,
|
|
1884
|
+
alloc_bytes: 0,
|
|
1885
|
+
gc_ms: 0,
|
|
1886
|
+
in_loop: false
|
|
1887
|
+
})) : [{
|
|
1888
|
+
path: input.file,
|
|
1889
|
+
file: input.file,
|
|
1890
|
+
line: 1,
|
|
1891
|
+
code: input.code ?? "",
|
|
1892
|
+
cpu_ms: 0,
|
|
1893
|
+
alloc_bytes: 0,
|
|
1894
|
+
gc_ms: 0,
|
|
1895
|
+
in_loop: false
|
|
1896
|
+
}],
|
|
1897
|
+
cost_ms: r2(input.render_ms),
|
|
1898
|
+
alloc_bytes: 0,
|
|
1899
|
+
save_ms: r2(input.render_ms),
|
|
1900
|
+
wait: true,
|
|
1901
|
+
evidence: `${input.diff.differing} of ${input.diff.tokens} pieces of the document change between renders, all of them: ${parts.join("; ")}`
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* THE PAGE WAITS FOR A LATE ISLAND'S DATA: a call whose every key is used only as props of an island
|
|
1906
|
+
* that wakes late (`visible`, `idle`, `interaction`). Every request waits for it before the first
|
|
1907
|
+
* byte, for markup the visitor may never scroll to. Deferred (`render: 'deferred'`), the island
|
|
1908
|
+
* renders on the server when it wakes, with its own call: the page stops waiting. Saving: the
|
|
1909
|
+
* stretch the call held the render alone.
|
|
1910
|
+
*/
|
|
1911
|
+
function late_island_pattern(items) {
|
|
1912
|
+
const costly = items.filter((i) => i.alone >= MIN_SAVE_MS).sort((a, b) => b.alone - a.alone);
|
|
1913
|
+
if (!costly.length) return void 0;
|
|
1914
|
+
const top = costly[0];
|
|
1915
|
+
const save = costly.reduce((s, i) => s + i.alone, 0);
|
|
1916
|
+
return {
|
|
1917
|
+
kind: "late-island-wait",
|
|
1918
|
+
title: `The page waits for data only a late island shows${places(costly.length)}`,
|
|
1919
|
+
fix: `The data feeds only an island that wakes later (${top.wake}), yet every request waits for it before sending a byte. Move the call into the island (a remote function it awaits) and import it deferred — \`with { render: 'deferred', fetch: '${top.wake}' }\`: the page renders without it, and the island's HTML comes from the server when it is due.`,
|
|
1920
|
+
example: {
|
|
1921
|
+
before: `import ${top.island} from './${top.island}.svelte' with { wake: '${top.wake}' };\n<${top.island} ${top.key}={data.${top.key}} />`,
|
|
1922
|
+
after: `import ${top.island} from './${top.island}.svelte' with { render: 'deferred', fetch: '${top.wake}' };\n<${top.island} /> <!-- it fetches ${top.key} itself -->`
|
|
1923
|
+
},
|
|
1924
|
+
sites: costly.map((i) => ({
|
|
1925
|
+
path: i.at.path,
|
|
1926
|
+
file: i.at.file,
|
|
1927
|
+
line: i.at.line,
|
|
1928
|
+
code: i.at.code,
|
|
1929
|
+
cpu_ms: 0,
|
|
1930
|
+
alloc_bytes: 0,
|
|
1931
|
+
gc_ms: 0,
|
|
1932
|
+
in_loop: false,
|
|
1933
|
+
wait_ms: r2(i.ms),
|
|
1934
|
+
target: i.call,
|
|
1935
|
+
fn_name: i.island,
|
|
1936
|
+
via: [{
|
|
1937
|
+
path: i.use.path,
|
|
1938
|
+
file: i.use.file,
|
|
1939
|
+
line: i.use.line,
|
|
1940
|
+
code: i.use.code,
|
|
1941
|
+
in_loop: false
|
|
1942
|
+
}]
|
|
1943
|
+
})),
|
|
1944
|
+
cost_ms: r2(costly.reduce((s, i) => s + i.ms, 0)),
|
|
1945
|
+
alloc_bytes: 0,
|
|
1946
|
+
save_ms: r2(save),
|
|
1947
|
+
wait: true,
|
|
1948
|
+
evidence: `${top.call} (${r2(top.ms)} ms, ${r2(top.alone)} ms of it alone) fills ${top.key}, used only by ${top.island} (wake: ${top.wake}) at ${top.use.file}:${top.use.line}`
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
const NOT_CALLS = /* @__PURE__ */ new Set([
|
|
1952
|
+
"if",
|
|
1953
|
+
"for",
|
|
1954
|
+
"while",
|
|
1955
|
+
"switch",
|
|
1956
|
+
"return",
|
|
1957
|
+
"function",
|
|
1958
|
+
"catch",
|
|
1959
|
+
"typeof",
|
|
1960
|
+
"await",
|
|
1961
|
+
"new",
|
|
1962
|
+
"super",
|
|
1963
|
+
"import",
|
|
1964
|
+
"require"
|
|
1965
|
+
]);
|
|
1966
|
+
/** `a/b/../c` → `a/c` (a path is all this needs; no file system) */
|
|
1967
|
+
function normalize(p) {
|
|
1968
|
+
const out = [];
|
|
1969
|
+
for (const seg of p.split("/")) if (seg === "..") out.pop();
|
|
1970
|
+
else if (seg !== ".") out.push(seg);
|
|
1971
|
+
return out.join("/");
|
|
1972
|
+
}
|
|
1973
|
+
/** Where `name` is defined for code in `path`: a `function name(` / `const name =` in the file
|
|
1974
|
+
* itself, else through its `import { name } from '…'` (relative or `$lib/`), in that file. */
|
|
1975
|
+
function find_definition(source, path, name) {
|
|
1976
|
+
const in_file = (p) => {
|
|
1977
|
+
const got = source(p, 1, 4e3);
|
|
1978
|
+
if (!got) return void 0;
|
|
1979
|
+
for (let i = 0; i < got.lines.length; i++) {
|
|
1980
|
+
const t = tokens(got.lines[i]);
|
|
1981
|
+
for (let k = 0; k + 2 < t.length; k++) {
|
|
1982
|
+
if (t[k] === "function" && t[k + 1] === name && t[k + 2] === "(") return got.start + i;
|
|
1983
|
+
if ((t[k] === "const" || t[k] === "let" || t[k] === "var") && t[k + 1] === name && t[k + 2] === "=") return got.start + i;
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
};
|
|
1987
|
+
const here = in_file(path);
|
|
1988
|
+
if (here) return {
|
|
1989
|
+
path,
|
|
1990
|
+
line: here
|
|
1991
|
+
};
|
|
1992
|
+
const head = source(path, 1, 400);
|
|
1993
|
+
if (!head) return void 0;
|
|
1994
|
+
let names_in_import = false;
|
|
1995
|
+
for (const raw of head.lines) {
|
|
1996
|
+
const t = tokens(raw);
|
|
1997
|
+
if (t.includes("import")) names_in_import = false;
|
|
1998
|
+
if (t.includes(name) && (t.includes("import") || names_in_import || t.includes("from"))) names_in_import = true;
|
|
1999
|
+
const from = raw.indexOf(" from ");
|
|
2000
|
+
if (from === -1 || !names_in_import) continue;
|
|
2001
|
+
names_in_import = false;
|
|
2002
|
+
const q = raw.slice(from + 6).trim();
|
|
2003
|
+
const spec = q.slice(1, q.indexOf(q[0], 1));
|
|
2004
|
+
let target;
|
|
2005
|
+
if (spec.startsWith(".")) target = normalize(path.slice(0, path.lastIndexOf("/") + 1) + spec);
|
|
2006
|
+
else if (spec.startsWith("$lib/")) {
|
|
2007
|
+
const src = path.lastIndexOf("/src/");
|
|
2008
|
+
if (src === -1) return void 0;
|
|
2009
|
+
target = path.slice(0, src + 5) + "lib/" + spec.slice(5);
|
|
2010
|
+
} else return void 0;
|
|
2011
|
+
for (const p of [
|
|
2012
|
+
target,
|
|
2013
|
+
target + ".ts",
|
|
2014
|
+
target + ".js",
|
|
2015
|
+
target + "/index.ts",
|
|
2016
|
+
target + "/index.js"
|
|
2017
|
+
]) {
|
|
2018
|
+
const line = in_file(p);
|
|
2019
|
+
if (line) return {
|
|
2020
|
+
path: p,
|
|
2021
|
+
line
|
|
2022
|
+
};
|
|
2023
|
+
}
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* The costly line shows no shape, but optimised code may have charged it someone else's time:
|
|
2029
|
+
* a loop header carries its body's (the body's calls inlined into it), a call carries the called
|
|
2030
|
+
* function's (inlined). Run the line rules over the loop's block, or the called app function's
|
|
2031
|
+
* body, with the costly line's cost and a per-item context, and return the first line that has a
|
|
2032
|
+
* shape. Library rules are left out: library time belongs to the line that was measured.
|
|
2033
|
+
*/
|
|
2034
|
+
function look_inside(source, l, t, body, svelte, ctx) {
|
|
2035
|
+
const try_lines = (path, start, lines, per_item, via) => {
|
|
2036
|
+
const locals = intl_locals(lines);
|
|
2037
|
+
const text = text_locals(lines);
|
|
2038
|
+
const rx_locals = regexp_locals(lines, start);
|
|
2039
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2040
|
+
const code = lines[i].trim();
|
|
2041
|
+
if (!code || code.startsWith("//") || code.startsWith("*") || code.startsWith("/*")) continue;
|
|
2042
|
+
const from = per_item ? 0 : loop_from(lines.slice(0, i + 1), path.endsWith(".svelte"));
|
|
2043
|
+
const c = {
|
|
2044
|
+
...ctx,
|
|
2045
|
+
loop: from >= 0,
|
|
2046
|
+
from,
|
|
2047
|
+
repeated: per_item || from >= 0,
|
|
2048
|
+
lib_ms: 0,
|
|
2049
|
+
pkg_ms: 0,
|
|
2050
|
+
lib_names: [],
|
|
2051
|
+
per_call_ms: void 0,
|
|
2052
|
+
per_call_bytes: void 0,
|
|
2053
|
+
intl_locals: locals,
|
|
2054
|
+
text_locals: text,
|
|
2055
|
+
regexp_locals: rx_locals,
|
|
2056
|
+
after: lines.slice(i + 1, i + 4)
|
|
2057
|
+
};
|
|
2058
|
+
const tl = tokens(lines[i]);
|
|
2059
|
+
for (const rule of RULES) {
|
|
2060
|
+
if (rule.kind === "library-per-item" || rule.kind === "sync-io") continue;
|
|
2061
|
+
if (rule.match(tl, c)) {
|
|
2062
|
+
const built = rule.kind === "regexp-per-call" && !constructs(tl, REGEXP) ? used_local_regexp(tl, rx_locals) : void 0;
|
|
2063
|
+
const said = built?.code ?? code;
|
|
2064
|
+
return {
|
|
2065
|
+
rule,
|
|
2066
|
+
path,
|
|
2067
|
+
line: built?.line ?? start + i,
|
|
2068
|
+
code: said.length > 140 ? said.slice(0, 139) + "…" : said,
|
|
2069
|
+
via
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
};
|
|
2075
|
+
if (t[0] === "for" || t[0] === "while") {
|
|
2076
|
+
const block = function_body(source, l.path, l.line, 120);
|
|
2077
|
+
if (block && block.lines.length > 1) {
|
|
2078
|
+
const hit = try_lines(l.path, block.start + 1, block.lines.slice(1), true, false);
|
|
2079
|
+
if (hit) return hit;
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
const per_item = ctx.repeated || ctx.loop || t.includes("sort") || t.includes("toSorted");
|
|
2083
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2084
|
+
for (let k = 0; k + 1 < t.length && seen.size < 3; k++) {
|
|
2085
|
+
const name = t[k];
|
|
2086
|
+
if (t[k + 1] !== "(" || seen.has(name) || NOT_CALLS.has(name) || t[k - 1] === "." || t[k - 1] === "?." || t[k - 1] === "new" || t[k - 1] === "function") continue;
|
|
2087
|
+
if (!is_word_start(name.charCodeAt(0))) continue;
|
|
2088
|
+
seen.add(name);
|
|
2089
|
+
const def = find_definition(source, l.path, name);
|
|
2090
|
+
if (!def || def.path === l.path && def.line === l.line) continue;
|
|
2091
|
+
const fb = function_body(source, def.path, def.line, 200);
|
|
2092
|
+
if (!fb) continue;
|
|
2093
|
+
const hit = try_lines(def.path, fb.start, fb.lines, per_item, true);
|
|
2094
|
+
if (hit) return hit;
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
/** a memo runs at least this often, and costs this much, before it is judged */
|
|
2098
|
+
const COLD_MIN_CALLS = 10;
|
|
2099
|
+
const COLD_MIN_MS = 1;
|
|
2100
|
+
/** hits under this share of calls: the cache does not work */
|
|
2101
|
+
const COLD_MAX_HIT_SHARE = .1;
|
|
2102
|
+
/**
|
|
2103
|
+
* THE CACHE THAT NEVER HITS: an app function with a memo shape — a lookup (`M.get(k)`,
|
|
2104
|
+
* `M.has(k)`), an early `return` of the hit, and a store (`M.set(k, …)`) after the work — whose
|
|
2105
|
+
* MISS PATH ran about as often as the function itself. Measured from V8's call counts, which
|
|
2106
|
+
* survive inlining: a helper the miss path calls once (not in a loop), and nothing else calls,
|
|
2107
|
+
* ran once per miss; its count against the memo's is the miss rate. (Block counts would say it
|
|
2108
|
+
* directly, but V8 only instruments functions compiled after they are switched on.)
|
|
2109
|
+
*/
|
|
2110
|
+
function find_cold_caches(input) {
|
|
2111
|
+
const by_name = /* @__PURE__ */ new Map();
|
|
2112
|
+
for (const [k, n] of Object.entries(input.call_counts)) {
|
|
2113
|
+
const at = k.indexOf("\0");
|
|
2114
|
+
if (k[at + 1] === "#") continue;
|
|
2115
|
+
const name = k.slice(0, at);
|
|
2116
|
+
by_name.set(name, by_name.has(name) ? null : n);
|
|
2117
|
+
}
|
|
2118
|
+
const fn_by_name = /* @__PURE__ */ new Map();
|
|
2119
|
+
for (const f of input.functions) if (!fn_by_name.has(f.name)) fn_by_name.set(f.name, f);
|
|
2120
|
+
const sites = [];
|
|
2121
|
+
for (const f of input.functions) {
|
|
2122
|
+
const calls = f.calls ?? 0;
|
|
2123
|
+
if (f.category !== "app" && f.category !== "component" || calls < COLD_MIN_CALLS || f.total_ms < COLD_MIN_MS || f.line <= 0) continue;
|
|
2124
|
+
const body = function_body(input.source, f.path, f.line, 200);
|
|
2125
|
+
if (!body) continue;
|
|
2126
|
+
let get_line = 0;
|
|
2127
|
+
let return_line = 0;
|
|
2128
|
+
let set_line = 0;
|
|
2129
|
+
let map = "";
|
|
2130
|
+
for (let i = 0; i < body.lines.length && !set_line; i++) {
|
|
2131
|
+
const t = tokens(body.lines[i]);
|
|
2132
|
+
const line = body.start + i;
|
|
2133
|
+
for (let k = 2; k + 1 < t.length; k++) {
|
|
2134
|
+
if (t[k - 1] !== "." || t[k + 1] !== "(") continue;
|
|
2135
|
+
if (!get_line && (t[k] === "get" || t[k] === "has")) {
|
|
2136
|
+
get_line = line;
|
|
2137
|
+
map = t[k - 2];
|
|
2138
|
+
} else if (get_line && t[k] === "set" && t[k - 2] === map) set_line = line;
|
|
2139
|
+
}
|
|
2140
|
+
if (get_line && !return_line && !set_line && t.includes("return")) return_line = line;
|
|
2141
|
+
}
|
|
2142
|
+
if (!get_line || !return_line || !set_line) continue;
|
|
2143
|
+
const miss = body.lines.slice(return_line - body.start + 1, set_line - body.start + 1);
|
|
2144
|
+
let misses;
|
|
2145
|
+
let helper = "";
|
|
2146
|
+
for (let i = 0; i < miss.length; i++) {
|
|
2147
|
+
if (loop_from(miss.slice(0, i + 1)) >= 0) continue;
|
|
2148
|
+
const t = tokens(miss[i]);
|
|
2149
|
+
for (let k = 0; k + 1 < t.length; k++) {
|
|
2150
|
+
const name = t[k];
|
|
2151
|
+
if (t[k + 1] !== "(" || NOT_CALLS.has(name) || t[k - 1] === "." || t[k - 1] === "?." || t[k - 1] === "new" || name === f.name) continue;
|
|
2152
|
+
if (!is_word_start(name.charCodeAt(0))) continue;
|
|
2153
|
+
const n = by_name.get(name);
|
|
2154
|
+
if (n === null || n === void 0 || n > calls) continue;
|
|
2155
|
+
const caller = fn_by_name.get(name)?.stacks?.[0]?.frames.find((x) => x.c === "app" || x.c === "component");
|
|
2156
|
+
if (caller && caller.n !== f.name) continue;
|
|
2157
|
+
if (misses === void 0 || n > misses) {
|
|
2158
|
+
misses = n;
|
|
2159
|
+
helper = name;
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
if (misses === void 0) continue;
|
|
2164
|
+
const hits = calls - misses;
|
|
2165
|
+
if (hits / calls >= COLD_MAX_HIT_SHARE) continue;
|
|
2166
|
+
const code = body.lines[get_line - body.start]?.trim() ?? "";
|
|
2167
|
+
sites.push({
|
|
2168
|
+
path: f.path,
|
|
2169
|
+
file: f.url || short_file(f.path),
|
|
2170
|
+
line: get_line,
|
|
2171
|
+
code: code.length > 140 ? code.slice(0, 139) + "…" : code,
|
|
2172
|
+
fn: f.key,
|
|
2173
|
+
fn_name: f.name,
|
|
2174
|
+
cpu_ms: f.total_ms,
|
|
2175
|
+
alloc_bytes: 0,
|
|
2176
|
+
gc_ms: 0,
|
|
2177
|
+
calls,
|
|
2178
|
+
hits: Math.max(0, hits),
|
|
2179
|
+
in_loop: false,
|
|
2180
|
+
target: helper,
|
|
2181
|
+
...ctx_field(input.source, f.path, get_line)
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
if (!sites.length) return void 0;
|
|
2185
|
+
sites.sort((a, b) => b.cpu_ms - a.cpu_ms);
|
|
2186
|
+
const top = sites[0];
|
|
2187
|
+
const cost = sites.reduce((s, x) => s + x.cpu_ms, 0);
|
|
2188
|
+
return {
|
|
2189
|
+
kind: "cache-never-hits",
|
|
2190
|
+
title: sites.every((s) => s.hits === 0) ? `A cache never hits${places(sites.length)}` : `A cache almost never hits${places(sites.length)}`,
|
|
2191
|
+
fix: "Every call misses, does the work, and stores a result nobody reads back. Check the key: a cache keyed by an object only hits for that same object, so a fresh copy ({ ...p }, structuredClone) or a new instance per call misses every time. Key it by a stable id, pass the original object, share one cache, or remove it.",
|
|
2192
|
+
example: {
|
|
2193
|
+
before: "const vm = new WeakMap(); … toVM({ ...product }) // a new object every call: always a miss",
|
|
2194
|
+
after: "const vm = new Map(); … vm.get(product.id) ?? vm.set(product.id, build(product)).get(product.id)"
|
|
2195
|
+
},
|
|
2196
|
+
sites,
|
|
2197
|
+
cost_ms: r2(cost),
|
|
2198
|
+
alloc_bytes: 0,
|
|
2199
|
+
save_ms: 0,
|
|
2200
|
+
evidence: `${top.fn_name} ran ${top.calls} times in one render and its cache answered ${top.hits} of them (${top.target}, which only the miss path calls, ran ${top.calls - top.hits} times)`
|
|
2201
|
+
};
|
|
2202
|
+
}
|
|
2203
|
+
export { PATTERN_LABEL, almost_same_document_pattern, cache_rules, counted_sync_io, find_cold_caches, find_patterns, find_same_answers, find_wait_patterns, fix_impact, heap_growth, in_loop, is_clear_leak, is_flat, late_island_pattern, loop_from, render_per_item_pattern, same_document_pattern, same_work_pattern, seed_whole_pattern, stable_answers, tokens, varied_answers, wait_save_on_path };
|