Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
50f1b1d
Add Preloader Logic
jkmassel Dec 9, 2025
b8d37fd
Update GutenbergKit for the demo app
jkmassel Dec 4, 2025
1aca406
Add site preparation screen
jkmassel Dec 4, 2025
7fb256b
Add documentation
jkmassel Dec 8, 2025
efb4ed0
Remove old tests
jkmassel Dec 9, 2025
c95bc89
Fix rebase issue
jkmassel Dec 9, 2025
6aa25bc
fix visibility issue
jkmassel Dec 9, 2025
b1b9a85
Address HTTPClient feedback
jkmassel Dec 9, 2025
c8d4f11
Remove EditorKeyValueCache
jkmassel Dec 9, 2025
1d53407
Apply suggestions from code review
jkmassel Dec 9, 2025
5cdbc5b
Remove `loadEditorTask` synthesizing accessor
jkmassel Dec 9, 2025
f2dcb1c
Update ios/Sources/GutenbergKit/Sources/EditorLogging.swift
jkmassel Dec 9, 2025
48e3640
Update ios/Demo-iOS/Sources/Views/SitePreparationView.swift
jkmassel Dec 9, 2025
ddfe7c9
Pass cache policy into EditorAssetLibrary
jkmassel Dec 10, 2025
46b6838
Move editor start button
jkmassel Dec 12, 2025
50800e0
Use modal editor
jkmassel Dec 12, 2025
354d8f9
Remove editor loading state machine
jkmassel Dec 13, 2025
935fd8a
Drop `EditorErrorViewController`
jkmassel Dec 12, 2025
5682570
Use EditorLocalization
jkmassel Dec 12, 2025
39cff26
Fix a potential bug with editor loading
jkmassel Dec 13, 2025
900dce7
Update ios/Demo-iOS/Sources/Views/SitePreparationView.swift
jkmassel Dec 13, 2025
304e4fb
Update ios/Demo-iOS/Sources/Views/EditorView.swift
jkmassel Dec 13, 2025
e2a578a
Update ios/Demo-iOS/Sources/Views/EditorView.swift
jkmassel Dec 13, 2025
9fba044
Rename to Foundation+Extensions.swift
jkmassel Dec 13, 2025
7752bd5
Inline cachePolicy usage docs
jkmassel Dec 13, 2025
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
328 changes: 328 additions & 0 deletions docs/preloading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
# Preloading System

## Overview

The preloading system in GutenbergKit pre-fetches WordPress REST API responses before the editor loads, eliminating network latency during editor initialization. By injecting cached API responses directly into the JavaScript runtime, the Gutenberg editor can almost always initialize instantly without waiting for network requests.

## Architecture

The preloading system consists of several interconnected components:

```
+---------------------------------------------------------------------+
Copy link
Contributor

@kean kean Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) I would suggest to avoid committing documentation as it can be generated on the fly. The committed docs will likely get outdated quickly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't say I agree here – I don't think someone should have to say "hey bot, please generate some docs for me" if they're trying to understand how the system works?

Any bots working on the codebase can/should also reference this to know how it's supposed to work.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe there is value in the documentation. I think the key is finding the right level of detail; the more detailed the static documentation, the more likely it will become outdated (or pose an unwieldy maintenance burden).

We might consider:

  1. Reducing the number of examples; relying upon a single example showcasing the high-level concept of utilizing the disparate parts together.
  2. Limit the times we document low-level implementation details—e.g., header filtering.

| EditorService |
| (Orchestrates dependency fetching and caching) |
+----------------------------------+----------------------------------+
|
+------------------+------------------+
| | |
v v v
+--------------------+ +--------------+ +--------------------+
| RESTAPIRepository | |EditorPreload | |EditorAssetLibrary |
| (API caching) | | List | | (JS/CSS bundles) |
+---------+----------+ +------+-------+ +--------------------+
| |
v v
+--------------------+ +-------------------------------------+
| EditorURLCache | | GBKitGlobal |
| (Disk caching) | | (Serialized to window.GBKit) |
+---------+----------+ +------------------+------------------+
| |
v v
+--------------------+ +-----------------------------+
|EditorCachePolicy | | JavaScript Preloading |
| (TTL management) | | Middleware |
+--------------------+ +-----------------------------+
```

## Key Components

### EditorService

The `EditorService` actor coordinates fetching all editor dependencies concurrently:

**Swift**
```swift
let service = EditorService(configuration: config)
let dependencies = try await service.prepare { progress in
print("Loading: \(progress.fractionCompleted * 100)%")
}
```

**Kotlin**
```kotlin
// TBD
```

