diff --git a/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift b/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift
index b084eda59..51a7e908c 100644
--- a/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift
+++ b/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift
@@ -130,16 +130,22 @@ public actor EditorAssetLibrary {
let editorRepresentation = try manifest.buildEditorRepresentation(for: self.configuration)
try bundle.writeManifest(editorRepresentation: editorRepresentation)
- try await withThrowingTaskGroup { group in
+ await withTaskGroup { group in
let links = (manifest.scripts + manifest.styles).filter { self.isSupportedAsset($0) }
-
+
for asset in links {
group.addTask {
- (asset, try await self.fetchAsset(url: asset, into: bundle))
+ do {
+ try await self.fetchAsset(url: asset, into: bundle)
+ } catch {
+ // Log and continue - individual asset failures shouldn't block the editor
+ // This handles cases like content blockers blocking analytics scripts
+ log(.warn, "Failed to download asset \(asset.lastPathComponent): \(error.localizedDescription)")
+ }
}
}
-
- for try await _ in group {
+
+ for await _ in group {
complete += 1
await progress?(EditorProgress(completed: complete, total: links.count))
}
@@ -150,6 +156,7 @@ public actor EditorAssetLibrary {
/// Downloads a single asset and copies it into the temporary bundle directory.
///
+ @discardableResult
private func fetchAsset(url: URL, into bundle: EditorAssetBundle) async throws -> URL {
let tempUrl = try await logExecutionTime("Downloading \(url.lastPathComponent)") {
try await httpClient.download(URLRequest(method: .GET, url: url)).0
@@ -157,7 +164,7 @@ public actor EditorAssetLibrary {
let destinationPath = bundle.bundleRoot.appending(path: url.path(percentEncoded: false))
let destinationParent = destinationPath.deletingLastPathComponent()
-
+
// Ensure the destination directory exists
try FileManager.default.createDirectory(at: destinationParent, withIntermediateDirectories: true)
diff --git a/ios/Tests/GutenbergKitTests/Stores/EditorAssetLibraryTests.swift b/ios/Tests/GutenbergKitTests/Stores/EditorAssetLibraryTests.swift
index 0e354a441..cd18ec507 100644
--- a/ios/Tests/GutenbergKitTests/Stores/EditorAssetLibraryTests.swift
+++ b/ios/Tests/GutenbergKitTests/Stores/EditorAssetLibraryTests.swift
@@ -676,7 +676,12 @@ struct EditorAssetLibraryTests {
"""
let mockClient = EditorAssetLibraryMockHTTPClient()
- mockClient.urlResponseHandler = { _ in Data(manifestJSON.utf8) }
+ mockClient.urlResponseHandler = { url in
+ if url.path.contains("editor-assets") {
+ return Data(manifestJSON.utf8)
+ }
+ return Data("mock content".utf8)
+ }
let library = makeLibrary(httpClient: mockClient, cachePolicy: .ignore)
@@ -769,7 +774,12 @@ struct EditorAssetLibraryTests {
"""
let mockClient = EditorAssetLibraryMockHTTPClient()
- mockClient.urlResponseHandler = { _ in Data(manifestJSON.utf8) }
+ mockClient.urlResponseHandler = { url in
+ if url.path.contains("editor-assets") {
+ return Data(manifestJSON.utf8)
+ }
+ return Data("mock content".utf8)
+ }
let library = makeLibrary(httpClient: mockClient, cachePolicy: .ignore)
@@ -816,6 +826,53 @@ struct EditorAssetLibraryTests {
#expect(progressTracker.count == 1)
#expect(progressTracker.updates.first?.total == 1)
}
+
+ @Test("buildBundle continues when individual asset downloads fail")
+ func buildBundleContinuesWhenAssetDownloadsFail() async throws {
+ let manifestJSON = """
+ {
+ "scripts": "",
+ "styles": "",
+ "allowed_block_types": ["core/paragraph"]
+ }
+ """
+
+ let mockClient = EditorAssetLibraryMockHTTPClient()
+ mockClient.urlResponseHandler = { url in
+ if url.path.contains("editor-assets") {
+ return Data(manifestJSON.utf8)
+ }
+ // Simulate content blocker blocking stats.js
+ if url.host == "blocked.com" {
+ throw URLError(.badURL)
+ }
+ return Data("mock content".utf8)
+ }
+
+ let library = makeLibrary(httpClient: mockClient, cachePolicy: .ignore)
+
+ let manifest = try await library.fetchManifest()
+
+ // Should NOT throw - individual asset failures are caught and logged
+ let bundle = try await library.buildBundle(for: manifest)
+
+ // Bundle should be created successfully
+ #expect(!bundle.id.isEmpty)
+
+ // Verify progress was reported for all assets (including the failed one)
+ #expect(mockClient.downloadCallCount == 3)
+
+ // Verify the successful assets were downloaded
+ let bundleRoot = await library.bundleRoot(for: bundle)
+ let goodScriptPath = bundleRoot.appending(path: "/good-script.js")
+ let stylePath = bundleRoot.appending(path: "/style.css")
+ #expect(FileManager.default.fileExists(at: goodScriptPath))
+ #expect(FileManager.default.fileExists(at: stylePath))
+
+ // The failed asset should not exist
+ let failedScriptPath = bundleRoot.appending(path: "/stats.js")
+ #expect(!FileManager.default.fileExists(at: failedScriptPath))
+ }
}
// MARK: - Progress Tracker for Tests
@@ -844,8 +901,6 @@ final class EditorAssetLibraryMockHTTPClient: EditorHTTPClientProtocol, @uncheck
var getCallCount = 0
var downloadCallCount = 0
var downloadedURLs: [URL] = []
- var downloadResponse: URL?
- var shouldThrowError: Error?
private let lock = NSLock()
/// URLs requested via `perform(_:)`. Use this to verify which endpoints were called.
@@ -854,8 +909,10 @@ final class EditorAssetLibraryMockHTTPClient: EditorHTTPClientProtocol, @uncheck
lock.withLock { _requestedURLs }
}
- /// Handler for generating response data based on request URL. Defaults to returning empty data.
- var urlResponseHandler: ((URL) -> Data) = { _ in Data() }
+ /// Handler for generating response data based on request URL.
+ /// Can throw to simulate failures for specific URLs.
+ /// Used by both `perform()` and `download()` methods.
+ var urlResponseHandler: ((URL) throws -> Data) = { _ in Data() }
func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
let url = try #require(urlRequest.url)
@@ -865,11 +922,7 @@ final class EditorAssetLibraryMockHTTPClient: EditorHTTPClientProtocol, @uncheck
_requestedURLs.append(url)
}
- if let error = shouldThrowError {
- throw error
- }
-
- let responseData = urlResponseHandler(url)
+ let responseData = try urlResponseHandler(url)
let response = HTTPURLResponse(
url: url,
@@ -889,9 +942,10 @@ final class EditorAssetLibraryMockHTTPClient: EditorHTTPClientProtocol, @uncheck
downloadedURLs.append(url)
}
- if let error = shouldThrowError {
- throw error
- }
+ let data = try urlResponseHandler(url)
+
+ let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try data.write(to: tempURL)
let response = HTTPURLResponse(
url: url,
@@ -900,16 +954,6 @@ final class EditorAssetLibraryMockHTTPClient: EditorHTTPClientProtocol, @uncheck
headerFields: nil
)!
- // Create a temporary file with some content
- let tempURL =
- downloadResponse
- ?? FileManager.default.temporaryDirectory
- .appendingPathComponent(UUID().uuidString)
-
- if downloadResponse == nil {
- try Data("mock content".utf8).write(to: tempURL)
- }
-
return (tempURL, response)
}
}