benchforge 0.1.11 → 0.2.4
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 +20 -0
- package/README.md +99 -294
- package/bin/benchforge +1 -2
- package/dist/AnalyzeArchive-8NCJhmhS.mjs +145 -0
- package/dist/AnalyzeArchive-8NCJhmhS.mjs.map +1 -0
- package/dist/BenchMatrix-BZVrBB_h.mjs +1050 -0
- package/dist/BenchMatrix-BZVrBB_h.mjs.map +1 -0
- package/dist/{BenchRunner-BzyUfiyB.d.mts → BenchRunner-DglX1NOn.d.mts} +119 -66
- package/dist/CoverageSampler-D5T9DRqe.mjs +27 -0
- package/dist/CoverageSampler-D5T9DRqe.mjs.map +1 -0
- package/dist/Formatters-BWj3d4sv.mjs +95 -0
- package/dist/Formatters-BWj3d4sv.mjs.map +1 -0
- package/dist/{HeapSampler-B8dtKHn1.mjs → HeapSampler-Dq-hpXem.mjs} +4 -4
- package/dist/HeapSampler-Dq-hpXem.mjs.map +1 -0
- package/dist/RunBenchCLI-C17DrJz8.mjs +3075 -0
- package/dist/RunBenchCLI-C17DrJz8.mjs.map +1 -0
- package/dist/StatisticalUtils-BD92crgM.mjs +255 -0
- package/dist/StatisticalUtils-BD92crgM.mjs.map +1 -0
- package/dist/TimeSampler-Ds8n7l2B.mjs +29 -0
- package/dist/TimeSampler-Ds8n7l2B.mjs.map +1 -0
- package/dist/ViewerServer-BJhdnxlN.mjs +639 -0
- package/dist/ViewerServer-BJhdnxlN.mjs.map +1 -0
- package/dist/ViewerServer-CuMNdNBz.mjs +2 -0
- package/dist/bin/benchforge.mjs +4 -5
- package/dist/bin/benchforge.mjs.map +1 -1
- package/dist/index.d.mts +711 -558
- package/dist/index.mjs +98 -3
- package/dist/index.mjs.map +1 -0
- package/dist/runners/WorkerScript.d.mts +12 -4
- package/dist/runners/WorkerScript.mjs +77 -105
- package/dist/runners/WorkerScript.mjs.map +1 -1
- package/dist/viewer/assets/CIPlot-BkOvMoMa.js +1 -0
- package/dist/viewer/assets/HistogramKde-CmSyUFY0.js +1 -0
- package/dist/viewer/assets/LegendUtils-BJpbn_jr.js +55 -0
- package/dist/viewer/assets/SampleTimeSeries-C4VBhXr3.js +1 -0
- package/dist/viewer/assets/index-Br9bp_cX.js +153 -0
- package/dist/viewer/assets/index-NzXXe_CC.css +1 -0
- package/dist/viewer/index.html +19 -0
- package/dist/viewer/speedscope/LICENSE +21 -0
- package/dist/viewer/speedscope/SourceCodePro-Regular.ttf-ILST5JV6.woff2 +0 -0
- package/dist/viewer/speedscope/favicon-16x16-V2DMIAZS.js +2 -0
- package/dist/viewer/speedscope/favicon-16x16-V2DMIAZS.js.map +7 -0
- package/dist/viewer/speedscope/favicon-16x16-VSI62OPJ.png +0 -0
- package/dist/viewer/speedscope/favicon-32x32-3EB2YCUY.png +0 -0
- package/dist/viewer/speedscope/favicon-32x32-THY3JDJL.js +2 -0
- package/dist/viewer/speedscope/favicon-32x32-THY3JDJL.js.map +7 -0
- package/dist/viewer/speedscope/favicon-FOKUP5Y5.ico +0 -0
- package/dist/viewer/speedscope/favicon-M34RF7BI.js +2 -0
- package/dist/viewer/speedscope/favicon-M34RF7BI.js.map +7 -0
- package/dist/viewer/speedscope/file-format-schema.json +274 -0
- package/dist/viewer/speedscope/index.html +19 -0
- package/dist/viewer/speedscope/jfrview_bg-BLJXNNQB.wasm +0 -0
- package/dist/viewer/speedscope/perf-vertx-stacks-01-collapsed-all-ZNUIGAJL.txt +199 -0
- package/dist/viewer/speedscope/release.txt +3 -0
- package/dist/viewer/speedscope/source-code-pro.LICENSE.md +93 -0
- package/dist/viewer/speedscope/speedscope-GHPHNKXC.css +2 -0
- package/dist/viewer/speedscope/speedscope-GHPHNKXC.css.map +7 -0
- package/dist/viewer/speedscope/speedscope-QZFMJ7VP.js +212 -0
- package/dist/viewer/speedscope/speedscope-QZFMJ7VP.js.map +7 -0
- package/package.json +52 -27
- package/src/bin/benchforge.ts +2 -2
- package/src/cli/AnalyzeArchive.ts +232 -0
- package/src/cli/BrowserBench.ts +322 -0
- package/src/cli/CliArgs.ts +164 -51
- package/src/cli/CliExport.ts +179 -0
- package/src/cli/CliOptions.ts +147 -0
- package/src/cli/CliReport.ts +197 -0
- package/src/cli/FilterBenchmarks.ts +18 -30
- package/src/cli/RunBenchCLI.ts +132 -866
- package/src/cli/SuiteRunner.ts +160 -0
- package/src/cli/ViewerServer.ts +282 -0
- package/src/export/AllocExport.ts +121 -0
- package/src/export/ArchiveExport.ts +146 -0
- package/src/export/ArchiveFormat.ts +50 -0
- package/src/export/CoverageExport.ts +148 -0
- package/src/export/EditorUri.ts +10 -0
- package/src/export/PerfettoExport.ts +64 -99
- package/src/export/SpeedscopeTypes.ts +98 -0
- package/src/export/TimeExport.ts +115 -0
- package/src/index.ts +86 -67
- package/src/matrix/BenchMatrix.ts +230 -0
- package/src/matrix/CaseLoader.ts +8 -6
- package/src/matrix/MatrixDirRunner.ts +153 -0
- package/src/matrix/MatrixFilter.ts +49 -47
- package/src/matrix/MatrixInlineRunner.ts +50 -0
- package/src/matrix/MatrixReport.ts +90 -250
- package/src/matrix/VariantLoader.ts +5 -5
- package/src/profiling/browser/BenchLoop.ts +51 -0
- package/src/profiling/browser/BrowserCDP.ts +133 -0
- package/src/profiling/browser/BrowserGcStats.ts +33 -0
- package/src/profiling/browser/BrowserProfiler.ts +160 -0
- package/src/profiling/browser/CdpClient.ts +82 -0
- package/src/profiling/browser/CdpPage.ts +138 -0
- package/src/profiling/browser/ChromeLauncher.ts +158 -0
- package/src/profiling/browser/ChromeTraceEvent.ts +28 -0
- package/src/profiling/browser/PageLoadMode.ts +61 -0
- package/src/profiling/node/CoverageSampler.ts +27 -0
- package/src/profiling/node/CoverageTypes.ts +23 -0
- package/src/profiling/node/HeapSampleReport.ts +261 -0
- package/src/{heap-sample → profiling/node}/HeapSampler.ts +1 -2
- package/src/{heap-sample → profiling/node}/ResolvedProfile.ts +18 -9
- package/src/profiling/node/TimeSampler.ts +57 -0
- package/src/report/BenchmarkReport.ts +146 -0
- package/src/report/Colors.ts +9 -0
- package/src/report/Formatters.ts +110 -0
- package/src/report/GcSections.ts +151 -0
- package/src/{GitUtils.ts → report/GitUtils.ts} +18 -19
- package/src/report/HtmlReport.ts +223 -0
- package/src/report/ParseStats.ts +73 -0
- package/src/report/StandardSections.ts +147 -0
- package/src/report/ViewerSections.ts +286 -0
- package/src/report/text/TableReport.ts +253 -0
- package/src/report/text/TextReport.ts +123 -0
- package/src/runners/AdaptiveWrapper.ts +116 -236
- package/src/runners/BenchRunner.ts +20 -15
- package/src/{Benchmark.ts → runners/BenchmarkSpec.ts} +5 -6
- package/src/runners/CreateRunner.ts +5 -7
- package/src/runners/GcStats.ts +47 -50
- package/src/{MeasuredResults.ts → runners/MeasuredResults.ts} +43 -37
- package/src/runners/MergeBatches.ts +123 -0
- package/src/{NodeGC.ts → runners/NodeGC.ts} +2 -3
- package/src/runners/RunnerOrchestrator.ts +127 -243
- package/src/runners/RunnerUtils.ts +75 -1
- package/src/runners/SampleStats.ts +100 -0
- package/src/runners/TimingRunner.ts +244 -0
- package/src/runners/TimingUtils.ts +3 -2
- package/src/runners/WorkerScript.ts +135 -151
- package/src/stats/BootstrapDifference.ts +282 -0
- package/src/{PermutationTest.ts → stats/PermutationTest.ts} +8 -17
- package/src/stats/StatisticalUtils.ts +445 -0
- package/src/{tests → test}/AdaptiveConvergence.test.ts +10 -10
- package/src/test/AdaptiveRunner.test.ts +39 -41
- package/src/{tests → test}/AdaptiveSampling.test.ts +9 -9
- package/src/test/AdaptiveStatistics.integration.ts +2 -2
- package/src/{tests → test}/BenchMatrix.test.ts +19 -16
- package/src/test/BenchmarkReport.test.ts +63 -13
- package/src/test/BrowserBench.e2e.test.ts +186 -17
- package/src/test/BrowserBench.test.ts +10 -5
- package/src/test/BuildTimeSection.test.ts +130 -0
- package/src/test/CapSamples.test.ts +82 -0
- package/src/test/CoverageExport.test.ts +115 -0
- package/src/test/CoverageSampler.test.ts +33 -0
- package/src/test/HeapAttribution.test.ts +14 -14
- package/src/{tests → test}/MatrixFilter.test.ts +1 -1
- package/src/{tests → test}/MatrixReport.test.ts +1 -1
- package/src/test/PermutationTest.test.ts +1 -1
- package/src/{tests → test}/RealDataValidation.test.ts +6 -6
- package/src/test/RunBenchCLI.test.ts +39 -38
- package/src/test/RunnerOrchestrator.test.ts +12 -12
- package/src/test/StatisticalUtils.test.ts +48 -12
- package/src/{table-util/test → test}/TableReport.test.ts +2 -2
- package/src/test/TestUtils.ts +12 -7
- package/src/test/TimeExport.test.ts +139 -0
- package/src/test/TimeSampler.test.ts +37 -0
- package/src/test/ViewerLive.e2e.test.ts +159 -0
- package/src/test/ViewerStatic.static.e2e.test.ts +137 -0
- package/src/{tests → test}/fixtures/baseline/impl.ts +1 -1
- package/src/{tests → test}/fixtures/bevy30-samples.ts +3 -1
- package/src/test/fixtures/cases/asyncCases.ts +9 -0
- package/src/{tests → test}/fixtures/cases/cases.ts +5 -2
- package/src/test/fixtures/cases/variants/product.ts +2 -0
- package/src/test/fixtures/cases/variants/sum.ts +2 -0
- package/src/test/fixtures/discover/fast.ts +1 -0
- package/src/{tests → test}/fixtures/discover/slow.ts +1 -1
- package/src/test/fixtures/invalid/bad.ts +1 -0
- package/src/test/fixtures/loader/fast.ts +1 -0
- package/src/{tests → test}/fixtures/loader/slow.ts +1 -1
- package/src/test/fixtures/loader/stateful.ts +2 -0
- package/src/test/fixtures/stateful/stateful.ts +2 -0
- package/src/test/fixtures/variants/extra.ts +1 -0
- package/src/test/fixtures/variants/impl.ts +1 -0
- package/src/test/fixtures/worker/fast.ts +1 -0
- package/src/{tests → test}/fixtures/worker/slow.ts +1 -1
- package/src/viewer/DateFormat.ts +30 -0
- package/src/viewer/Helpers.ts +23 -0
- package/src/viewer/LineData.ts +120 -0
- package/src/viewer/Providers.ts +191 -0
- package/src/viewer/ReportData.ts +123 -0
- package/src/viewer/State.ts +49 -0
- package/src/viewer/Theme.ts +15 -0
- package/src/viewer/components/App.tsx +73 -0
- package/src/viewer/components/DropZone.tsx +71 -0
- package/src/viewer/components/LazyPlot.ts +33 -0
- package/src/viewer/components/SamplesPanel.tsx +214 -0
- package/src/viewer/components/Shell.tsx +26 -0
- package/src/viewer/components/SourcePanel.tsx +216 -0
- package/src/viewer/components/SummaryPanel.tsx +332 -0
- package/src/viewer/components/TabBar.tsx +131 -0
- package/src/viewer/components/TabContent.tsx +46 -0
- package/src/viewer/components/ThemeToggle.tsx +50 -0
- package/src/viewer/index.html +20 -0
- package/src/viewer/main.tsx +4 -0
- package/src/viewer/plots/CIPlot.ts +313 -0
- package/src/{html/browser → viewer/plots}/HistogramKde.ts +33 -38
- package/src/viewer/plots/LegendUtils.ts +134 -0
- package/src/viewer/plots/PlotTypes.ts +85 -0
- package/src/viewer/plots/RenderPlots.ts +230 -0
- package/src/viewer/plots/SampleTimeSeries.ts +306 -0
- package/src/viewer/plots/SvgHelpers.ts +136 -0
- package/src/viewer/plots/TimeSeriesMarks.ts +319 -0
- package/src/viewer/report.css +427 -0
- package/src/viewer/shell.css +357 -0
- package/src/viewer/tsconfig.json +11 -0
- package/dist/BrowserHeapSampler-B6asLKWQ.mjs +0 -202
- package/dist/BrowserHeapSampler-B6asLKWQ.mjs.map +0 -1
- package/dist/GcStats-wX7Xyblu.mjs +0 -77
- package/dist/GcStats-wX7Xyblu.mjs.map +0 -1
- package/dist/HeapSampler-B8dtKHn1.mjs.map +0 -1
- package/dist/TimingUtils-DwOwkc8G.mjs +0 -597
- package/dist/TimingUtils-DwOwkc8G.mjs.map +0 -1
- package/dist/browser/index.js +0 -914
- package/dist/src-B-DDaCa9.mjs +0 -3108
- package/dist/src-B-DDaCa9.mjs.map +0 -1
- package/src/BenchMatrix.ts +0 -380
- package/src/BenchmarkReport.ts +0 -161
- package/src/HtmlDataPrep.ts +0 -148
- package/src/StandardSections.ts +0 -261
- package/src/StatisticalUtils.ts +0 -175
- package/src/TypeUtil.ts +0 -8
- package/src/browser/BrowserGcStats.ts +0 -44
- package/src/browser/BrowserHeapSampler.ts +0 -271
- package/src/export/JsonExport.ts +0 -103
- package/src/export/JsonFormat.ts +0 -91
- package/src/export/SpeedscopeExport.ts +0 -202
- package/src/heap-sample/HeapSampleReport.ts +0 -269
- package/src/html/HtmlReport.ts +0 -131
- package/src/html/HtmlTemplate.ts +0 -284
- package/src/html/Types.ts +0 -88
- package/src/html/browser/CIPlot.ts +0 -287
- package/src/html/browser/LegendUtils.ts +0 -163
- package/src/html/browser/RenderPlots.ts +0 -263
- package/src/html/browser/SampleTimeSeries.ts +0 -389
- package/src/html/browser/Types.ts +0 -96
- package/src/html/browser/index.ts +0 -1
- package/src/html/index.ts +0 -17
- package/src/runners/BasicRunner.ts +0 -364
- package/src/table-util/ConvergenceFormatters.ts +0 -19
- package/src/table-util/Formatters.ts +0 -157
- package/src/table-util/README.md +0 -70
- package/src/table-util/TableReport.ts +0 -293
- package/src/tests/fixtures/cases/asyncCases.ts +0 -7
- package/src/tests/fixtures/cases/variants/product.ts +0 -2
- package/src/tests/fixtures/cases/variants/sum.ts +0 -2
- package/src/tests/fixtures/discover/fast.ts +0 -1
- package/src/tests/fixtures/invalid/bad.ts +0 -1
- package/src/tests/fixtures/loader/fast.ts +0 -1
- package/src/tests/fixtures/loader/stateful.ts +0 -2
- package/src/tests/fixtures/stateful/stateful.ts +0 -2
- package/src/tests/fixtures/variants/extra.ts +0 -1
- package/src/tests/fixtures/variants/impl.ts +0 -1
- package/src/tests/fixtures/worker/fast.ts +0 -1
- /package/src/{table-util/test → test}/TableValueExtractor.test.ts +0 -0
- /package/src/{table-util/test → test}/TableValueExtractor.ts +0 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { GitVersion } from "../report/GitUtils.ts";
|
|
2
|
+
import type { PausePoint } from "../runners/MeasuredResults.ts";
|
|
3
|
+
import type {
|
|
4
|
+
CILevel,
|
|
5
|
+
DifferenceCI,
|
|
6
|
+
HistogramBin,
|
|
7
|
+
} from "../stats/StatisticalUtils.ts";
|
|
8
|
+
|
|
9
|
+
/** Top-level data structure for the HTML benchmark report. */
|
|
10
|
+
export interface ReportData {
|
|
11
|
+
groups: BenchmarkGroup[];
|
|
12
|
+
metadata: {
|
|
13
|
+
timestamp: string;
|
|
14
|
+
bencherVersion: string;
|
|
15
|
+
cliArgs?: Record<string, unknown>;
|
|
16
|
+
cliDefaults?: Record<string, unknown>;
|
|
17
|
+
gcTrackingEnabled?: boolean;
|
|
18
|
+
currentVersion?: GitVersion;
|
|
19
|
+
baselineVersion?: GitVersion;
|
|
20
|
+
environment?: {
|
|
21
|
+
node: string;
|
|
22
|
+
platform: string;
|
|
23
|
+
arch: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A named group of benchmarks, optionally compared against a baseline. */
|
|
29
|
+
export interface BenchmarkGroup {
|
|
30
|
+
name: string;
|
|
31
|
+
baseline?: BenchmarkEntry;
|
|
32
|
+
benchmarks: BenchmarkEntry[];
|
|
33
|
+
warnings?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One benchmark's raw data, statistics, and optional comparison results. */
|
|
37
|
+
export interface BenchmarkEntry {
|
|
38
|
+
name: string;
|
|
39
|
+
samples: number[];
|
|
40
|
+
warmupSamples?: number[];
|
|
41
|
+
allocationSamples?: number[];
|
|
42
|
+
heapSamples?: number[];
|
|
43
|
+
gcEvents?: GcEvent[];
|
|
44
|
+
optSamples?: number[];
|
|
45
|
+
pausePoints?: PausePoint[];
|
|
46
|
+
batchOffsets?: number[];
|
|
47
|
+
stats: BenchmarkStats;
|
|
48
|
+
heapSize?: { min: number; max: number; avg: number };
|
|
49
|
+
totalTime?: number;
|
|
50
|
+
sections?: ViewerSection[];
|
|
51
|
+
coverageSummary?: CoverageSummary;
|
|
52
|
+
heapSummary?: HeapSummary;
|
|
53
|
+
comparisonCI?: DifferenceCI;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Summary percentile statistics for a benchmark's samples. */
|
|
57
|
+
export interface BenchmarkStats {
|
|
58
|
+
min: number;
|
|
59
|
+
max: number;
|
|
60
|
+
avg: number;
|
|
61
|
+
p50: number;
|
|
62
|
+
p75: number;
|
|
63
|
+
p99: number;
|
|
64
|
+
p999: number;
|
|
65
|
+
cv?: number;
|
|
66
|
+
mad?: number;
|
|
67
|
+
outlierRate?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A section of related stats for the viewer (e.g., "Lines / Sec", "GC"). */
|
|
71
|
+
export interface ViewerSection {
|
|
72
|
+
title: string;
|
|
73
|
+
tabLink?: string;
|
|
74
|
+
rows: ViewerRow[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A stat row with per-run values and optional comparison CI. */
|
|
78
|
+
export interface ViewerRow {
|
|
79
|
+
label: string;
|
|
80
|
+
entries: ViewerEntry[];
|
|
81
|
+
comparisonCI?: DifferenceCI;
|
|
82
|
+
shared?: boolean;
|
|
83
|
+
/** First comparable row with a statKind in the section. */
|
|
84
|
+
primary?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A single run's value for a stat. */
|
|
88
|
+
export interface ViewerEntry {
|
|
89
|
+
runName: string;
|
|
90
|
+
value: string;
|
|
91
|
+
bootstrapCI?: BootstrapCIData;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Bootstrap CI data for inline visualization. */
|
|
95
|
+
export interface BootstrapCIData {
|
|
96
|
+
estimate: number;
|
|
97
|
+
ci: [number, number];
|
|
98
|
+
histogram: HistogramBin[];
|
|
99
|
+
/** Formatted CI bounds for display (e.g., ["0.12ms", "0.15ms"]) */
|
|
100
|
+
ciLabels?: [string, string];
|
|
101
|
+
/** Block-level (between-run) or sample-level (within-run) resampling */
|
|
102
|
+
ciLevel?: CILevel;
|
|
103
|
+
/** false when batch count is too low for reliable CI */
|
|
104
|
+
ciReliable?: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Summary of coverage/call-count data. */
|
|
108
|
+
export interface CoverageSummary {
|
|
109
|
+
functionCount: number;
|
|
110
|
+
totalCalls: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Summary of heap allocation profile. */
|
|
114
|
+
export interface HeapSummary {
|
|
115
|
+
totalBytes: number;
|
|
116
|
+
userBytes: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A garbage collection event with timing relative to the benchmark start. */
|
|
120
|
+
export interface GcEvent {
|
|
121
|
+
offset: number;
|
|
122
|
+
duration: number;
|
|
123
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { signal } from "@preact/signals";
|
|
2
|
+
import type { DataProvider } from "./Providers.ts";
|
|
3
|
+
import type { ReportData } from "./ReportData.ts";
|
|
4
|
+
|
|
5
|
+
/** Tracked state for an open source-code tab in the viewer. */
|
|
6
|
+
export interface SourceTabState {
|
|
7
|
+
id: string;
|
|
8
|
+
file: string;
|
|
9
|
+
line: number;
|
|
10
|
+
col: number;
|
|
11
|
+
generation: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** User color-scheme preference: follow OS, or force light/dark. */
|
|
15
|
+
export type ThemePreference = "system" | "light" | "dark";
|
|
16
|
+
|
|
17
|
+
/** Active data source (server or archive). */
|
|
18
|
+
export const provider = signal<DataProvider | null>(null);
|
|
19
|
+
|
|
20
|
+
/** Parsed report data from the provider. */
|
|
21
|
+
export const reportData = signal<ReportData | null>(null);
|
|
22
|
+
|
|
23
|
+
/** Currently visible tab id. */
|
|
24
|
+
export const activeTabId = signal("summary");
|
|
25
|
+
|
|
26
|
+
/** Whether sample data has been loaded for the samples tab. */
|
|
27
|
+
export const samplesLoaded = signal(false);
|
|
28
|
+
|
|
29
|
+
/** Error info when a `?url=` archive fetch fails. */
|
|
30
|
+
export const urlError = signal<{ url: string; detail: string } | null>(null);
|
|
31
|
+
|
|
32
|
+
/** Open source-code tabs. */
|
|
33
|
+
export const sourceTabs = signal<SourceTabState[]>([]);
|
|
34
|
+
|
|
35
|
+
const cookieTheme = document.cookie.match(/(?:^|; )theme=(light|dark)/);
|
|
36
|
+
|
|
37
|
+
/** User's light/dark theme preference, initialized from cookie. */
|
|
38
|
+
export const themePreference = signal<ThemePreference>(
|
|
39
|
+
(cookieTheme?.[1] as ThemePreference) ?? "system",
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/** Pick the best default tab based on available data. */
|
|
43
|
+
export function defaultTabId(): string {
|
|
44
|
+
const config = provider.value?.config;
|
|
45
|
+
if (config?.hasReport) return "summary";
|
|
46
|
+
if (config?.hasProfile) return "flamechart";
|
|
47
|
+
if (config?.hasTimeProfile) return "time-flamechart";
|
|
48
|
+
return "summary";
|
|
49
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ThemePreference, themePreference } from "./State.ts";
|
|
2
|
+
|
|
3
|
+
/** Apply a theme preference, persisting to a cookie and updating the document. */
|
|
4
|
+
export function setTheme(pref: ThemePreference): void {
|
|
5
|
+
themePreference.value = pref;
|
|
6
|
+
if (pref === "system") {
|
|
7
|
+
delete document.documentElement.dataset.theme;
|
|
8
|
+
// biome-ignore lint/suspicious/noDocumentCookie: no alternative API for setting cookies
|
|
9
|
+
document.cookie = "theme=; max-age=0; path=/; SameSite=Lax";
|
|
10
|
+
} else {
|
|
11
|
+
document.documentElement.dataset.theme = pref;
|
|
12
|
+
// biome-ignore lint/suspicious/noDocumentCookie: no alternative API for setting cookies
|
|
13
|
+
document.cookie = `theme=${pref}; max-age=31536000; path=/; SameSite=Lax`;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useEffect, useState } from "preact/hooks";
|
|
2
|
+
import type { DataProvider } from "../Providers.ts";
|
|
3
|
+
import {
|
|
4
|
+
type ArchiveData,
|
|
5
|
+
ArchiveProvider,
|
|
6
|
+
ServerProvider,
|
|
7
|
+
} from "../Providers.ts";
|
|
8
|
+
import {
|
|
9
|
+
activeTabId,
|
|
10
|
+
defaultTabId,
|
|
11
|
+
provider,
|
|
12
|
+
reportData,
|
|
13
|
+
samplesLoaded,
|
|
14
|
+
sourceTabs,
|
|
15
|
+
urlError,
|
|
16
|
+
} from "../State.ts";
|
|
17
|
+
import { DropZone } from "./DropZone.tsx";
|
|
18
|
+
import { Shell } from "./Shell.tsx";
|
|
19
|
+
|
|
20
|
+
/** Reset viewer state and activate the appropriate default tab for the provider. */
|
|
21
|
+
export function initViewer(p: DataProvider): void {
|
|
22
|
+
provider.value = p;
|
|
23
|
+
reportData.value = null;
|
|
24
|
+
samplesLoaded.value = false;
|
|
25
|
+
urlError.value = null;
|
|
26
|
+
sourceTabs.value = [];
|
|
27
|
+
activeTabId.value = defaultTabId();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function App() {
|
|
31
|
+
const [resolved, setResolved] = useState(false);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
resolve().then(() => setResolved(true));
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
if (provider.value) return <Shell />;
|
|
38
|
+
if (resolved) return <DropZone />;
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Try archive URL param, preloaded data, then dev server -- first match wins. */
|
|
43
|
+
async function resolve(): Promise<void> {
|
|
44
|
+
const archiveUrl = new URLSearchParams(window.location.search).get("url");
|
|
45
|
+
if (archiveUrl) {
|
|
46
|
+
try {
|
|
47
|
+
const resp = await fetch(archiveUrl);
|
|
48
|
+
if (resp.ok) {
|
|
49
|
+
initViewer(new ArchiveProvider((await resp.json()) as ArchiveData));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
urlError.value = { url: archiveUrl, detail: `(${resp.status})` };
|
|
53
|
+
} catch {
|
|
54
|
+
urlError.value = {
|
|
55
|
+
url: archiveUrl,
|
|
56
|
+
detail: "(Perhaps CORS or network error?)",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const preloaded = (window as unknown as Record<string, unknown>)
|
|
62
|
+
.__benchforgeArchive as ArchiveData | undefined;
|
|
63
|
+
if (preloaded) {
|
|
64
|
+
initViewer(new ArchiveProvider(preloaded));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
initViewer(await ServerProvider.create());
|
|
70
|
+
} catch {
|
|
71
|
+
// No server available
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useRef, useState } from "preact/hooks";
|
|
2
|
+
import { type ArchiveData, ArchiveProvider } from "../Providers.ts";
|
|
3
|
+
import { urlError } from "../State.ts";
|
|
4
|
+
import { initViewer } from "./App.tsx";
|
|
5
|
+
|
|
6
|
+
/** Landing page for loading `.benchforge` archive files via drag-drop or file picker. */
|
|
7
|
+
export function DropZone() {
|
|
8
|
+
const [dragOver, setDragOver] = useState(false);
|
|
9
|
+
const [error, setError] = useState<string | null>(null);
|
|
10
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
11
|
+
|
|
12
|
+
/** Parse an archive JSON file and initialize the viewer with its data. */
|
|
13
|
+
async function loadFile(file: File): Promise<void> {
|
|
14
|
+
try {
|
|
15
|
+
const text = await file.text();
|
|
16
|
+
const archive = JSON.parse(text) as ArchiveData;
|
|
17
|
+
initViewer(new ArchiveProvider(archive));
|
|
18
|
+
} catch (err) {
|
|
19
|
+
console.error("Failed to load archive:", err);
|
|
20
|
+
setError(String(err));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
class={`drop-zone${dragOver ? " drag-over" : ""}`}
|
|
27
|
+
onDragOver={e => {
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
setDragOver(true);
|
|
30
|
+
}}
|
|
31
|
+
onDragLeave={() => setDragOver(false)}
|
|
32
|
+
onDrop={async e => {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
setDragOver(false);
|
|
35
|
+
const file = e.dataTransfer?.files[0];
|
|
36
|
+
if (file) loadFile(file);
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
<div class="drop-zone-content">
|
|
40
|
+
<h2>Benchforge Viewer</h2>
|
|
41
|
+
<p>
|
|
42
|
+
Drop a <code>.benchforge</code> file here to view results
|
|
43
|
+
</p>
|
|
44
|
+
<div class="drop-zone-divider">or</div>
|
|
45
|
+
<label class="drop-zone-browse">
|
|
46
|
+
Browse files
|
|
47
|
+
<input
|
|
48
|
+
ref={inputRef}
|
|
49
|
+
type="file"
|
|
50
|
+
accept=".benchforge"
|
|
51
|
+
hidden
|
|
52
|
+
onChange={() => {
|
|
53
|
+
const file = inputRef.current?.files?.[0];
|
|
54
|
+
if (file) loadFile(file);
|
|
55
|
+
}}
|
|
56
|
+
/>
|
|
57
|
+
</label>
|
|
58
|
+
{urlError.value && (
|
|
59
|
+
<p class="drop-zone-error">
|
|
60
|
+
Failed to load archive from <b>{urlError.value.url}</b>.{" "}
|
|
61
|
+
{urlError.value.detail}
|
|
62
|
+
<p>Download the file and drop it here instead.</p>
|
|
63
|
+
</p>
|
|
64
|
+
)}
|
|
65
|
+
{error && (
|
|
66
|
+
<p class="drop-zone-error">Failed to load file: {error}</p>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RefObject } from "preact";
|
|
2
|
+
import { useEffect, useRef } from "preact/hooks";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Lazy-import a plot module and mount the resulting element into a div ref.
|
|
6
|
+
* Defers the ~hundreds-of-KB `@observablehq/plot` + `d3` bundle until the
|
|
7
|
+
* Samples tab is actually opened, keeping the initial viewer load small.
|
|
8
|
+
*/
|
|
9
|
+
export function useLazyPlot(
|
|
10
|
+
render: () => Promise<Element | null | undefined>,
|
|
11
|
+
deps: unknown[],
|
|
12
|
+
errorLabel?: string,
|
|
13
|
+
): RefObject<HTMLDivElement> {
|
|
14
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
render()
|
|
17
|
+
.then(el => {
|
|
18
|
+
if (!ref.current || !el) return;
|
|
19
|
+
ref.current.innerHTML = "";
|
|
20
|
+
ref.current.appendChild(el);
|
|
21
|
+
})
|
|
22
|
+
.catch(err => {
|
|
23
|
+
console.error(`${errorLabel ?? "Plot"} failed:`, err);
|
|
24
|
+
if (ref.current) {
|
|
25
|
+
const div = document.createElement("div");
|
|
26
|
+
div.className = "loading";
|
|
27
|
+
div.textContent = String(err.message ?? err);
|
|
28
|
+
ref.current.replaceChildren(div);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}, deps);
|
|
32
|
+
return ref;
|
|
33
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { useMemo, useState } from "preact/hooks";
|
|
2
|
+
import type { BenchmarkGroup, ReportData } from "../ReportData.ts";
|
|
3
|
+
import {
|
|
4
|
+
batchCount,
|
|
5
|
+
filterToBatch,
|
|
6
|
+
type FlattenedData,
|
|
7
|
+
flattenSamples,
|
|
8
|
+
type PreparedBenchmark,
|
|
9
|
+
prepareBenchmarks,
|
|
10
|
+
} from "../plots/RenderPlots.ts";
|
|
11
|
+
import type { SeriesVisibility } from "../plots/SampleTimeSeries.ts";
|
|
12
|
+
import { reportData, samplesLoaded } from "../State.ts";
|
|
13
|
+
import { useLazyPlot } from "./LazyPlot.ts";
|
|
14
|
+
|
|
15
|
+
/** True when at least one benchmark group has multiple samples (enough to plot). */
|
|
16
|
+
export function hasSufficientSamples(data: ReportData): boolean {
|
|
17
|
+
return data.groups.some(groupHasSamples);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** True when any benchmark or baseline in the group has multiple samples. */
|
|
21
|
+
function groupHasSamples(group: BenchmarkGroup): boolean {
|
|
22
|
+
const multiSample = (b: { samples: unknown[] }) => b.samples.length > 1;
|
|
23
|
+
return group.benchmarks.some(multiSample) || (!!group.baseline && multiSample(group.baseline));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Time-series and histogram plots for each benchmark group. Lazy-loaded on first tab activation. */
|
|
27
|
+
export function SamplesPanel() {
|
|
28
|
+
const data = reportData.value;
|
|
29
|
+
if (!samplesLoaded.value || !data) return null;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<>
|
|
33
|
+
{data.groups.map((group, i) => (
|
|
34
|
+
<SamplesGroup key={i} group={group} index={i} />
|
|
35
|
+
))}
|
|
36
|
+
</>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Renders time-series and histogram plots for one benchmark group, with batch stepping and series toggles. */
|
|
41
|
+
function SamplesGroup({ group, index }: { group: BenchmarkGroup; index: number }) {
|
|
42
|
+
const hasSamples = groupHasSamples(group);
|
|
43
|
+
const benchmarks = useMemo(() => prepareBenchmarks(group), [group]);
|
|
44
|
+
const flat = useMemo(
|
|
45
|
+
() => hasSamples ? flattenSamples(benchmarks) : null,
|
|
46
|
+
[benchmarks, hasSamples],
|
|
47
|
+
);
|
|
48
|
+
const numBatches = hasSamples ? batchCount(benchmarks) : 0;
|
|
49
|
+
|
|
50
|
+
// batch === 0 means "All", 1..numBatches means specific batch
|
|
51
|
+
const [batch, setBatch] = useState(0);
|
|
52
|
+
const activeBatch = batch > numBatches ? 0 : batch;
|
|
53
|
+
|
|
54
|
+
const viewFlat = useMemo(
|
|
55
|
+
() => flat && activeBatch > 0 ? filterToBatch(flat, benchmarks, activeBatch - 1) : flat,
|
|
56
|
+
[flat, benchmarks, activeBatch],
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const [visibility, setVisibility] = useState<SeriesVisibility>({
|
|
60
|
+
baseline: true,
|
|
61
|
+
heap: true,
|
|
62
|
+
baselineHeap: false,
|
|
63
|
+
rejected: true,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (!group.benchmarks?.length) return null;
|
|
67
|
+
if (!hasSamples || !flat || !viewFlat) return (
|
|
68
|
+
<div>
|
|
69
|
+
<div class="group-header">
|
|
70
|
+
<h2>{group.name}</h2>
|
|
71
|
+
</div>
|
|
72
|
+
<p class="single-sample-notice">
|
|
73
|
+
Single sample collected — plots require multiple samples.
|
|
74
|
+
</p>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const hasBaseline = !!group.baseline;
|
|
79
|
+
const hasHeap = flat.heapSeries.length > 0, hasBaselineHeap = flat.baselineHeapSeries.length > 0;
|
|
80
|
+
const hasRejected = flat.timeSeries.some(d => d.isRejected);
|
|
81
|
+
const totalPoints = viewFlat.timeSeries.length, sampled = totalPoints > 1000;
|
|
82
|
+
|
|
83
|
+
const toggle = (key: keyof SeriesVisibility) =>
|
|
84
|
+
setVisibility(v => ({ ...v, [key]: !v[key] }));
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div>
|
|
88
|
+
<div class="group-header">
|
|
89
|
+
<h2>{group.name}</h2>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="plot-grid">
|
|
92
|
+
<div class="plot-container">
|
|
93
|
+
<div class="plot-title">Time per Iteration</div>
|
|
94
|
+
<div class="plot-description">
|
|
95
|
+
{sampled
|
|
96
|
+
? `Sampled from ${totalPoints.toLocaleString()} iterations (showing ~1,000)`
|
|
97
|
+
: "Execution time for each iteration in collection order"}
|
|
98
|
+
</div>
|
|
99
|
+
<div class="plot-controls">
|
|
100
|
+
<SeriesToggles
|
|
101
|
+
hasBaseline={hasBaseline}
|
|
102
|
+
hasHeap={hasHeap}
|
|
103
|
+
hasBaselineHeap={hasBaselineHeap}
|
|
104
|
+
hasRejected={hasRejected}
|
|
105
|
+
visibility={visibility}
|
|
106
|
+
onToggle={toggle}
|
|
107
|
+
/>
|
|
108
|
+
{numBatches > 1 && (
|
|
109
|
+
<BatchStepper batch={activeBatch} total={numBatches} onChange={setBatch} />
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
<TimeSeriesPlot
|
|
113
|
+
benchmarks={benchmarks}
|
|
114
|
+
flat={viewFlat}
|
|
115
|
+
index={index}
|
|
116
|
+
visibility={visibility}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
<div class="plot-container">
|
|
120
|
+
<div class="plot-title">Time Distribution</div>
|
|
121
|
+
<div class="plot-description">
|
|
122
|
+
Frequency distribution of execution times
|
|
123
|
+
</div>
|
|
124
|
+
<HistogramPlot benchmarks={benchmarks} flat={viewFlat} index={index} />
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface ToggleProps {
|
|
132
|
+
hasBaseline: boolean;
|
|
133
|
+
hasHeap: boolean;
|
|
134
|
+
hasBaselineHeap: boolean;
|
|
135
|
+
hasRejected: boolean;
|
|
136
|
+
visibility: SeriesVisibility;
|
|
137
|
+
onToggle: (key: keyof SeriesVisibility) => void;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Pill button that toggles a boolean state with active/inactive styling. */
|
|
141
|
+
function TogglePill(
|
|
142
|
+
{ label, active, onClick }: { label: string; active: boolean; onClick: () => void },
|
|
143
|
+
) {
|
|
144
|
+
return (
|
|
145
|
+
<button class={`toggle-pill${active ? " active" : ""}`} onClick={onClick}>
|
|
146
|
+
{label}
|
|
147
|
+
</button>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Visibility toggles for optional series (baseline, heap, rejected). */
|
|
152
|
+
function SeriesToggles(props: ToggleProps) {
|
|
153
|
+
const { hasBaseline, hasHeap, hasBaselineHeap, hasRejected, visibility, onToggle } = props;
|
|
154
|
+
if (!hasBaseline && !hasHeap && !hasRejected) return null;
|
|
155
|
+
return (
|
|
156
|
+
<div class="series-toggles">
|
|
157
|
+
{hasBaseline && <TogglePill label="baseline" active={visibility.baseline} onClick={() => onToggle("baseline")} />}
|
|
158
|
+
{hasHeap && <TogglePill label="heap" active={visibility.heap} onClick={() => onToggle("heap")} />}
|
|
159
|
+
{hasBaselineHeap && <TogglePill label="heap (baseline)" active={visibility.baselineHeap} onClick={() => onToggle("baselineHeap")} />}
|
|
160
|
+
{hasRejected && <TogglePill label="rejected" active={visibility.rejected} onClick={() => onToggle("rejected")} />}
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Prev/next stepper for cycling through batches or showing all. */
|
|
166
|
+
function BatchStepper({ batch, total, onChange }: {
|
|
167
|
+
batch: number; total: number; onChange: (batch: number) => void;
|
|
168
|
+
}) {
|
|
169
|
+
const prev = () => onChange(batch <= 0 ? total : batch - 1);
|
|
170
|
+
const next = () => onChange(batch >= total ? 0 : batch + 1);
|
|
171
|
+
const label = batch === 0 ? "All" : `Batch ${batch} of ${total}`;
|
|
172
|
+
return (
|
|
173
|
+
<div class="batch-stepper">
|
|
174
|
+
<button class="batch-btn" onClick={prev}>‹</button>
|
|
175
|
+
<span class="batch-label">{label}</span>
|
|
176
|
+
<button class="batch-btn" onClick={next}>›</button>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
interface PlotProps { benchmarks: PreparedBenchmark[]; flat: FlattenedData; index: number }
|
|
182
|
+
interface TimeSeriesPlotProps extends PlotProps { visibility: SeriesVisibility }
|
|
183
|
+
|
|
184
|
+
/** Lazy-imports and renders a time-series chart for one benchmark group. */
|
|
185
|
+
function TimeSeriesPlot({ flat, index, visibility }: TimeSeriesPlotProps) {
|
|
186
|
+
const ref = useLazyPlot(async () => {
|
|
187
|
+
if (flat.timeSeries.length === 0) return null;
|
|
188
|
+
const { createSampleTimeSeries } = await import("../plots/SampleTimeSeries.ts");
|
|
189
|
+
const { timeSeries, allGcEvents, allPausePoints, heapSeries, baselineHeapSeries } = flat;
|
|
190
|
+
return createSampleTimeSeries(
|
|
191
|
+
timeSeries, allGcEvents, allPausePoints, heapSeries, baselineHeapSeries, visibility,
|
|
192
|
+
);
|
|
193
|
+
}, [flat, visibility], "Time series plot");
|
|
194
|
+
return (
|
|
195
|
+
<div id={`sample-timeseries-${index}`} class="plot-area" ref={ref}>
|
|
196
|
+
<div class="loading">Loading time series...</div>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Lazy-imports and renders a histogram with KDE for one benchmark group. */
|
|
202
|
+
function HistogramPlot({ benchmarks, flat, index }: PlotProps) {
|
|
203
|
+
const names = benchmarks.map(b => b.name);
|
|
204
|
+
const ref = useLazyPlot(async () => {
|
|
205
|
+
if (flat.allSamples.length === 0) return null;
|
|
206
|
+
const { createHistogramKde } = await import("../plots/HistogramKde.ts");
|
|
207
|
+
return createHistogramKde(flat.allSamples, names);
|
|
208
|
+
}, [flat, benchmarks], "Histogram plot");
|
|
209
|
+
return (
|
|
210
|
+
<div id={`histogram-${index}`} class="plot-area" ref={ref}>
|
|
211
|
+
<div class="loading">Loading histogram...</div>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useEffect } from "preact/hooks";
|
|
2
|
+
import { provider, sourceTabs } from "../State.ts";
|
|
3
|
+
import { openSourceTab } from "./SourcePanel.tsx";
|
|
4
|
+
import { TabBar } from "./TabBar.tsx";
|
|
5
|
+
import { TabContent } from "./TabContent.tsx";
|
|
6
|
+
|
|
7
|
+
/** Root viewer shell: listens for `open-source` messages, renders tab bar and content. */
|
|
8
|
+
export function Shell() {
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
function onMessage(ev: MessageEvent): void {
|
|
11
|
+
if (ev.data?.type === "open-source") {
|
|
12
|
+
const { file, line, col } = ev.data;
|
|
13
|
+
if (file) openSourceTab(file, line, col);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
window.addEventListener("message", onMessage);
|
|
17
|
+
return () => window.removeEventListener("message", onMessage);
|
|
18
|
+
}, []);
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
<TabBar />
|
|
23
|
+
<TabContent />
|
|
24
|
+
</>
|
|
25
|
+
);
|
|
26
|
+
}
|