The `prepare` method fetches these resources in parallel:
- Editor settings (theme styles, block settings)
- Asset bundles (JavaScript and CSS files)
- Preload list (API responses for editor initialization)

### EditorPreloadList

The `EditorPreloadList` struct contains pre-fetched API responses that are serialized to JSON and injected into the editor's JavaScript runtime:

| Property | API Endpoint | Description |
|----------|--------------|-------------|
| `postData` | `/wp/v2/posts/{id}?context=edit` | The post being edited (existing posts only) |
| `postTypeData` | `/wp/v2/types/{type}?context=edit` | Schema for the current post type |
| `postTypesData` | `/wp/v2/types?context=view` | All available post types |
| `activeThemeData` | `/wp/v2/themes?context=edit&status=active` | Active theme information |
| `settingsOptionsData` | `OPTIONS /wp/v2/settings` | Site settings schema |

### EditorURLCache

The `EditorURLCache` provides disk-based caching for API responses, keyed by URL and HTTP method. It supports three cache policies via `EditorCachePolicy`:

| Policy | Behavior |
|--------|----------|
| `.ignore` | Never use cached responses (force fresh data) |
| `.maxAge(TimeInterval)` | Use cached responses younger than the specified age |
| `.always` | Always use cached responses regardless of age |

Example:

**Swift**
```swift
// Cache responses for up to 1 hour
let service = EditorService(
configuration: config,
cachePolicy: .maxAge(3600)
)
```

**Kotlin**
```kotlin
// TBD
```

### RESTAPIRepository

The `RESTAPIRepository` handles fetching and caching individual API responses. It follows a read-through caching pattern:

1. Check cache for existing response
2. If cache hit and valid per policy, return cached data
3. If cache miss or expired, fetch from network
4. Store response in cache
5. Return response

## Data Flow

### 1. Preparation Phase (Native)

When `EditorService.prepare()` is called:

```
EditorService.prepare()
|-- prepareEditorSettings() -> EditorSettings
|-- prepareAssetBundle() -> EditorAssetBundle
+-- preparePreloadList()
|-- prepareActiveTheme() -> EditorURLResponse
|-- prepareSettingsOptions() -> EditorURLResponse
|-- preparePost(type:) -> EditorURLResponse
|-- preparePostTypes() -> EditorURLResponse
+-- preparePost(id:) -> EditorURLResponse (if editing existing post)
```

### 2. Serialization Phase (Native)

The `EditorPreloadList` is converted to JSON via `build()`:

```json
{
"/wp/v2/types/post?context=edit": {
"body": { "slug": "post", "supports": { ... } },
"headers": { "Link": "<...>; rel=\"https://api.w.org/\"" }
},
"/wp/v2/types?context=view": {
"body": { "post": { ... }, "page": { ... } },
"headers": {}
},
"/wp/v2/themes?context=edit&status=active": {
"body": [ ... ],
"headers": {}
},
"OPTIONS": {
"/wp/v2/settings": {
"body": { ... },
"headers": {}
}
}
}
```

### 3. Injection Phase (Native to Web)

The `GBKitGlobal` struct packages all configuration and preload data, then injects it into the WebView as `window.GBKit`:

```javascript
window.GBKit = {
siteURL: "https://example.com",
siteApiRoot: "https://example.com/wp-json",
authHeader: "Bearer ...",
preloadData: { /* serialized EditorPreloadList */ },
editorSettings: { /* theme styles, colors, etc. */ },
// ... other configuration
};
```

### 4. Consumption Phase (JavaScript)

The `@wordpress/api-fetch` package includes a preloading middleware that intercepts API requests:

```javascript
// In src/utils/api-fetch.js
export function configureApiFetch() {
const { preloadData } = getGBKit();

apiFetch.use(
apiFetch.createPreloadingMiddleware(preloadData ?? defaultPreloadData)
);
}
```

When Gutenberg makes an API request:

1. The preloading middleware checks if the request path exists in `preloadData`
2. If found, the cached response is returned immediately (no network request)
3. If not found, the request proceeds to the network
4. The preload entry is consumed (one-time use) to ensure fresh data on subsequent requests

## Header Filtering

Only certain headers are preserved in preload responses to match WordPress core's behavior:

- `Accept` - Content type negotiation
- `Link` - REST API discovery and pagination

This filtering is performed by `EditorURLResponse.asPreloadResponse()`.

## Cache Management

### Automatic Cleanup

`EditorService` automatically cleans up old asset bundles once per day:

**Swift**
```swift
try await onceEvery(.seconds(86_400)) {
try await self.cleanup()
}
```

**Kotlin**
```kotlin
//tbd
```

### Manual Cache Control

**Swift**
```swift
// Clear unused resources (keeps most recent)
try await service.cleanup()

// Clear all resources (requires re-download)
try await service.purge()
```

