Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
format:
name: Format (swift-format lint)
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

- name: Select latest stable Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable

- name: Show toolchain
run: swift --version

# Fails (exit 1) on any formatting deviation thanks to --strict.
# The .swift-format config at the repo root is auto-discovered.
- name: Check formatting
run: |
git ls-files '*.swift' -z \
| xargs -0 swift format lint --strict --configuration .swift-format

build:
name: Build (iOS simulator)
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

- name: Select latest stable Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable

- name: Show toolchain
run: swift --version

# iOS-only package: build against the simulator SDK (a plain `swift build`
# would target macOS and fail on UIKit-dependent code).
- name: Build library
run: |
SDK_PATH="$(xcrun --sdk iphonesimulator --show-sdk-path)"
swift build --sdk "$SDK_PATH" --triple arm64-apple-ios16.0-simulator

# The integration tests reference TestConfig (gitignored — it holds real
# credentials). CI only compiles the tests, so write a placeholder with
# the same shape as Helpers/TestConfig.example.swift.
- name: Generate placeholder TestConfig
run: |
cat > Tests/FastCommentsUITests/Helpers/TestConfig.swift <<'SWIFT'
enum TestConfig {
static let tenantId = "ci-placeholder"
static let apiKey = "ci-placeholder"
static let e2eApiKey = "ci-placeholder"
static let host = "https://fastcomments.com"
}
SWIFT

- name: Compile tests
run: |
SDK_PATH="$(xcrun --sdk iphonesimulator --show-sdk-path)"
swift build --build-tests --sdk "$SDK_PATH" --triple arm64-apple-ios16.0-simulator

- name: Build example app
run: |
xcodebuild build \
-project ExampleApp/FastCommentsExample.xcodeproj \
-scheme FastCommentsExample \
-destination 'generic/platform=iOS Simulator' \
CODE_SIGNING_ALLOWED=NO
14 changes: 14 additions & 0 deletions .swift-format
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"version": 1,
"lineLength": 120,
"indentation": {
"spaces": 4
},
"maximumBlankLines": 1,
"respectsExistingLineBreaks": true,
"lineBreakBeforeEachArgument": false,
"prioritizeKeepingFunctionOutputTogether": true,
"rules": {
"UseSynthesizedInitializer": false
}
}
175 changes: 91 additions & 84 deletions ExampleApp/FastCommentsExample/BenchmarkView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import SwiftUI
import FastCommentsUI
import FastCommentsSwift
#if canImport(UIKit)
import UIKit
import UIKit
#endif

struct BenchmarkView: View {
Expand All @@ -12,7 +12,7 @@ struct BenchmarkView: View {
config: FastCommentsWidgetConfig(tenantId: "benchmark", urlId: "benchmark")
)
#if canImport(UIKit)
@StateObject private var fpsMonitor = FPSMonitor()
@StateObject private var fpsMonitor = FPSMonitor()
#endif

@State private var generationTimeMs: Double = 0
Expand All @@ -30,13 +30,13 @@ struct BenchmarkView: View {
ZStack(alignment: .topTrailing) {
LiveChatView(sdk: sdk)
#if canImport(UIKit)
fpsOverlay
fpsOverlay
#endif
}
}
.navigationTitle("100k Benchmark")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .primaryAction) {
Expand All @@ -50,11 +50,11 @@ struct BenchmarkView: View {
}
}
#if canImport(UIKit)
.onAppear { fpsMonitor.start() }
.onDisappear {
fpsMonitor.stop()
sdk.commentsTree.build(comments: [])
}
.onAppear { fpsMonitor.start() }
.onDisappear {
fpsMonitor.stop()
sdk.commentsTree.build(comments: [])
}
#endif
.task {
if autoRun && !hasRun {
Expand All @@ -79,12 +79,12 @@ struct BenchmarkView: View {
statItem("Delta", "+\(String(format: "%.1f", memoryAfterMB - memoryBeforeMB)) MB")
}
#if canImport(UIKit)
HStack(spacing: 16) {
statItem("FPS", String(format: "%.0f", fpsMonitor.currentFPS))
statItem("Min", String(format: "%.0f", fpsMonitor.minFPS == .infinity ? 0 : fpsMonitor.minFPS))
statItem("Avg", String(format: "%.0f", fpsMonitor.averageFPS))
statItem("Drops", "\(fpsMonitor.droppedFrameCount)")
}
HStack(spacing: 16) {
statItem("FPS", String(format: "%.0f", fpsMonitor.currentFPS))
statItem("Min", String(format: "%.0f", fpsMonitor.minFPS == .infinity ? 0 : fpsMonitor.minFPS))
statItem("Avg", String(format: "%.0f", fpsMonitor.averageFPS))
statItem("Drops", "\(fpsMonitor.droppedFrameCount)")
}
#endif
} else {
Text("Tap Run to load 100,000 comments")
Expand Down Expand Up @@ -112,22 +112,22 @@ struct BenchmarkView: View {
// MARK: - FPS Overlay

#if canImport(UIKit)
private var fpsOverlay: some View {
Text("\(Int(fpsMonitor.currentFPS)) fps")
.font(.caption.monospacedDigit().bold())
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(fpsColor.opacity(0.85))
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 6))
.padding(8)
}
private var fpsOverlay: some View {
Text("\(Int(fpsMonitor.currentFPS)) fps")
.font(.caption.monospacedDigit().bold())
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(fpsColor.opacity(0.85))
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 6))
.padding(8)
}

