From a75077701f14d2c26a5482888ed39e37c540c3b7 Mon Sep 17 00:00:00 2001 From: Dominik Kapusta Date: Tue, 26 Apr 2022 17:15:51 +0200 Subject: [PATCH] Lazy load background tabs at app startup (#553) Task/Issue URL: https://app.asana.com/0/1177771139624306/1201065943414949/f Description: I added a mechanism that records selected tabs in chronological order and reloads them in that order at app startup. The logic is as follows: * app launches * if there are no URL tabs or only 1 URL tab and it's currently selected, do nothing, don't even initialize * if current tab is a URL tab, wait until it finishes loading (or fails to load), otherwise proceed immediately * pick up to 3 most recently visited URL tab and reload them in background * as background tabs finish (or fail) loading, repeat previous step (keeping at most 3 concurrent loads) until 20 background tabs are loaded or there are no more URL tabs to load * as the user switches through tabs during lazy loading, record visited tabs and remove them from the lazy loading queue (they were visited manually which triggered a reload) * if 20 tabs were reloaded in background or there are no more not activated URL tabs, report finished work Additional details: * TabLazyLoader is owned by TabCollectionViewModel which serves as its data source * When lazy loader reports completion, it gets deallocated * Lazy loader does not differentiate between successful and failed loads, it only records website load attempts and completion (regardless of the outcome) - it's therefore not affected by "no internet" scenario * the implementation is based on generic types to allow for more thorough unit testing * the mechanism is inspired by how Chromium-based browsers work, but magic numbers 3 and 20 mentioned above were picked by Gabriel - you'll find more information in the Asana task. * OSLog.tabLazyLoading was added to help debugging lazy loading (disabled by default) * If there are more than 20 tabs at app startup, lazy loading starts with 10 tabs adjacent to the current one, before proceeding to 10 most recently selected. * Lazy loading is paused every time the user interacts with the current tab (triggers a navigation). --- DuckDuckGo.xcodeproj/project.pbxproj | 32 ++ DuckDuckGo/Browser Tab/Model/Tab.swift | 14 +- .../Browser Tab/ViewModel/TabViewModel.swift | 20 +- .../Utilities/AdjacentItemEnumerator.swift | 112 ++++++ DuckDuckGo/Common/Utilities/Logging.swift | 6 + .../Tab+NSSecureCoding.swift | 3 + .../WindowManager+StateRestoration.swift | 3 + .../ViewModel/TabCollectionViewModel.swift | 22 + .../TabLazyLoader/LazyLoadable.swift | 62 +++ .../TabLazyLoader/TabLazyLoader.swift | 264 ++++++++++++ .../TabLazyLoaderDataSource.swift | 76 ++++ .../AdjacentItemEnumeratorTests.swift | 67 ++++ .../ViewModel/TabLazyLoaderTests.swift | 376 ++++++++++++++++++ 13 files changed, 1053 insertions(+), 4 deletions(-) create mode 100644 DuckDuckGo/Common/Utilities/AdjacentItemEnumerator.swift create mode 100644 DuckDuckGo/Tab Bar/ViewModel/TabLazyLoader/LazyLoadable.swift create mode 100644 DuckDuckGo/Tab Bar/ViewModel/TabLazyLoader/TabLazyLoader.swift create mode 100644 DuckDuckGo/Tab Bar/ViewModel/TabLazyLoader/TabLazyLoaderDataSource.swift create mode 100644 Unit Tests/Tab Bar/ViewModel/AdjacentItemEnumeratorTests.swift create mode 100644 Unit Tests/Tab Bar/ViewModel/TabLazyLoaderTests.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 9f243cbd00..028ece9464 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -20,6 +20,11 @@ 339A6B5826A044BA00E3DAE8 /* duckduckgo-privacy-dashboard in Resources */ = {isa = PBXBuildFile; fileRef = 339A6B5726A044BA00E3DAE8 /* duckduckgo-privacy-dashboard */; }; 371C0A2927E33EDC0070591F /* FeedbackPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 371C0A2827E33EDC0070591F /* FeedbackPresenter.swift */; }; 371E141927E92E42009E3B5B /* MultilineScrollableTextFix.swift in Sources */ = {isa = PBXBuildFile; fileRef = 371E141827E92E42009E3B5B /* MultilineScrollableTextFix.swift */; }; + 37534C9E28104D9B002621E7 /* TabLazyLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37534C9D28104D9B002621E7 /* TabLazyLoaderTests.swift */; }; + 37534CA028113101002621E7 /* LazyLoadable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37534C9F28113101002621E7 /* LazyLoadable.swift */; }; + 37534CA3281132CB002621E7 /* TabLazyLoaderDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37534CA2281132CB002621E7 /* TabLazyLoaderDataSource.swift */; }; + 37534CA52811987D002621E7 /* AdjacentItemEnumeratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37534CA42811987D002621E7 /* AdjacentItemEnumeratorTests.swift */; }; + 37534CA8281198CD002621E7 /* AdjacentItemEnumerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37534CA62811988E002621E7 /* AdjacentItemEnumerator.swift */; }; 376705AF27EB488600DD8D76 /* RoundedSelectionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0511B3262CAA5A00F6079C /* RoundedSelectionRowView.swift */; }; 376705B327EC7D4F00DD8D76 /* TextButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376705B227EC7D4F00DD8D76 /* TextButton.swift */; }; 3776582D27F71652009A6B35 /* WebsiteBreakageReportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3776582C27F71652009A6B35 /* WebsiteBreakageReportTests.swift */; }; @@ -33,6 +38,7 @@ 37AFCE8927DA33BA00471A10 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AFCE8827DA33BA00471A10 /* Preferences.swift */; }; 37AFCE8B27DB69BC00471A10 /* PreferencesDefaultBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AFCE8A27DB69BC00471A10 /* PreferencesDefaultBrowserView.swift */; }; 37AFCE9227DB8CAD00471A10 /* PreferencesAboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37AFCE9127DB8CAD00471A10 /* PreferencesAboutView.swift */; }; + 37B11B3928095E6600CBB621 /* TabLazyLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B11B3828095E6600CBB621 /* TabLazyLoader.swift */; }; 37CC53EC27E8A4D10028713D /* PreferencesPrivacyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC53EB27E8A4D10028713D /* PreferencesPrivacyView.swift */; }; 37CC53F027E8D1440028713D /* PreferencesDownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC53EF27E8D1440028713D /* PreferencesDownloadsView.swift */; }; 37CC53F427E8D4620028713D /* NSPathControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC53F327E8D4620028713D /* NSPathControlView.swift */; }; @@ -762,6 +768,11 @@ 339A6B5726A044BA00E3DAE8 /* duckduckgo-privacy-dashboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = "duckduckgo-privacy-dashboard"; path = "Submodules/duckduckgo-privacy-dashboard"; sourceTree = SOURCE_ROOT; }; 371C0A2827E33EDC0070591F /* FeedbackPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackPresenter.swift; sourceTree = ""; }; 371E141827E92E42009E3B5B /* MultilineScrollableTextFix.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultilineScrollableTextFix.swift; sourceTree = ""; }; + 37534C9D28104D9B002621E7 /* TabLazyLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabLazyLoaderTests.swift; sourceTree = ""; }; + 37534C9F28113101002621E7 /* LazyLoadable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyLoadable.swift; sourceTree = ""; }; + 37534CA2281132CB002621E7 /* TabLazyLoaderDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabLazyLoaderDataSource.swift; sourceTree = ""; }; + 37534CA42811987D002621E7 /* AdjacentItemEnumeratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjacentItemEnumeratorTests.swift; sourceTree = ""; }; + 37534CA62811988E002621E7 /* AdjacentItemEnumerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjacentItemEnumerator.swift; sourceTree = ""; }; 376705B227EC7D4F00DD8D76 /* TextButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextButton.swift; sourceTree = ""; }; 3776582C27F71652009A6B35 /* WebsiteBreakageReportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteBreakageReportTests.swift; sourceTree = ""; }; 3776582E27F82E62009A6B35 /* AutofillPreferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutofillPreferences.swift; sourceTree = ""; }; @@ -774,6 +785,7 @@ 37AFCE8827DA33BA00471A10 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; 37AFCE8A27DB69BC00471A10 /* PreferencesDefaultBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesDefaultBrowserView.swift; sourceTree = ""; }; 37AFCE9127DB8CAD00471A10 /* PreferencesAboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesAboutView.swift; sourceTree = ""; }; + 37B11B3828095E6600CBB621 /* TabLazyLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabLazyLoader.swift; sourceTree = ""; }; 37CC53EB27E8A4D10028713D /* PreferencesPrivacyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesPrivacyView.swift; sourceTree = ""; }; 37CC53EF27E8D1440028713D /* PreferencesDownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesDownloadsView.swift; sourceTree = ""; }; 37CC53F327E8D4620028713D /* NSPathControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSPathControlView.swift; sourceTree = ""; }; @@ -1550,6 +1562,16 @@ path = dist; sourceTree = ""; }; + 37534CA128113277002621E7 /* TabLazyLoader */ = { + isa = PBXGroup; + children = ( + 37534C9F28113101002621E7 /* LazyLoadable.swift */, + 37534CA2281132CB002621E7 /* TabLazyLoaderDataSource.swift */, + 37B11B3828095E6600CBB621 /* TabLazyLoader.swift */, + ); + path = TabLazyLoader; + sourceTree = ""; + }; 3776582B27F7163B009A6B35 /* Website Breakage Report */ = { isa = PBXGroup; children = ( @@ -1971,6 +1993,7 @@ 85C6A29525CC1FFD00EEB5F1 /* UserDefaultsWrapper.swift */, B6AAAC2C260330580029438D /* PublishedAfter.swift */, 4BB6CE5E26B77ED000EC5860 /* Cryptography.swift */, + 37534CA62811988E002621E7 /* AdjacentItemEnumerator.swift */, ); path = Utilities; sourceTree = ""; @@ -2901,6 +2924,7 @@ isa = PBXGroup; children = ( AA9FF95E24A1FB680039E328 /* TabCollectionViewModel.swift */, + 37534CA128113277002621E7 /* TabLazyLoader */, ); path = ViewModel; sourceTree = ""; @@ -3164,6 +3188,8 @@ children = ( AAC9C01D24CB6BEB00AD1325 /* TabCollectionViewModelTests.swift */, AAE39D1A24F44885008EF28B /* TabCollectionViewModelDelegateMock.swift */, + 37534C9D28104D9B002621E7 /* TabLazyLoaderTests.swift */, + 37534CA42811987D002621E7 /* AdjacentItemEnumeratorTests.swift */, ); path = ViewModel; sourceTree = ""; @@ -4057,8 +4083,10 @@ AAA0CC572539EBC90079BC96 /* FaviconUserScript.swift in Sources */, B6A9E45A261460350067D1B9 /* ApiRequestError.swift in Sources */, AADCBF3A26F7C2CE00EF67A8 /* LottieAnimationCache.swift in Sources */, + 37534CA3281132CB002621E7 /* TabLazyLoaderDataSource.swift in Sources */, 4B723E0E26B0006300E14D75 /* LoginImport.swift in Sources */, 85589E9627BFE25D0038AD11 /* FailedAssertionView.swift in Sources */, + 37534CA028113101002621E7 /* LazyLoadable.swift in Sources */, EAE42800275D47FA00DAC26B /* ClickToLoadModel.swift in Sources */, 0230C0A3272080090018F728 /* KeyedCodingExtension.swift in Sources */, B6C0B23026E61D630031CB7F /* DownloadListStore.swift in Sources */, @@ -4116,6 +4144,7 @@ 0230C0A52721F3750018F728 /* GPCRequestFactory.swift in Sources */, 9833912F27AAA3CE00DAF119 /* AppTrackerDataSetProvider.swift in Sources */, 4BA1A6B3258B080A00F6F690 /* EncryptionKeyGeneration.swift in Sources */, + 37B11B3928095E6600CBB621 /* TabLazyLoader.swift in Sources */, 4B723E0B26B0005B00E14D75 /* CSVImportViewController.swift in Sources */, 8589063C267BCDC000D23B0D /* SaveCredentialsViewController.swift in Sources */, 4BBE0AA727B9B027003B37A8 /* PopUpButton.swift in Sources */, @@ -4527,6 +4556,7 @@ 37D2771527E870D4003365FD /* PreferencesAppearanceView.swift in Sources */, AA72D5FE25FFF94E00C77619 /* NSMenuItemExtension.swift in Sources */, 4BA1A6C2258B0A1300F6F690 /* ContiguousBytesExtension.swift in Sources */, + 37534CA8281198CD002621E7 /* AdjacentItemEnumerator.swift in Sources */, AA9B7C7E26A06E040008D425 /* TrackerInfo.swift in Sources */, B6553692268440D700085A79 /* WKProcessPool+GeolocationProvider.swift in Sources */, ); @@ -4543,9 +4573,11 @@ 142879DA24CE1179005419BB /* SuggestionViewModelTests.swift in Sources */, 4B9292C12667103100AD2C21 /* BookmarkMigrationTests.swift in Sources */, 4B9292BC2667103100AD2C21 /* BookmarkSidebarTreeControllerTests.swift in Sources */, + 37534CA52811987D002621E7 /* AdjacentItemEnumeratorTests.swift in Sources */, B662D3DC2755DF670035D4D6 /* OldPixelDataModel.xcdatamodeld in Sources */, B6DA44232616CABC00DD1EC2 /* PixelArgumentsTests.swift in Sources */, AAEC74BC2642F0F800C2EFBC /* History.xcdatamodeld in Sources */, + 37534C9E28104D9B002621E7 /* TabLazyLoaderTests.swift in Sources */, 85F1B0C925EF9759004792B6 /* URLEventHandlerTests.swift in Sources */, 4B9292BD2667103100AD2C21 /* BookmarkOutlineViewDataSourceTests.swift in Sources */, B6A5A27925B93FFF00AA7ADA /* StateRestorationManagerTests.swift in Sources */, diff --git a/DuckDuckGo/Browser Tab/Model/Tab.swift b/DuckDuckGo/Browser Tab/Model/Tab.swift index 0fa7107e51..010f033278 100644 --- a/DuckDuckGo/Browser Tab/Model/Tab.swift +++ b/DuckDuckGo/Browser Tab/Model/Tab.swift @@ -42,7 +42,7 @@ protocol TabDelegate: FileDownloadManagerDelegate, ContentOverlayUserScriptDeleg // swiftlint:disable type_body_length // swiftlint:disable file_length -final class Tab: NSObject { +final class Tab: NSObject, Identifiable { enum TabContent: Equatable { case homePage @@ -147,7 +147,9 @@ final class Tab: NSObject { parentTab: Tab? = nil, shouldLoadInBackground: Bool = false, canBeClosedWithBack: Bool = false, - currentDownload: URL? = nil) { + lastSelectedAt: Date? = nil, + currentDownload: URL? = nil + ) { self.content = content self.faviconManagement = faviconManagement @@ -160,6 +162,7 @@ final class Tab: NSObject { self.parentTab = parentTab self._canBeClosedWithBack = canBeClosedWithBack self.sessionStateData = sessionStateData + self.lastSelectedAt = lastSelectedAt self.currentDownload = currentDownload let configuration = webViewConfiguration ?? WKWebViewConfiguration() @@ -189,6 +192,7 @@ final class Tab: NSObject { // MARK: - Event Publishers let webViewDidFinishNavigationPublisher = PassthroughSubject() + let webViewDidFailNavigationPublisher = PassthroughSubject() @MainActor @Published var isAMPProtectionExtracting: Bool = false @@ -205,6 +209,8 @@ final class Tab: NSObject { var fbBlockingEnabled = true + var isLazyLoadingInProgress = false + @Published private(set) var content: TabContent { didSet { handleFavicon(oldContent: oldValue) @@ -234,6 +240,8 @@ final class Tab: NSObject { self.content = content } } + + var lastSelectedAt: Date? @Published var title: String? @Published var error: Error? @@ -1100,6 +1108,7 @@ extension Tab: WKNavigationDelegate { // https://app.asana.com/0/1199230911884351/1200381133504356/f // hasError = true + webViewDidFailNavigationPublisher.send() invalidateSessionStateData() } @@ -1113,6 +1122,7 @@ extension Tab: WKNavigationDelegate { } self.error = error + webViewDidFailNavigationPublisher.send() } @available(macOS 11.3, *) diff --git a/DuckDuckGo/Browser Tab/ViewModel/TabViewModel.swift b/DuckDuckGo/Browser Tab/ViewModel/TabViewModel.swift index 3d1cf3081d..996d9382cf 100644 --- a/DuckDuckGo/Browser Tab/ViewModel/TabViewModel.swift +++ b/DuckDuckGo/Browser Tab/ViewModel/TabViewModel.swift @@ -105,11 +105,27 @@ final class TabViewModel { } private func subscribeToTitle() { - tab.$title.receive(on: DispatchQueue.main).sink { [weak self] _ in self?.updateTitle() } .store(in: &cancellables) + tab.$title + .filter { [weak self] _ in + self?.tab.isLazyLoadingInProgress == false + } + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateTitle() + } + .store(in: &cancellables) } private func subscribeToFavicon() { - tab.$favicon.receive(on: DispatchQueue.main).sink { [weak self] _ in self?.updateFavicon() } .store(in: &cancellables) + tab.$favicon + .filter { [weak self] _ in + self?.tab.isLazyLoadingInProgress == false + } + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateFavicon() + } + .store(in: &cancellables) } private func subscribeToTabError() { diff --git a/DuckDuckGo/Common/Utilities/AdjacentItemEnumerator.swift b/DuckDuckGo/Common/Utilities/AdjacentItemEnumerator.swift new file mode 100644 index 0000000000..e89e9a95b2 --- /dev/null +++ b/DuckDuckGo/Common/Utilities/AdjacentItemEnumerator.swift @@ -0,0 +1,112 @@ +// +// AdjacentItemEnumerator.swift +// +// Copyright © 2022 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/** + * This struct generates array indices adjacent to a given index. + * + * The _adjacent_ indices are generated as diffs against given index + * with the following pattern: + * + * 1, -1, 2, -2, 3, -3, 4, -4, ... + * + */ +struct AdjacentItemEnumerator { + + /** + * The index of an item for which adjacent items indices are computed. + */ + var itemIndex: Int + + /** + * Last valid adjacent item index returned by `nextIndex(arraySize:)`. + */ + var currentAdjacentIndex: Int { + itemIndex + currentDiff + } + + /** + * Computes next adjacent index, constrained by `arraySize`. + * + * Returns the next available and valid index, between by `0` and `arraySize-1`, + * or `nil` if the index falls outside array bounds. + */ + mutating func nextIndex(arraySize: Int) -> Int? { + if currentDiff != 0 { + operation = operation.next + } + + previousDiff = currentDiff + currentDiff = operation.perform(with: currentDiff) + + let newIndex = itemIndex + currentDiff + + if newIndex < 0 || newIndex >= arraySize { + let previousIndex = itemIndex + previousDiff + if previousIndex <= 0 || previousIndex >= arraySize - 1 { + return nil + } + return nextIndex(arraySize: arraySize) + } + return newIndex + } + + /** + * Resets the enumerator internal state + * + * Calling this function is equivalent to reinstantiating the enumerator. + */ + mutating func reset() { + currentDiff = 0 + previousDiff = 0 + operation = .toggleSignAndAdvance + } + + init(itemIndex: Int = 0) { + self.itemIndex = itemIndex + } + + // MARK: - Private + + private var currentDiff = 0 + private var previousDiff = 0 + private var operation: Operation = .toggleSignAndAdvance + + private enum Operation { + case toggleSign, toggleSignAndAdvance + + func perform(with value: Int) -> Int { + switch self { + case .toggleSign: + return value * -1 + case .toggleSignAndAdvance: + return (value * -1) + 1 + } + } + + var next: Operation { + switch self { + case .toggleSign: + return .toggleSignAndAdvance + case .toggleSignAndAdvance: + return .toggleSign + } + } + } +} diff --git a/DuckDuckGo/Common/Utilities/Logging.swift b/DuckDuckGo/Common/Utilities/Logging.swift index 38f7ac82d2..d63e0838fd 100644 --- a/DuckDuckGo/Common/Utilities/Logging.swift +++ b/DuckDuckGo/Common/Utilities/Logging.swift @@ -61,6 +61,9 @@ extension OSLog { Logging.autoLockLoggingEnabled ? Logging.autoLockLog : .disabled } + static var tabLazyLoading: OSLog { + Logging.tabLazyLoaderLoggingEnabled ? Logging.tabLazyLoaderLog : .disabled + } } struct Logging { @@ -92,6 +95,9 @@ struct Logging { fileprivate static let autoLockLoggingEnabled = false fileprivate static let autoLockLog: OSLog = OSLog(subsystem: Bundle.main.bundleIdentifier ?? "DuckDuckGo", category: "Auto-Lock") + fileprivate static let tabLazyLoaderLoggingEnabled = false + fileprivate static let tabLazyLoaderLog: OSLog = OSLog(subsystem: Bundle.main.bundleIdentifier ?? "DuckDuckGo", category: "Lazy Loading") + fileprivate static let autoconsentLoggingEnabled = false fileprivate static let autoconsentLog: OSLog = OSLog(subsystem: Bundle.main.bundleIdentifier ?? "DuckDuckGo", category: "Autoconsent") diff --git a/DuckDuckGo/State Restoration/Tab+NSSecureCoding.swift b/DuckDuckGo/State Restoration/Tab+NSSecureCoding.swift index dc938fe3a0..890fd803ab 100644 --- a/DuckDuckGo/State Restoration/Tab+NSSecureCoding.swift +++ b/DuckDuckGo/State Restoration/Tab+NSSecureCoding.swift @@ -29,6 +29,7 @@ extension Tab: NSSecureCoding { static let tabType = "tabType" static let preferencePane = "preferencePane" static let visitedDomains = "visitedDomains" + static let lastSelectedAt = "lastSelectedAt" static let currentDownload = "currentDownload" } @@ -52,6 +53,7 @@ extension Tab: NSSecureCoding { title: decoder.decodeIfPresent(at: NSSecureCodingKeys.title), favicon: decoder.decodeIfPresent(at: NSSecureCodingKeys.favicon), sessionStateData: decoder.decodeIfPresent(at: NSSecureCodingKeys.sessionStateData), + lastSelectedAt: decoder.decodeIfPresent(at: NSSecureCodingKeys.lastSelectedAt), currentDownload: currentDownload) } @@ -64,6 +66,7 @@ extension Tab: NSSecureCoding { favicon.map(coder.encode(forKey: NSSecureCodingKeys.favicon)) getActualSessionStateData().map(coder.encode(forKey: NSSecureCodingKeys.sessionStateData)) coder.encode(content.type.rawValue, forKey: NSSecureCodingKeys.tabType) + lastSelectedAt.map(coder.encode(forKey: NSSecureCodingKeys.lastSelectedAt)) coder.encode(currentDownload, forKey: NSSecureCodingKeys.currentDownload) if let pane = content.preferencePane { diff --git a/DuckDuckGo/State Restoration/WindowManager+StateRestoration.swift b/DuckDuckGo/State Restoration/WindowManager+StateRestoration.swift index d673b8f3e5..ff0d7e2b85 100644 --- a/DuckDuckGo/State Restoration/WindowManager+StateRestoration.swift +++ b/DuckDuckGo/State Restoration/WindowManager+StateRestoration.swift @@ -34,6 +34,7 @@ extension WindowsManager { let isOriginalKeyWindowPresent = Self.windows.contains(where: {$0.isKeyWindow}) var newKeyWindow: NSWindow? + var newKeyWindowModel: TabCollectionViewModel? for (idx, item) in state.windows.enumerated() { guard let window = self.openNewWindow(with: item.model, showWindow: false) else { continue } window.setContentSize(item.frame.size) @@ -41,10 +42,12 @@ extension WindowsManager { if idx == state.keyWindowIndex { newKeyWindow = window + newKeyWindowModel = item.model } } if !isOriginalKeyWindowPresent { newKeyWindow?.makeKeyAndOrderFront(self) + newKeyWindowModel?.setUpLazyLoadingIfNeeded() } if !state.windows.isEmpty { diff --git a/DuckDuckGo/Tab Bar/ViewModel/TabCollectionViewModel.swift b/DuckDuckGo/Tab Bar/ViewModel/TabCollectionViewModel.swift index ab63d43c9f..b6bf1ca861 100644 --- a/DuckDuckGo/Tab Bar/ViewModel/TabCollectionViewModel.swift +++ b/DuckDuckGo/Tab Bar/ViewModel/TabCollectionViewModel.swift @@ -59,6 +59,8 @@ final class TabCollectionViewModel: NSObject { // In a special occasion, we want to select the "parent" tab after closing the currently selected tab private var selectParentOnRemoval = false + private var tabLazyLoader: TabLazyLoader? + private var isTabLazyLoadingRequested: Bool = false private var cancellables = Set() @@ -82,6 +84,25 @@ final class TabCollectionViewModel: NSObject { self.init(tabCollection: tabCollection) } + func setUpLazyLoadingIfNeeded() { + guard !isTabLazyLoadingRequested else { + os_log("Lazy loading already requested in this session, skipping.", log: .tabLazyLoading, type: .debug) + return + } + + tabLazyLoader = TabLazyLoader(dataSource: self) + isTabLazyLoadingRequested = true + + tabLazyLoader?.lazyLoadingDidFinishPublisher + .sink { [weak self] _ in + self?.tabLazyLoader = nil + os_log("Disposed of Tab Lazy Loader", log: .tabLazyLoading, type: .debug) + } + .store(in: &cancellables) + + tabLazyLoader?.scheduleLazyLoading() + } + func tabViewModel(at index: Int) -> TabViewModel? { guard index >= 0, tabCollection.tabs.count > index else { os_log("TabCollectionViewModel: Index out of bounds", type: .error) @@ -463,6 +484,7 @@ final class TabCollectionViewModel: NSObject { return } let selectedTabViewModel = tabViewModel(at: selectionIndex) + selectedTabViewModel?.tab.lastSelectedAt = Date() self.selectedTabViewModel = selectedTabViewModel } diff --git a/DuckDuckGo/Tab Bar/ViewModel/TabLazyLoader/LazyLoadable.swift b/DuckDuckGo/Tab Bar/ViewModel/TabLazyLoader/LazyLoadable.swift new file mode 100644 index 0000000000..850be303e9 --- /dev/null +++ b/DuckDuckGo/Tab Bar/ViewModel/TabLazyLoader/LazyLoadable.swift @@ -0,0 +1,62 @@ +// +// LazyLoadable.swift +// +// Copyright © 2022 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine + +protocol LazyLoadable: AnyObject, Identifiable { + + var isUrl: Bool { get } + var url: URL? { get } + + var webViewFrame: CGRect { get set } + var isLazyLoadingInProgress: Bool { get set } + var loadingFinishedPublisher: AnyPublisher { get } + + func reload() + func isNewer(than other: Self) -> Bool +} + +extension Tab: LazyLoadable { + var isUrl: Bool { content.isUrl } + + var url: URL? { content.url } + + var loadingFinishedPublisher: AnyPublisher { + Publishers.Merge(webViewDidFinishNavigationPublisher, webViewDidFailNavigationPublisher) + .prefix(1) + .map { self } + .eraseToAnyPublisher() + } + + var webViewFrame: CGRect { + get { webView.frame } + set { webView.frame = newValue } + } + + func isNewer(than other: Tab) -> Bool { + switch (lastSelectedAt, other.lastSelectedAt) { + case (.none, .none), (.some, .none): + return true + case (.none, .some): + return false + case (.some(let timestamp), .some(let otherTimestamp)): + return timestamp > otherTimestamp + } + } +} diff --git a/DuckDuckGo/Tab Bar/ViewModel/TabLazyLoader/TabLazyLoader.swift b/DuckDuckGo/Tab Bar/ViewModel/TabLazyLoader/TabLazyLoader.swift new file mode 100644 index 0000000000..3e4e1809bb --- /dev/null +++ b/DuckDuckGo/Tab Bar/ViewModel/TabLazyLoader/TabLazyLoader.swift @@ -0,0 +1,264 @@ +// +// TabLazyLoader.swift +// +// Copyright © 2022 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine +import os + +final class TabLazyLoader { + + enum Const { + static var maxNumberOfLazyLoadedTabs: Int { 20 } + static var maxNumberOfLazyLoadedAdjacentTabs: Int { 10 } + static var maxNumberOfConcurrentlyLoadedTabs: Int { 3 } + } + + /** + * Emits output when lazy loader finishes. + * + * The output is `true` if lazy loading was performed and `false` if no tabs were lazy loaded. + */ + private(set) lazy var lazyLoadingDidFinishPublisher: AnyPublisher = { + lazyLoadingDidFinishSubject.prefix(1).eraseToAnyPublisher() + }() + + private(set) lazy var isLazyLoadingPausedPublisher: AnyPublisher = { + isLazyLoadingPausedSubject.removeDuplicates().eraseToAnyPublisher() + }() + + init?(dataSource: DataSource) { + guard dataSource.qualifiesForLazyLoading else { + os_log("Lazy loading not applicable", log: .tabLazyLoading, type: .debug) + return nil + } + + self.dataSource = dataSource + + if let selectedTabIndex = dataSource.selectedTabIndex, + dataSource.tabs.filter({ $0.isUrl }).count > Const.maxNumberOfLazyLoadedTabs { + + os_log("%d open URL tabs, will load adjacent tabs first", log: .tabLazyLoading, type: .debug, dataSource.tabs.count) + shouldLoadAdjacentTabs = true + adjacentItemEnumerator = .init(itemIndex: selectedTabIndex) + } else { + shouldLoadAdjacentTabs = false + } + } + + func scheduleLazyLoading() { + guard let currentTab = dataSource.selectedTab else { + os_log("Lazy loading not applicable", log: .tabLazyLoading, type: .debug) + lazyLoadingDidFinishSubject.send(false) + return + } + + trackUserSwitchingTabs() + delayLazyLoadingUntilCurrentTabFinishesLoading(currentTab) + } + + // MARK: - Private + + private let lazyLoadingDidFinishSubject = PassthroughSubject() + private let isLazyLoadingPausedSubject = CurrentValueSubject(false) + private let tabDidLoadSubject = PassthroughSubject() + + private let numberOfTabsInProgress = CurrentValueSubject(0) + private var numberOfTabsRemaining = Const.maxNumberOfLazyLoadedTabs + + private let shouldLoadAdjacentTabs: Bool + private var adjacentItemEnumerator: AdjacentItemEnumerator? + private var numberOfAdjacentTabsRemaining = Const.maxNumberOfLazyLoadedAdjacentTabs + + private var idsOfTabsSelectedOrReloadedInThisSession = Set() + private var cancellables = Set() + + private unowned var dataSource: DataSource + + private func trackUserSwitchingTabs() { + dataSource.selectedTabPublisher + .sink { [weak self] tab in + self?.idsOfTabsSelectedOrReloadedInThisSession.insert(tab.id) + } + .store(in: &cancellables) + } + + private func delayLazyLoadingUntilCurrentTabFinishesLoading(_ tab: DataSource.Tab) { + guard tab.isUrl else { + startLazyLoadingRecentlySelectedTabs() + return + } + + tab.loadingFinishedPublisher + .sink { [weak self] _ in + self?.startLazyLoadingRecentlySelectedTabs() + } + .store(in: &cancellables) + } + + private func startLazyLoadingRecentlySelectedTabs() { + guard hasAnyTabsToLoad() else { + os_log("No tabs to load", log: .tabLazyLoading, type: .debug) + let loadedAnyTab = numberOfTabsRemaining < Const.maxNumberOfLazyLoadedTabs + lazyLoadingDidFinishSubject.send(loadedAnyTab) + return + } + + tabDidLoadSubject + .prefix(Const.maxNumberOfLazyLoadedTabs) + .sink(receiveCompletion: { [weak self] _ in + + os_log("Lazy tab loading finished, preloaded %d tabs", log: .tabLazyLoading, type: .debug, Const.maxNumberOfLazyLoadedTabs) + self?.lazyLoadingDidFinishSubject.send(true) + + }, receiveValue: { [weak self] tab in + + os_log("Tab did finish loading %s", log: .tabLazyLoading, type: .debug, String(reflecting: tab.url)) + self?.numberOfTabsInProgress.value -= 1 + + }) + .store(in: &cancellables) + + willReloadNextTab + .sink { [weak self] in + self?.findAndReloadNextTab() + } + .store(in: &cancellables) + } + + private var willReloadNextTab: AnyPublisher { + let readyToLoadNextTab = numberOfTabsInProgress + .filter { [weak self] _ in + guard let self = self else { return false } + + if self.dataSource.isSelectedTabLoading { + os_log("Selected tab is currently loading, pausing lazy loading until it finishes", log: .tabLazyLoading, type: .debug) + self.isLazyLoadingPausedSubject.send(true) + return false + } + self.isLazyLoadingPausedSubject.send(false) + return true + } + .asVoid() + + let selectedTabDidFinishLoading = dataSource.isSelectedTabLoadingPublisher.filter({ !$0 }).asVoid() + + return Publishers.Merge(readyToLoadNextTab, selectedTabDidFinishLoading) + .filter { [weak self] in + (self?.numberOfTabsInProgress.value ?? 0) < Const.maxNumberOfConcurrentlyLoadedTabs + } + .eraseToAnyPublisher() + } + + private func findAndReloadNextTab() { + guard numberOfTabsRemaining > 0 else { + os_log("Maximum allowed tabs loaded (%d), skipping", log: .tabLazyLoading, type: .debug, Const.maxNumberOfLazyLoadedTabs) + return + } + + let tabToLoad = findTabToLoad() + + switch (tabToLoad, numberOfTabsInProgress.value) { + case (.none, 0): + os_log("No more tabs suitable for lazy loading", log: .tabLazyLoading, type: .debug) + lazyLoadingDidFinishSubject.send(true) + case (.none, _): + break + case (let .some(tab), _): + lazyLoadTab(tab) + } + } + + private func hasAnyTabsToLoad() -> Bool { + return findTabToLoad(dryRun: true) != nil + } + + /** + * `dryRun` parameter, when set to `true`, reverts any changes made to lazy loader's state. + * + * This is to allow this function to be called to check whether there is any tab, + * either adjacent to current or recently selected, that can be loaded. + */ + private func findTabToLoad(dryRun: Bool = false) -> DataSource.Tab? { + var tab: DataSource.Tab? + + if shouldLoadAdjacentTabs, numberOfAdjacentTabsRemaining > 0 { + tab = findAdjacentTabToLoad() + if dryRun { + adjacentItemEnumerator?.reset() + } else if tab != nil { + numberOfAdjacentTabsRemaining -= 1 + os_log("Will reload adjacent tab #%d of %d", log: .tabLazyLoading, type: .debug, + Const.maxNumberOfLazyLoadedAdjacentTabs - numberOfAdjacentTabsRemaining, + Const.maxNumberOfLazyLoadedAdjacentTabs) + } + } + + if tab == nil { + tab = findRecentlySelectedTabToLoad() + if !dryRun, tab != nil { + os_log("Will reload recently selected tab", log: .tabLazyLoading, type: .debug) + } + } + + return tab + } + + private func findAdjacentTabToLoad() -> DataSource.Tab? { + while true { + guard let nextIndex = adjacentItemEnumerator?.nextIndex(arraySize: dataSource.tabs.count) else { + return nil + } + let tab = dataSource.tabs[nextIndex] + if tab.isUrl { + return tab + } + } + } + + private func findRecentlySelectedTabToLoad() -> DataSource.Tab? { + dataSource.tabs + .filter { $0.isUrl && !idsOfTabsSelectedOrReloadedInThisSession.contains($0.id) } + .sorted { $0.isNewer(than: $1) } + .first + } + + private func lazyLoadTab(_ tab: DataSource.Tab) { + os_log("Reloading %s", log: .tabLazyLoading, type: .debug, String(reflecting: tab.url)) + + subscribeToTabLoadingFinished(tab) + idsOfTabsSelectedOrReloadedInThisSession.insert(tab.id) + + if let selectedTabWebViewFrame = dataSource.selectedTab?.webViewFrame { + tab.webViewFrame = selectedTabWebViewFrame + } + + tab.isLazyLoadingInProgress = true + tab.reload() + numberOfTabsRemaining -= 1 + numberOfTabsInProgress.value += 1 + } + + private func subscribeToTabLoadingFinished(_ tab: DataSource.Tab) { + tab.loadingFinishedPublisher + .sink(receiveValue: { [weak self] tab in + tab.isLazyLoadingInProgress = false + self?.tabDidLoadSubject.send(tab) + }) + .store(in: &cancellables) + } +} diff --git a/DuckDuckGo/Tab Bar/ViewModel/TabLazyLoader/TabLazyLoaderDataSource.swift b/DuckDuckGo/Tab Bar/ViewModel/TabLazyLoader/TabLazyLoaderDataSource.swift new file mode 100644 index 0000000000..d84caddae9 --- /dev/null +++ b/DuckDuckGo/Tab Bar/ViewModel/TabLazyLoader/TabLazyLoaderDataSource.swift @@ -0,0 +1,76 @@ +// +// TabLazyLoaderDataSource.swift +// +// Copyright © 2022 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine + +protocol TabLazyLoaderDataSource: AnyObject { + associatedtype Tab: LazyLoadable + + var tabs: [Tab] { get } + var selectedTab: Tab? { get } + var selectedTabIndex: Int? { get } + + var selectedTabPublisher: AnyPublisher { get } + + var isSelectedTabLoading: Bool { get } + var isSelectedTabLoadingPublisher: AnyPublisher { get } +} + +extension TabLazyLoaderDataSource { + var qualifiesForLazyLoading: Bool { + + let notSelectedURLTabsCount: Int = { + let count = tabs.filter({ $0.isUrl }).count + let isURLTabSelected = selectedTab?.isUrl ?? false + return isURLTabSelected ? count-1 : count + }() + + return notSelectedURLTabsCount > 0 + } +} + +extension TabCollectionViewModel: TabLazyLoaderDataSource { + + var tabs: [Tab] { + tabCollection.tabs + } + + var selectedTab: Tab? { + selectedTabViewModel?.tab + } + + var selectedTabIndex: Int? { + selectionIndex + } + + var selectedTabPublisher: AnyPublisher { + $selectedTabViewModel.compactMap(\.?.tab).eraseToAnyPublisher() + } + + var isSelectedTabLoading: Bool { + selectedTabViewModel?.isLoading ?? false + } + + var isSelectedTabLoadingPublisher: AnyPublisher { + $selectedTabViewModel + .compactMap { $0 } + .flatMap(\.$isLoading) + .eraseToAnyPublisher() + } +} diff --git a/Unit Tests/Tab Bar/ViewModel/AdjacentItemEnumeratorTests.swift b/Unit Tests/Tab Bar/ViewModel/AdjacentItemEnumeratorTests.swift new file mode 100644 index 0000000000..f819f3a44d --- /dev/null +++ b/Unit Tests/Tab Bar/ViewModel/AdjacentItemEnumeratorTests.swift @@ -0,0 +1,67 @@ +// +// AdjacentItemEnumeratorTests.swift +// +// Copyright © 2022 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +class AdjacentItemEnumeratorTests: XCTestCase { + + func testAdjacentItems() throws { + var enumerator = AdjacentItemEnumerator(itemIndex: 5) + + var indices: [Int?] = [] + for _ in 0..<10 { + indices.append(enumerator.nextIndex(arraySize: 20)) + } + + XCTAssertEqual(indices.compactMap({ $0 }), [6, 4, 7, 3, 8, 2, 9, 1, 10, 0]) + } + + func testThatEnumeratorDoesNotCrossZero() throws { + var enumerator = AdjacentItemEnumerator(itemIndex: 0) + + var indices: [Int?] = [] + for _ in 0..<5 { + indices.append(enumerator.nextIndex(arraySize: 10)) + } + + XCTAssertEqual(indices.compactMap({ $0 }), [1, 2, 3, 4, 5]) + } + + func testThatEnumeratorDoesNotExceedArraySize() throws { + var enumerator = AdjacentItemEnumerator(itemIndex: 8) + + var indices: [Int?] = [] + for _ in 0..<5 { + indices.append(enumerator.nextIndex(arraySize: 10)) + } + + XCTAssertEqual(indices.compactMap({ $0 }), [9, 7, 6, 5, 4]) + } + + func testThatEnumeratorReturnsNilWhenOutOfArrayBounds() throws { + var enumerator = AdjacentItemEnumerator(itemIndex: 2) + + var indices: [Int?] = [] + for _ in 0..<5 { + indices.append(enumerator.nextIndex(arraySize: 4)) + } + + XCTAssertEqual(indices.compactMap({ $0 }), [3, 1, 0]) + } +} diff --git a/Unit Tests/Tab Bar/ViewModel/TabLazyLoaderTests.swift b/Unit Tests/Tab Bar/ViewModel/TabLazyLoaderTests.swift new file mode 100644 index 0000000000..4c694a9e33 --- /dev/null +++ b/Unit Tests/Tab Bar/ViewModel/TabLazyLoaderTests.swift @@ -0,0 +1,376 @@ +// +// TabLazyLoaderTests.swift +// +// Copyright © 2022 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Combine +@testable import DuckDuckGo_Privacy_Browser + +private final class TabMock: LazyLoadable { + + var isLazyLoadingInProgress: Bool = false + + var isUrl: Bool = true + var url: URL? = "https://example.com".url + var webViewFrame: CGRect = .zero + + var loadingFinishedSubject = PassthroughSubject() + lazy var loadingFinishedPublisher: AnyPublisher = loadingFinishedSubject.eraseToAnyPublisher() + + func isNewer(than other: TabMock) -> Bool { isNewerClosure(other) } + func reload() { reloadClosure(self) } + + var isNewerClosure: (TabMock) -> Bool = { _ in true } + var reloadClosure: (TabMock) -> Void = { _ in } + + var selectedTimestamp: Date + + init( + isUrl: Bool = true, + url: URL? = "https://example.com".url, + webViewFrame: CGRect = .zero, + reloadExpectation: XCTestExpectation? = nil, + selectedTimestamp: Date = Date(timeIntervalSince1970: 0) + ) { + self.isUrl = isUrl + self.url = url + self.webViewFrame = webViewFrame + self.selectedTimestamp = selectedTimestamp + + isNewerClosure = { [unowned self] other in + self.selectedTimestamp > other.selectedTimestamp + } + + reloadClosure = { tab in + // instantly notify that loading has finished (or failed) + DispatchQueue.main.async { + reloadExpectation?.fulfill() + tab.loadingFinishedSubject.send(tab) + } + } + } + + static let mockUrl = TabMock() + static let mockNotUrl = TabMock(isUrl: false, url: nil) +} + +private final class TabLazyLoaderDataSourceMock: TabLazyLoaderDataSource { + + typealias Tab = TabMock + + var tabs: [Tab] = [] + var selectedTab: Tab? + var selectedTabIndex: Int? + var selectedTabPublisher: AnyPublisher { + selectedTabSubject.eraseToAnyPublisher() + } + + var selectedTabSubject = PassthroughSubject() + + var isSelectedTabLoading: Bool = false + var isSelectedTabLoadingPublisher: AnyPublisher { + isSelectedTabLoadingSubject.eraseToAnyPublisher() + } + + var isSelectedTabLoadingSubject = PassthroughSubject() +} + +class TabLazyLoaderTests: XCTestCase { + + // swiftlint:disable implicitly_unwrapped_optional + private var dataSource: TabLazyLoaderDataSourceMock! + var cancellables = Set() + + override func setUpWithError() throws { + try super.setUpWithError() + dataSource = TabLazyLoaderDataSourceMock() + cancellables.removeAll() + } + + func testWhenThereAreNoTabsThenLazyLoaderIsNotInstantiated() throws { + dataSource.tabs = [] + XCTAssertNil(TabLazyLoader(dataSource: dataSource)) + } + + func testWhenThereAreNoUrlTabsThenLazyLoaderIsNotInstantiated() throws { + dataSource.tabs = [.mockNotUrl, .mockNotUrl] + XCTAssertNil(TabLazyLoader(dataSource: dataSource)) + } + + func testWhenThereIsOneUrlTabAndItIsCurrentlySelectedThenLazyLoaderIsNotInstantiated() throws { + let urlTab = TabMock.mockUrl + dataSource.tabs = [.mockNotUrl, .mockNotUrl, urlTab] + dataSource.selectedTab = urlTab + XCTAssertNil(TabLazyLoader(dataSource: dataSource)) + } + + func testWhenThereIsOneUrlTabAndItIsNotCurrentlySelectedThenLazyLoaderIsInstantiated() throws { + let notUrlTab = TabMock.mockUrl + dataSource.tabs = [.mockNotUrl, notUrlTab, .mockUrl] + dataSource.selectedTab = notUrlTab + XCTAssertNotNil(TabLazyLoader(dataSource: dataSource)) + } + + func testWhenThereIsNoSelectedTabThenLazyLoadingIsSkipped() throws { + dataSource.tabs = [.mockUrl] + dataSource.selectedTab = nil + + let lazyLoader = TabLazyLoader(dataSource: dataSource) + + var didFinishEvents: [Bool] = [] + lazyLoader?.lazyLoadingDidFinishPublisher + .sink(receiveValue: { value in + didFinishEvents.append(value) + }) + .store(in: &cancellables) + + lazyLoader?.scheduleLazyLoading() + + XCTAssertEqual(didFinishEvents.count, 1) + XCTAssertEqual(try XCTUnwrap(didFinishEvents.first), false) + } + + func testWhenSelectedTabIsNotUrlThenLazyLoadingStartsImmediately() throws { + let reloadExpectation = expectation(description: "TabMock.reload() called") + reloadExpectation.expectedFulfillmentCount = 2 + + dataSource.tabs = [ + .mockNotUrl, + TabMock.init(isUrl: true, reloadExpectation: reloadExpectation), + TabMock.init(isUrl: true, reloadExpectation: reloadExpectation) + ] + dataSource.selectedTab = dataSource.tabs.first + + let lazyLoader = TabLazyLoader(dataSource: dataSource) + + var didFinishEvents: [Bool] = [] + lazyLoader?.lazyLoadingDidFinishPublisher.sink(receiveValue: { didFinishEvents.append($0) }).store(in: &cancellables) + + // When + lazyLoader?.scheduleLazyLoading() + + // Then + waitForExpectations(timeout: 0.3) + XCTAssertEqual(didFinishEvents.count, 1) + XCTAssertEqual(try XCTUnwrap(didFinishEvents.first), true) + } + + func testThatLazyLoadingStartsAfterCurrentUrlTabFinishesLoading() throws { + let reloadExpectation = expectation(description: "TabMock.reload() called") + reloadExpectation.expectedFulfillmentCount = 2 + + let selectedUrlTab = TabMock.mockUrl + + dataSource.tabs = [ + selectedUrlTab, + TabMock.init(isUrl: true, reloadExpectation: reloadExpectation), + TabMock.init(isUrl: true, reloadExpectation: reloadExpectation) + ] + dataSource.selectedTab = dataSource.tabs.first + + let lazyLoader = TabLazyLoader(dataSource: dataSource) + + var didFinishEvents: [Bool] = [] + lazyLoader?.lazyLoadingDidFinishPublisher.sink(receiveValue: { didFinishEvents.append($0) }).store(in: &cancellables) + + // When + lazyLoader?.scheduleLazyLoading() + selectedUrlTab.reload() + + // Then + waitForExpectations(timeout: 0.3) + XCTAssertEqual(didFinishEvents.count, 1) + XCTAssertEqual(try XCTUnwrap(didFinishEvents.first), true) + } + + func testThatLazyLoadingDoesNotStartIfCurrentUrlTabDoesNotFinishLoading() throws { + let reloadExpectation = expectation(description: "TabMock.reload() called") + reloadExpectation.isInverted = true + + dataSource.tabs = [ + .mockUrl, + TabMock.init(isUrl: true, reloadExpectation: reloadExpectation), + TabMock.init(isUrl: true, reloadExpectation: reloadExpectation) + ] + dataSource.selectedTab = dataSource.tabs.first + + let lazyLoader = TabLazyLoader(dataSource: dataSource) + + var didFinishEvents: [Bool] = [] + lazyLoader?.lazyLoadingDidFinishPublisher.sink(receiveValue: { didFinishEvents.append($0) }).store(in: &cancellables) + + // When + lazyLoader?.scheduleLazyLoading() + + // Then + waitForExpectations(timeout: 0.3) + XCTAssertEqual(didFinishEvents.count, 0) + } + + func testThatLazyLoadingStopsAfterLoadingMaximumNumberOfTabs() throws { + let maxNumberOfLazyLoadedTabs = TabLazyLoader.Const.maxNumberOfLazyLoadedTabs + let reloadExpectation = expectation(description: "TabMock.reload() called") + reloadExpectation.expectedFulfillmentCount = maxNumberOfLazyLoadedTabs + + dataSource.tabs = [.mockNotUrl] + for _ in 0..<(2 * maxNumberOfLazyLoadedTabs) { + dataSource.tabs.append(TabMock.init(isUrl: true, reloadExpectation: reloadExpectation)) + } + dataSource.selectedTab = dataSource.tabs.first + + let lazyLoader = TabLazyLoader(dataSource: dataSource) + + var didFinishEvents: [Bool] = [] + lazyLoader?.lazyLoadingDidFinishPublisher.sink(receiveValue: { didFinishEvents.append($0) }).store(in: &cancellables) + + // When + lazyLoader?.scheduleLazyLoading() + + // Then + waitForExpectations(timeout: 0.3) + XCTAssertEqual(didFinishEvents.count, 1) + XCTAssertEqual(try XCTUnwrap(didFinishEvents.first), true) + } + + func testThatLazyLoadingSkipsTabsSelectedInCurrentSession() throws { + let reloadExpectation = expectation(description: "TabMock.reload() called") + reloadExpectation.expectedFulfillmentCount = 2 + + let selectedUrlTab = TabMock.mockUrl + + dataSource.tabs = [ + selectedUrlTab, + TabMock.init(isUrl: true, reloadExpectation: reloadExpectation), + TabMock.init(isUrl: true, reloadExpectation: reloadExpectation), + TabMock.init(isUrl: true, reloadExpectation: reloadExpectation), + TabMock.init(isUrl: true, reloadExpectation: reloadExpectation), + TabMock.init(isUrl: true, reloadExpectation: reloadExpectation) + ] + dataSource.selectedTab = selectedUrlTab + + let lazyLoader = TabLazyLoader(dataSource: dataSource) + + var didFinishEvents: [Bool] = [] + lazyLoader?.lazyLoadingDidFinishPublisher.sink(receiveValue: { didFinishEvents.append($0) }).store(in: &cancellables) + + // When + lazyLoader?.scheduleLazyLoading() + + dataSource.selectedTabSubject.send(dataSource.tabs[1]) + dataSource.selectedTabSubject.send(dataSource.tabs[4]) + dataSource.selectedTabSubject.send(dataSource.tabs[5]) + + selectedUrlTab.reload() + + // Then + waitForExpectations(timeout: 0.3) + XCTAssertEqual(didFinishEvents.count, 1) + XCTAssertEqual(try XCTUnwrap(didFinishEvents.first), true) + } + + func testWhenTabNumberExceedsMaximumForLazyLoadingThenAdjacentTabsAreLoadedFirst() throws { + let maxNumberOfLazyLoadedTabs = TabLazyLoader.Const.maxNumberOfLazyLoadedTabs + let reloadExpectation = expectation(description: "TabMock.reload() called") + reloadExpectation.expectedFulfillmentCount = maxNumberOfLazyLoadedTabs + 1 + + var reloadedTabsIndices = [Int]() + + // add 2 * max number tabs, ordered by selected timestamp ascending + for i in 0..<(2 * maxNumberOfLazyLoadedTabs) { + let tab = TabMock(isUrl: true, url: "http://\(i).com".url!, selectedTimestamp: Date(timeIntervalSince1970: .init(i))) + tab.reloadClosure = { tab in + DispatchQueue.main.async { + reloadedTabsIndices.append(i) + tab.loadingFinishedSubject.send(tab) + reloadExpectation.fulfill() + } + } + dataSource.tabs.append(tab) + } + + // select tab #3, this will cause loading tabs adjacent to #3, and then from the end of the array (based on timestamp) + dataSource.selectedTab = dataSource.tabs[3] + dataSource.selectedTabIndex = 3 + + let lazyLoader = TabLazyLoader(dataSource: dataSource) + + var didFinishEvents: [Bool] = [] + lazyLoader?.lazyLoadingDidFinishPublisher.sink(receiveValue: { didFinishEvents.append($0) }).store(in: &cancellables) + + // When + lazyLoader?.scheduleLazyLoading() + dataSource.selectedTab?.reload() + + // Then + waitForExpectations(timeout: 0.3) + XCTAssertEqual(didFinishEvents.count, 1) + XCTAssertEqual(reloadedTabsIndices, [3, 4, 2, 5, 1, 6, 0, 7, 8, 9, 10, 39, 38, 37, 36, 35, 34, 33, 32, 31, 30]) + } + + /** + * This test sets up 2 tabs suitable for lazy loading. + * When the first one is reloaded, it artificially triggers currently selected tab reload. + * This effectively pauses lazy loading and prevents the other tab from being reloaded + * until currently selected tab is marked as done loading. + */ + func testWhenSelectedTabIsLoadingThenLazyLoadingIsPaused() throws { + var reloadedTabsUrls = [URL?]() + + let tabReloadClosure: (TabMock) -> Void = { tab in + reloadedTabsUrls.append(tab.url) + tab.loadingFinishedSubject.send(tab) + } + + let oldTab = TabMock(isUrl: true, url: "http://old.com".url, selectedTimestamp: .init(timeIntervalSince1970: 0)) + let newTab = TabMock(isUrl: true, url: "http://new.com".url, selectedTimestamp: .init(timeIntervalSince1970: 1)) + + oldTab.reloadClosure = tabReloadClosure + newTab.reloadClosure = { [unowned self] tab in + // mark currently selected tab as reloading, causing lazy loading to pause + self.dataSource.isSelectedTabLoading = true + self.dataSource.isSelectedTabLoadingSubject.send(true) + tabReloadClosure(tab) + } + + dataSource.tabs = [.mockNotUrl, newTab, oldTab] + dataSource.selectedTab = dataSource.tabs.first + + let lazyLoader = TabLazyLoader(dataSource: dataSource) + + var didFinishEvents: [Bool] = [] + var isLazyLoadingPausedEvents: [Bool] = [] + + lazyLoader?.lazyLoadingDidFinishPublisher.sink(receiveValue: { didFinishEvents.append($0) }).store(in: &cancellables) + lazyLoader?.isLazyLoadingPausedPublisher.sink(receiveValue: { isLazyLoadingPausedEvents.append($0) }).store(in: &cancellables) + + // When + lazyLoader?.scheduleLazyLoading() + + XCTAssertEqual(reloadedTabsUrls, [newTab.url]) + + // unpause lazy loading here + dataSource.isSelectedTabLoading = false + dataSource.isSelectedTabLoadingSubject.send(false) + + // Then + XCTAssertEqual(reloadedTabsUrls, [newTab.url, oldTab.url]) + XCTAssertEqual(didFinishEvents.count, 1) + XCTAssertEqual(isLazyLoadingPausedEvents, [false, true, false]) + XCTAssertEqual(try XCTUnwrap(didFinishEvents.first), true) + } + +}