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
26 changes: 25 additions & 1 deletion docs/code/preloading.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,28 @@ let dependencies = try await service.prepare { progress in
}
```

#### Sharing Work Between Services

Every `EditorService` for a site reads and writes the same on-disk caches, so there's no need to hand a service from a
prefetch to the editor — create one for each caller. Don't call `prepare()` on a service while an earlier call on it is
still running: progress is tracked per service, so the later call takes over the progress callback, and whichever
finishes first stops progress for both.

Services for the same site also share work while it's in flight. A request identical to one already in flight joins it
rather than going out again, and a build of an asset bundle joins the one already running. So an editor opened before a
prefetch finishes fetches only its own post and the `editor-assets` manifest, even when the two are for different posts.
Requests are shared only between clients with the same `URLSession` instance, credentials, and timeout, and never from a
client with a delegate, which expects to see every request it makes. A bundle build is shared by every service for the
site whatever its client, just as the bundle it produces is once it's on disk.

The request for the post is never shared, even between two editors on the same post: one already in flight can predate
an edit made since. It opts out through its cache policy — a request that asks to skip the cache
(`.reloadIgnoringLocalCacheData` and its siblings) always goes out on its own — and a host's own requests through
`EditorHTTPClient` can do the same.

Cancelling a caller ends only that caller's wait; shared work stops once no caller is left waiting on it. `purge()`
doesn't stop it, so work that began before a purge can still land after it.

### EditorViewController Loading Flows

`EditorViewController` supports two loading flows based on whether dependencies are provided:
Expand All @@ -337,7 +359,9 @@ let editor = EditorViewController(
)
```

The editor displays a progress bar while fetching, then loads once complete.
The editor displays a progress bar while fetching, then loads once complete. The fetch does not hold the
editor: releasing it mid-fetch frees it immediately, and the fetch finishes in the background, warming the
cache for the next editor.

### Best Practice: Prepare Early