**Kotlin**
```kotlin
//tbd
```

## Offline Mode

When `EditorConfiguration.isOfflineModeEnabled` is `true`, the preloading system returns empty dependencies:

```swift
if self.configuration.isOfflineModeEnabled {
return EditorDependencies(
editorSettings: .undefined,
assetBundle: .empty,
preloadList: nil
)
}
```

Offline mode doesn't refer to reguar site that are offline – it's for when you're using GutenbergKit separately from a WordPress
site (for instance, the bundled editor in the demo app, or you just want an editor without the WP integration).

The JavaScript side falls back to `defaultPreloadData` which contains minimal type definitions to allow basic editor functionality.

## Progress Reporting

The preloading system reports its progress to give the user high-quality feedback about the loading process - if the user loads the
editor without `EditorDependencies` present, the editor will display a loading screen with a progress bar. If the user provides `EditorDependencies`
that contain everything the editor needs, the progress bar will never be displayed.

## EditorDependencies

`EditorDependencies` contains all pre-fetched resources needed to initialize the editor instantly.

| Property | Type | Description |
|----------|------|-------------|
| `editorSettings` | `EditorSettings` | Theme styles, colors, typography, block settings |
| `assetBundle` | `EditorAssetBundle` | Cached JavaScript/CSS for plugins/themes |
| `preloadList` | `EditorPreloadList?` | Pre-fetched API responses |

### Obtaining Dependencies

```swift
let service = EditorService(configuration: configuration)
let dependencies = try await service.prepare { progress in
loadingView.progress = progress.fractionCompleted
}
```

### EditorViewController Loading Flows

`EditorViewController` supports two loading flows based on whether dependencies are provided:

#### Flow 1: Dependencies Provided (Recommended)

```swift
let editor = EditorViewController(
configuration: configuration,
dependencies: dependencies // Loads immediately
)
```

The editor skips the progress UI and loads the WebView immediately.

#### Flow 2: No Dependencies (Fallback)

```swift
let editor = EditorViewController(
configuration: configuration
// No dependencies - fetches automatically
)
```

The editor displays a progress bar while fetching, then loads once complete.

### Best Practice: Prepare Early

Fetch dependencies before the user needs the editor:

```swift
class PostListViewController: UIViewController {
private var editorDependencies: EditorDependencies?
private let editorService: EditorService

override func viewDidLoad() {
super.viewDidLoad()
Task {
self.editorDependencies = try? await editorService.prepare { _ in }
}
}

func editPost(_ post: Post) {
let editor = EditorViewController(
configuration: EditorConfiguration(post: post),
dependencies: editorDependencies
)
navigationController?.pushViewController(editor, animated: true)
}
}
12 changes: 12 additions & 0 deletions ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
buildRules = (
);
dependencies = (
245D6BE42EDFCD640076D741 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
2468525B2EAAC62B00ED1F09 /* Views */,
Expand Down Expand Up @@ -197,6 +198,13 @@
};
/* End PBXSourcesBuildPhase section */

/* Begin PBXTargetDependency section */
245D6BE42EDFCD640076D741 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
productRef = 245D6BE32EDFCD640076D741 /* GutenbergKit */;
};
/* End PBXTargetDependency section */

/* Begin XCBuildConfiguration section */
0C4F59972BEFF4980028BD96 /* Debug */ = {
isa = XCBuildConfiguration;
Expand Down Expand Up @@ -431,6 +439,10 @@
isa = XCSwiftPackageProductDependency;
productName = GutenbergKit;
};
245D6BE32EDFCD640076D741 /* GutenbergKit */ = {
isa = XCSwiftPackageProductDependency;
productName = GutenbergKit;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 0C4F59832BEFF4970028BD96 /* Project object */;
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions ios/Demo-iOS/Sources/ConfigurationItem.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import Foundation
import GutenbergKit

/// Represents a configuration item for the editor
enum ConfigurationItem: Codable, Identifiable, Equatable {
enum ConfigurationItem: Codable, Identifiable, Equatable, Hashable {
case bundledEditor
case editorConfiguration(ConfiguredEditor)

Expand All @@ -24,8 +25,13 @@ enum ConfigurationItem: Codable, Identifiable, Equatable {
}
}

struct RunnableEditor: Equatable, Hashable {
let configuration: EditorConfiguration
let dependencies: EditorDependencies?
}

/// Configuration for an editor with site integration
struct ConfiguredEditor: Codable, Identifiable, Equatable {
struct ConfiguredEditor: Codable, Identifiable, Equatable, Hashable {
let id: String
let name: String
let siteUrl: String
Expand Down
Loading