private var fpsColor: Color {
if fpsMonitor.currentFPS >= 55 { return .green }
if fpsMonitor.currentFPS >= 30 { return .yellow }
return .red
}
private var fpsColor: Color {
if fpsMonitor.currentFPS >= 55 { return .green }
if fpsMonitor.currentFPS >= 30 { return .yellow }
return .red
}
#endif

// MARK: - Benchmark
Expand All @@ -142,8 +142,8 @@ struct BenchmarkView: View {
memoryBeforeMB = 0
memoryAfterMB = 0
#if canImport(UIKit)
fpsMonitor.stop()
fpsMonitor.start()
fpsMonitor.stop()
fpsMonitor.start()
#endif
// Small delay so the UI clears before re-populating
try? await Task.sleep(nanoseconds: 200_000_000)
Expand Down Expand Up @@ -178,75 +178,82 @@ struct BenchmarkView: View {

let memAfter = getMemoryUsageMB()
memoryAfterMB = memAfter
print("[Benchmark] Memory after: \(String(format: "%.1f", memAfter)) MB (delta: +\(String(format: "%.1f", memAfter - memBefore)) MB)")
print(
"[Benchmark] Memory after: \(String(format: "%.1f", memAfter)) MB (delta: +\(String(format: "%.1f", memAfter - memBefore)) MB)"
)

print("[Benchmark] Tree stats — visibleNodes: \(sdk.commentsTree.visibleSize()), allComments: \(sdk.commentsTree.totalSize())")
print(
"[Benchmark] Tree stats — visibleNodes: \(sdk.commentsTree.visibleSize()), allComments: \(sdk.commentsTree.totalSize())"
)

// Let the first frame render, then run a scroll stress test
try? await Task.sleep(nanoseconds: 500_000_000)
await runScrollTest()
}

#if canImport(UIKit)
/// Programmatically scroll the content to stress-test LazyVStack rendering.
private func runScrollTest() async {
print("[Benchmark] Starting scroll stress test...")
/// Programmatically scroll the content to stress-test LazyVStack rendering.
private func runScrollTest() async {
print("[Benchmark] Starting scroll stress test...")

guard let scrollView = findUIScrollView() else {
print("[Benchmark] Could not find UIScrollView for scroll test")
return
}
guard let scrollView = findUIScrollView() else {
print("[Benchmark] Could not find UIScrollView for scroll test")
return
}

let contentHeight = scrollView.contentSize.height
let viewHeight = scrollView.bounds.height
guard contentHeight > viewHeight else {
print("[Benchmark] Content too small to scroll")
return
}
let contentHeight = scrollView.contentSize.height
let viewHeight = scrollView.bounds.height
guard contentHeight > viewHeight else {
print("[Benchmark] Content too small to scroll")
return
}

// Reset FPS tracking for the scroll portion
fpsMonitor.stop()
fpsMonitor.start()
// Reset FPS tracking for the scroll portion
fpsMonitor.stop()
fpsMonitor.start()

let steps = 20
let stepSize = (contentHeight - viewHeight) / CGFloat(steps)
let steps = 20
let stepSize = (contentHeight - viewHeight) / CGFloat(steps)

// Scroll down in steps
for i in 1...steps {
let y = min(stepSize * CGFloat(i), contentHeight - viewHeight)
scrollView.setContentOffset(CGPoint(x: 0, y: y), animated: false)
// Allow a few frames to render at each position
try? await Task.sleep(nanoseconds: 100_000_000)
}
// Scroll down in steps
for i in 1...steps {
let y = min(stepSize * CGFloat(i), contentHeight - viewHeight)
scrollView.setContentOffset(CGPoint(x: 0, y: y), animated: false)
// Allow a few frames to render at each position
try? await Task.sleep(nanoseconds: 100_000_000)
}

// Scroll back up
for i in stride(from: steps - 1, through: 0, by: -1) {
let y = stepSize * CGFloat(i)
scrollView.setContentOffset(CGPoint(x: 0, y: y), animated: false)
try? await Task.sleep(nanoseconds: 100_000_000)
}
// Scroll back up
for i in stride(from: steps - 1, through: 0, by: -1) {
let y = stepSize * CGFloat(i)
scrollView.setContentOffset(CGPoint(x: 0, y: y), animated: false)
try? await Task.sleep(nanoseconds: 100_000_000)
}

print("[Benchmark] Scroll test complete — FPS min: \(String(format: "%.1f", fpsMonitor.minFPS == .infinity ? 0 : fpsMonitor.minFPS)), avg: \(String(format: "%.1f", fpsMonitor.averageFPS)), max: \(String(format: "%.1f", fpsMonitor.maxFPS)), dropped: \(fpsMonitor.droppedFrameCount)")
}
print(
"[Benchmark] Scroll test complete — FPS min: \(String(format: "%.1f", fpsMonitor.minFPS == .infinity ? 0 : fpsMonitor.minFPS)), avg: \(String(format: "%.1f", fpsMonitor.averageFPS)), max: \(String(format: "%.1f", fpsMonitor.maxFPS)), dropped: \(fpsMonitor.droppedFrameCount)"
)
}

/// Walk the view hierarchy to find the UIScrollView backing our SwiftUI ScrollView.
private func findUIScrollView() -> UIScrollView? {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first else { return nil }
return findScrollView(in: window)
}
/// Walk the view hierarchy to find the UIScrollView backing our SwiftUI ScrollView.
private func findUIScrollView() -> UIScrollView? {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first
else { return nil }
return findScrollView(in: window)
}

private func findScrollView(in view: UIView) -> UIScrollView? {
if let sv = view as? UIScrollView { return sv }
for sub in view.subviews {
if let found = findScrollView(in: sub) { return found }
private func findScrollView(in view: UIView) -> UIScrollView? {
if let sv = view as? UIScrollView { return sv }
for sub in view.subviews {
if let found = findScrollView(in: sub) { return found }
}
return nil
}
return nil
}
#else
private func runScrollTest() async {
print("[Benchmark] Scroll test not available on this platform")
}
private func runScrollTest() async {
print("[Benchmark] Scroll test not available on this platform")
}
#endif

// MARK: - Comment Generation
Expand All @@ -264,7 +271,7 @@ struct BenchmarkView: View {
"<p>Indeed, that makes sense.</p>",
]

let baseDate = Date().addingTimeInterval(-3 * 24 * 3600) // 3 days ago
let baseDate = Date().addingTimeInterval(-3 * 24 * 3600) // 3 days ago
var comments: [PublicComment] = []
comments.reserveCapacity(count)

Expand All @@ -273,7 +280,7 @@ struct BenchmarkView: View {
id: "bench-\(i)",
commenterName: names[i % names.count],
commentHTML: messages[i % messages.count],
date: baseDate.addingTimeInterval(Double(i) * 2.6), // ~2.6s apart = 3 days over 100k
date: baseDate.addingTimeInterval(Double(i) * 2.6), // ~2.6s apart = 3 days over 100k
verified: true
)
comments.append(comment)
Expand Down
2 changes: 1 addition & 1 deletion ExampleApp/FastCommentsExample/CommentsExampleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ struct CommentsExampleView: View {
theme.loadMoreButtonTextColor = .indigo

// Vote colors
theme.voteActiveColor = .red // Heart fill color
theme.voteActiveColor = .red // Heart fill color
theme.voteCountColor = .primary
theme.voteCountZeroColor = .secondary

Expand Down
Loading
Loading