From 2a2c137992a131a7b477c6687f8433772d94393a Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 24 Aug 2026 13:57:50 +1200 Subject: [PATCH 1/2] Replace Swift Charts loading placeholders with plain shapes While the first data set loads, ChartCard and TodayCard rendered real Swift Charts filled with mock data purely as redacted placeholders. Instantiating Swift Charts (and compiling its Metal shaders) inside the navigation transition was a measurable part of the first-open lag. The placeholders are now lightweight shapes drawn in the metric color: the chart card shows rounded bars or a polyline matching the selected chart type, and the Today card shows a gently rising polyline echoing the bell-curve flank its old fixture data produced. Both are replaced by the real charts as soon as data arrives, and previews for the placeholder states are included. --- .../JetpackStats/Cards/ChartCard.swift | 66 ++++++++++++++++++- .../JetpackStats/Cards/TodayCard.swift | 41 +++++++++++- 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/ChartCard.swift b/Modules/Sources/JetpackStats/Cards/ChartCard.swift index e820de15f83a..5ba83f0ccb09 100644 --- a/Modules/Sources/JetpackStats/Cards/ChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/ChartCard.swift @@ -118,8 +118,13 @@ struct ChartCard: View { @ViewBuilder private var chartContentView: some View { if viewModel.isFirstLoad { - mainChartView(metric: selectedMetric, data: mockChartData) - .redacted(reason: .placeholder) + // Plain shapes instead of a `Chart` with mock data. Building Swift Charts + // (and compiling its Metal shaders) during the push transition is a + // measurable part of the first-open lag, and the placeholder is replaced + // as soon as data arrives anyway. + ChartLoadingPlaceholder(chartType: selectedChartType, metric: selectedMetric) + .frame(height: chartHeight) + .padding(.horizontal, -Constants.step1) .opacity(0.2) .pulsating() } else if let data = viewModel.chartData[selectedMetric] { @@ -291,6 +296,53 @@ struct ChartCard: View { } } +/// A cheap stand-in for the chart while the first data set loads, mirroring the +/// card's selected chart type. +private struct ChartLoadingPlaceholder: View { + let chartType: ChartType + let metric: SiteMetric + + private let barHeights: [CGFloat] = [0.45, 0.7, 0.55, 0.9, 0.6, 0.8, 0.5, 0.75, 0.65, 0.85, 0.4, 0.7, 0.6, 0.95] + private let lineHeights: [CGFloat] = [ + 0.32, 0.38, 0.35, 0.42, 0.46, 0.43, 0.50, 0.55, 0.52, 0.58, 0.63, 0.60, 0.67, 0.72, 0.69, 0.78, 0.85 + ] + + var body: some View { + switch chartType { + case .columns: + bars + case .line: + line + } + } + + private var bars: some View { + GeometryReader { proxy in + HStack(alignment: .bottom, spacing: Constants.step0_5) { + ForEach(barHeights.indices, id: \.self) { index in + RoundedRectangle(cornerRadius: 3) + .fill(metric.primaryColor) + .frame(height: proxy.size.height * barHeights[index]) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + } + + private var line: some View { + GeometryReader { proxy in + let size = proxy.size + let step = size.width / CGFloat(lineHeights.count - 1) + Path { path in + path.addLines(lineHeights.enumerated().map { index, height in + CGPoint(x: CGFloat(index) * step, y: size.height * (1 - height)) + }) + } + .stroke(metric.primaryColor, style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) + } + } +} + private struct CardGradientBackground: View { let metric: SiteMetric @@ -408,3 +460,13 @@ private struct ChartCardPreview: View { } .background(Color(.systemGroupedBackground)) } + +#Preview("Loading placeholders") { + VStack(spacing: 24) { + ChartLoadingPlaceholder(chartType: .columns, metric: .views) + .frame(height: 140) + ChartLoadingPlaceholder(chartType: .line, metric: .views) + .frame(height: 140) + } + .padding() +} diff --git a/Modules/Sources/JetpackStats/Cards/TodayCard.swift b/Modules/Sources/JetpackStats/Cards/TodayCard.swift index b9c947db166d..febf7c1e0a49 100644 --- a/Modules/Sources/JetpackStats/Cards/TodayCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TodayCard.swift @@ -106,8 +106,14 @@ struct TodayCard: View { if let data = viewModel.data { makeSparklineView(data) } else { - let placeholder = makeSparklineView(placeholderData) - .redacted(reason: .placeholder) + // A plain shape instead of a `Chart` with placeholder data. See the + // matching note in `ChartCard`: building Swift Charts during the push + // transition is a measurable part of the first-open lag. + let placeholder = SparklinePlaceholder(metric: .views) + .frame(maxWidth: .infinity) + .padding(.trailing, 32) + .padding(.vertical, 2) + .offset(y: -3) if viewModel.isLoading { placeholder.pulsating().opacity(0.33) } else { @@ -201,6 +207,31 @@ struct TodayCard: View { } } +/// A cheap stand-in for `SparklineChart` while the first data set loads: +/// a gently rising polyline with light jitter, echoing the bell-curve flank +/// the real chart's placeholder data used to draw. No area wash: under the +/// loading state's dimming its contribution measures ~2/255, invisible. +private struct SparklinePlaceholder: View { + let metric: SiteMetric + + private let relativeHeights: [CGFloat] = [ + 0.08, 0.11, 0.10, 0.14, 0.16, 0.22, 0.25, 0.35, 0.41, 0.53, 0.61, 0.75, 0.83 + ] + + var body: some View { + GeometryReader { proxy in + let size = proxy.size + let step = size.width / CGFloat(relativeHeights.count - 1) + Path { path in + path.addLines(relativeHeights.enumerated().map { index, height in + CGPoint(x: CGFloat(index) * step, y: size.height * (1 - height)) + }) + } + .stroke(metric.primaryColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + } + } +} + private struct SparklineChart: View { let dataPoints: [(hour: Int, value: Int)] let previousDataPoints: [(hour: Int, value: Int)] @@ -349,3 +380,9 @@ private struct TodayCardPreview: View { .cardStyle() } } + +#Preview("Sparkline placeholder") { + SparklinePlaceholder(metric: .views) + .frame(width: 220, height: 52) + .padding() +} From b1cc092ef752e1ba0b4d7a906160d83769a1fd78 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 24 Aug 2026 14:03:51 +1200 Subject: [PATCH 2/2] Add a release note --- RELEASE-NOTES.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 2532da8ba574..7faa992a7bd6 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,6 +1,6 @@ 27.3 ----- -* [*] Stats: Improve performance when opening the Stats screen [#25938] +* [*] Stats: Improve performance when opening the Stats screen [#25938, #25931] 27.2 -----