Expand Down
70 changes: 69 additions & 1 deletion ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol {
private let delegate: EditorHTTPClientDelegate?
private let requestTimeout: TimeInterval?

/// Requests in flight that an identical `perform(_:)` joins instead of sending again. Every
/// editor and service builds its own client, so this is shared across all of them.
static let inFlightRequests = InFlightTasks<SharedRequest, (Data, HTTPURLResponse)>()

/// A request other callers can share: the request as it goes out, and the session it goes
/// out on. `URLRequest`'s own `==` ignores the timeout and the network service type, so
/// those are compared here; it ignores the body too, but a request with one isn't shared.
struct SharedRequest: Hashable, Sendable {
let request: URLRequest
let timeout: TimeInterval
let networkServiceType: URLRequest.NetworkServiceType
let session: ObjectIdentifier
}

public init(
urlSession: URLSessionProtocol,
authHeader: String,
Expand All @@ -106,9 +120,63 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol {
self.requestTimeout = requestTimeout
}

/// Sends `urlRequest`, throwing for a non-2xx status.
///
/// A request identical to one already in flight joins it rather than going out again, so
/// callers after the same site data — an editor and a prefetch, say — pay for one round
/// trip. Only a safe request without a body is shared, and only between clients no delegate
/// is watching. A request whose cache policy asks to skip the cache goes out alone: its
/// caller wants an answer no older than the call, and a request already in flight may
/// predate a write made since. Cancelling a caller ends its own wait; the request is
/// cancelled once no caller is left waiting on it.
public func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {

let configuredRequest = self.configureRequest(urlRequest)
guard let sharedRequest = sharedRequest(forConfigured: configuredRequest) else {
return try await send(configuredRequest)
}
return try await Self.inFlightRequests.value(for: sharedRequest) { _ in
try await self.send(configuredRequest)
}
}

/// For tests: what `perform(_:)` shares `urlRequest` under, or `nil` if it goes out alone.
func sharedRequest(for urlRequest: URLRequest) -> SharedRequest? {
sharedRequest(forConfigured: configureRequest(urlRequest))
}

/// `nil` for a request that must go out alone: an unsafe method or a body, a cache policy
/// that asks for a fresh answer, a delegate that expects to see each request it asked for,
/// or a session that isn't an object — a shared request is keyed by the session's identity,
/// which only an object keeps.
private func sharedRequest(forConfigured request: URLRequest) -> SharedRequest? {
guard delegate == nil,
Self.sharableMethods.contains(request.httpMethod ?? "GET"),
request.httpBody == nil,
request.httpBodyStream == nil,
!Self.freshAnswerPolicies.contains(request.cachePolicy),
type(of: urlSession) is AnyClass
else {
return nil
}
return SharedRequest(
request: request,
timeout: request.timeoutInterval,
networkServiceType: request.networkServiceType,
session: ObjectIdentifier(urlSession as AnyObject)
)
}

private static let sharableMethods: Set<String> = ["GET", "HEAD", "OPTIONS"]

/// The cache policies that ask the server afresh rather than trust a stored response, and
/// so won't take one already on its way.
private static let freshAnswerPolicies: Set<URLRequest.CachePolicy> = [
.reloadIgnoringLocalCacheData,
.reloadIgnoringLocalAndRemoteCacheData,
.reloadRevalidatingCacheData,
]

private func send(_ configuredRequest: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await self.urlSession.data(for: configuredRequest)
self.delegate?.didPerformRequest(configuredRequest, response: response, data: .bytes(data))

Expand Down
92 changes: 49 additions & 43 deletions ios/Sources/GutenbergKit/Sources/EditorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ import UIKit
// │ WARMUP MODE │ │ DEPENDENCIES │ │ NO DEPENDENCIES │
// │ (isWarmupMode) │ │ PROVIDED │ │ (Async Flow) │
// │ │ │ (Fast Path) │ │ │
// │ Load HTML without │ │ │ │ Spawn Task to fetch │
// │ Load HTML without │ │ │ │ Start a loader to fetch │
// │ any dependencies │ │ loadEditor() │ │ dependencies │
// │ for prewarming │ │ immediately │ │ │
// └────────────────────┘ └────────────────────┘ └───────────────────────────────┘
// │ ▼
// │ ┌───────────────────────────────┐
// │ │ prepareEditor() │
// │ │ • Load editor dependencies │
// │ │ EditorDependencyLoader │
// │ │ • Fetch editor dependencies │
// │ └───────────────────────────────┘
// │ ▼
// │ ┌───────────────────────────────┐
Expand Down Expand Up @@ -66,12 +66,16 @@ import UIKit
//
// ## Flow 2: No Dependencies (Async Flow)
//
// When no dependencies are provided, the controller fetches them asynchronously.
// When no dependencies are provided, an `EditorDependencyLoader` fetches them
// asynchronously and hands them to the fast path. The loader holds the controller
// only weakly, so a controller released mid-fetch is freed at once, not when the
// fetch ends.
//
// This is a fallback behaviour – the host app should provide the dependencies if it can,
// because it'll be a much better user experience.
//
@MainActor
public final class EditorViewController: UIViewController, GutenbergEditorControllerDelegate, UIAdaptivePresentationControllerDelegate, UIPopoverPresentationControllerDelegate, UISheetPresentationControllerDelegate {
public final class EditorViewController: UIViewController, GutenbergEditorControllerDelegate, EditorDependencyLoaderDelegate, UIAdaptivePresentationControllerDelegate, UIPopoverPresentationControllerDelegate, UISheetPresentationControllerDelegate {

public let webView: WKWebView
public var configuration: EditorConfiguration
Expand All @@ -85,6 +89,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
/// The fetched or provided editor dependencies (settings, assets, preload data).
private var dependencies: EditorDependencies?

/// Fetches `dependencies` when none were provided at init.
private var dependencyLoader: EditorDependencyLoader?

/// Error encountered while loading dependencies.
private var error: Error? {
didSet {
Expand Down Expand Up @@ -367,23 +374,11 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro

if let dependencies {
// FAST PATH: Dependencies were provided at init() - load immediately.
// Not cancellable: cancelling mid-`startUploadServer()` silently disables
// native uploads for the session (#357).
Task(priority: .userInitiated) { [weak self] in
do {
try await self?.loadEditor(dependencies: dependencies)
} catch {
self?.failToLoad(error)
}
}
startLoadingEditor(dependencies: dependencies)
} else {
// ASYNC FLOW: No dependencies - fetch them, then load as above.
// Not cancellable either, for the same reason plus one: nothing restarts
// the fetch, so the editor never recovers from a cancel. Note that
// `viewDidDisappear` fires when the editor is merely covered. See #651.
Task(priority: .userInitiated) { [weak self] in
await self?.prepareEditor()
}
// ASYNC FLOW: No dependencies - fetch them, then take the fast path.
displayProgressView()
dependencyLoader = EditorDependencyLoader(service: editorService, delegate: self)
}
}

Expand Down Expand Up @@ -503,28 +498,25 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
uploadServer?.stop()
}

/// Fetches all required dependencies and then loads the editor.
///
/// This method is the entry point for the **Async Flow** (when no dependencies were provided at init).
@MainActor
private func prepareEditor() async {
self.displayProgressView()
defer { self.hideProgressView() }
// MARK: - Async Flow (EditorDependencyLoaderDelegate)

do {
// EditorService.prepare() fetches dependencies concurrently with progress reporting
let dependencies = try await self.editorService.prepare { @MainActor [weak self] progress in
self?.progressView.setProgress(progress, animated: true)
}
func dependencyLoader(_ loader: EditorDependencyLoader, didUpdate progress: EditorProgress) {
progressView.setProgress(progress, animated: true)
}

// Store dependencies for later use (e.g., HTMLPreviewManager)
self.dependencies = dependencies
func dependencyLoader(_ loader: EditorDependencyLoader, didLoad dependencies: EditorDependencies) {
hideProgressView()

// Continue to the shared loading path
try await self.loadEditor(dependencies: dependencies)
} catch {
self.failToLoad(error)
}
// Store dependencies for later use (e.g., HTMLPreviewManager)
self.dependencies = dependencies

// Continue to the shared loading path
startLoadingEditor(dependencies: dependencies)
}

func dependencyLoader(_ loader: EditorDependencyLoader, didFailWith error: any Error) {
hideProgressView()
failToLoad(error)
}

private func failToLoad(_ error: Error) {
Expand All @@ -534,6 +526,22 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro

// MARK: - Shared Loading Path: Load Editor into WebView

/// Runs `loadEditor(dependencies:)` — the step both flows end on.
///
/// Not cancellable, and it holds the editor until the load returns: cancelling it
/// mid-`startUploadServer()` silently disables native uploads for the session
/// (#357). The hold is short — the server bind is capped by
/// `HTTPServer.defaultStartTimeout`.
private func startLoadingEditor(dependencies: EditorDependencies) {
Task(priority: .userInitiated) { [weak self] in
do {
try await self?.loadEditor(dependencies: dependencies)
} catch {
self?.failToLoad(error)
}
}
}

/// Loads the editor HTML into the WebView with the given dependencies.
///
/// This is the **shared loading path** used by both flows after dependencies are available.
Expand Down Expand Up @@ -675,9 +683,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro

/// Deletes all cached editor data for all sites
public static func deleteAllData() throws {
if FileManager.default.directoryExists(at: Paths.defaultCacheRoot) {
try FileManager.default.removeItem(at: Paths.defaultCacheRoot)
}
try EditorURLCache.deleteAll()

if FileManager.default.directoryExists(at: Paths.defaultStorageRoot) {
try FileManager.default.removeItem(at: Paths.defaultStorageRoot)
Expand Down
Loading
Loading