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
4 changes: 4 additions & 0 deletions Levitan.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
AB0000062F2139F6006016DE /* EnvironmentValues+ComponentMountingPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0000052F2139F6006016DE /* EnvironmentValues+ComponentMountingPolicy.swift */; };
AB0000072F2139F6006016DE /* EnvironmentValues+ComponentMountingPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0000052F2139F6006016DE /* EnvironmentValues+ComponentMountingPolicy.swift */; };
AB0000082F2139F6006016DE /* EnvironmentValues+ComponentMountingPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0000052F2139F6006016DE /* EnvironmentValues+ComponentMountingPolicy.swift */; };
BD630107286740451D607380 /* ComponentHostingNestingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A3BA78569B30A7793E48E7 /* ComponentHostingNestingTests.swift */; };
C005088D2F2139F6006016DE /* ComponentFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = C00508762F2139F6006016DE /* ComponentFrame.swift */; };
C005088E2F2139F6006016DE /* ComponentModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C00508862F2139F6006016DE /* ComponentModifier.swift */; };
C005088F2F2139F6006016DE /* AnyComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = C005084C2F2139F6006016DE /* AnyComponent.swift */; };
Expand Down Expand Up @@ -1141,6 +1142,7 @@
/* Begin PBXFileReference section */
AB0000012F2139F6006016DE /* ComponentMountingPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComponentMountingPolicy.swift; sourceTree = "<group>"; };
AB0000052F2139F6006016DE /* EnvironmentValues+ComponentMountingPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValues+ComponentMountingPolicy.swift"; sourceTree = "<group>"; };
B1A3BA78569B30A7793E48E7 /* ComponentHostingNestingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComponentHostingNestingTests.swift; sourceTree = "<group>"; };
C0007FFA23CBAAA5005F95E0 /* .swift-version */ = {isa = PBXFileReference; lastKnownFileType = text; path = ".swift-version"; sourceTree = "<group>"; };
C005084C2F2139F6006016DE /* AnyComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyComponent.swift; sourceTree = "<group>"; };
C005084D2F2139F6006016DE /* AnyComponentPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyComponentPresenter.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2910,6 +2912,7 @@
C0C3C5932DD5E5A70058A201 /* TestPlans */,
C0A01A1523CB8FFC00AC2C3A /* .swiftlint.yml */,
C057F01E23CB8EEB00C2D895 /* Info.plist */,
B1A3BA78569B30A7793E48E7 /* ComponentHostingNestingTests.swift */,
);
path = Tests;
sourceTree = "<group>";
Expand Down Expand Up @@ -3545,6 +3548,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
BD630107286740451D607380 /* ComponentHostingNestingTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
3 changes: 3 additions & 0 deletions Sources/Component/Hosting/ComponentHostingRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ internal struct ComponentHostingRoot<Content: View>: View {
internal var context: ComponentContext

internal var body: some View {
// Не через context.componentViewController: до iOS 27 это чтение протекает в окружение
// внешнего хостинга, см. ComponentHostingNestingTests.
let theme = context
.resolveEnvironment(EnvironmentValues())
.componentViewController?
.view
.tokens
Expand Down
116 changes: 116 additions & 0 deletions Tests/ComponentHostingNestingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#if canImport(UIKit)
import SwiftUI
import UIKit
import XCTest
@testable import Levitan

private struct NestedText: Component {

let text: String

var body: some View {
Text(text)
}
}

private struct NestingCard: FallbackComponent {

typealias UIView = NestingCardView

let text: String
}

@MainActor
private enum NestingCardLeaks {

static var texts: [String] = []
}

/// UIKit-компонент со SwiftUI-содержимым, как карточка дизайн-системы.
private final class NestingCardView: UIView {

private let contentView = ComponentHostingView<NestedText>()

override init(frame: CGRect) {
super.init(frame: frame)

contentView.frame = bounds
contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]

addSubview(contentView)
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

private struct NestingRoot: View {

let revision: Int

var body: some View {
ScrollView {
LazyVStack {
ForEach(0..<3) { index in
NestingCard(text: "\(index)-\(revision)")
}
}
}
}
}

@MainActor
final class ComponentHostingNestingTests: XCTestCase {

func testNestedHostingControllerDoesNotLeakIntoParentComponentContext() {
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 402, height: 874))
let hostingController = UIHostingController(rootView: NestingRoot(revision: 0))

NestingCardLeaks.texts = []

window.rootViewController = hostingController
window.makeKeyAndVisible()

// Утечка проявляется только на повторных обновлениях уже встроенных компонентов
for revision in 0..<3 {
hostingController.rootView = NestingRoot(revision: revision)
hostingController.view.setNeedsLayout()
hostingController.view.layoutIfNeeded()

RunLoop.main.run(until: Date().addingTimeInterval(0.3))
}

XCTAssertEqual(NestingCardLeaks.texts, [])
XCTAssertFalse(hostingController.children.isEmpty)

window.isHidden = true
}
}

extension NestingCardView: FallbackComponentView {

static func sizing(
for content: NestingCard,
fitting size: CGSize,
context: ComponentContext
) -> ComponentSizing {
ComponentSizing(width: .fill, height: .fixed(44.0))
}

func update(with content: NestingCard, context: ComponentContext) {
// Контекст карточки не должен содержать ее собственный UIHostingController:
// иначе ComponentHostingView добавит его дочерним самому себе, и UIKit упадет с переполнением стека.
// Обновление пропускается, чтобы утечка завершила тест проверкой, а не падением.
if context.componentViewController is ComponentHostingController<ComponentHostingRoot<NestedText>> {
return NestingCardLeaks.texts.append(content.text)
}

contentView.update(
with: NestedText(text: content.text),
context: context
)
}
}
#endif
Loading