Skip to content

Commit 6486090

Browse files
jkmasseldcalhounkean
authored
Add iOS Preload List (#250)
* Add Preloader Logic * Update GutenbergKit for the demo app * Add site preparation screen * Add documentation * Remove old tests * Fix rebase issue * fix visibility issue * Address HTTPClient feedback * Remove EditorKeyValueCache * Apply suggestions from code review Co-authored-by: David Calhoun <[email protected]> * Remove `loadEditorTask` synthesizing accessor * Update ios/Sources/GutenbergKit/Sources/EditorLogging.swift Co-authored-by: David Calhoun <[email protected]> * Update ios/Demo-iOS/Sources/Views/SitePreparationView.swift Co-authored-by: David Calhoun <[email protected]> * Pass cache policy into EditorAssetLibrary * Move editor start button * Use modal editor * Remove editor loading state machine * Drop `EditorErrorViewController` * Use EditorLocalization * Fix a potential bug with editor loading * Update ios/Demo-iOS/Sources/Views/SitePreparationView.swift Co-authored-by: David Calhoun <[email protected]> * Update ios/Demo-iOS/Sources/Views/EditorView.swift Co-authored-by: David Calhoun <[email protected]> * Update ios/Demo-iOS/Sources/Views/EditorView.swift Co-authored-by: Alex Grebenyuk <[email protected]> * Rename to Foundation+Extensions.swift * Inline cachePolicy usage docs --------- Co-authored-by: David Calhoun <[email protected]> Co-authored-by: Alex Grebenyuk <[email protected]>
1 parent 0acdfb8 commit 6486090

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+4235
-1954
lines changed

docs/preloading.md

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
# Preloading System
2+
3+
## Overview
4+
5+
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.
6+
7+
## Architecture
8+
9+
The preloading system consists of several interconnected components:
10+
11+
```
12+
+---------------------------------------------------------------------+
13+
| EditorService |
14+
| (Orchestrates dependency fetching and caching) |
15+
+----------------------------------+----------------------------------+
16+
|
17+
+------------------+------------------+
18+
| | |
19+
v v v
20+
+--------------------+ +--------------+ +--------------------+
21+
| RESTAPIRepository | |EditorPreload | |EditorAssetLibrary |
22+
| (API caching) | | List | | (JS/CSS bundles) |
23+
+---------+----------+ +------+-------+ +--------------------+
24+
| |
25+
v v
26+
+--------------------+ +-------------------------------------+
27+
| EditorURLCache | | GBKitGlobal |
28+
| (Disk caching) | | (Serialized to window.GBKit) |
29+
+---------+----------+ +------------------+------------------+
30+
| |
31+
v v
32+
+--------------------+ +-----------------------------+
33+
|EditorCachePolicy | | JavaScript Preloading |
34+
| (TTL management) | | Middleware |
35+
+--------------------+ +-----------------------------+
36+
```
37+
38+
## Key Components
39+
40+
### EditorService
41+
42+
The `EditorService` actor coordinates fetching all editor dependencies concurrently:
43+
44+
**Swift**
45+
```swift
46+
let service = EditorService(configuration: config)
47+
let dependencies = try await service.prepare { progress in
48+
print("Loading: \(progress.fractionCompleted * 100)%")
49+
}
50+
```
51+
52+
**Kotlin**
53+
```kotlin
54+
// TBD
55+
```
56+
57+
The `prepare` method fetches these resources in parallel:
58+
- Editor settings (theme styles, block settings)
59+
- Asset bundles (JavaScript and CSS files)
60+
- Preload list (API responses for editor initialization)
61+
62+
### EditorPreloadList
63+
64+
The `EditorPreloadList` struct contains pre-fetched API responses that are serialized to JSON and injected into the editor's JavaScript runtime:
65+
66+
| Property | API Endpoint | Description |
67+
|----------|--------------|-------------|
68+
| `postData` | `/wp/v2/posts/{id}?context=edit` | The post being edited (existing posts only) |
69+
| `postTypeData` | `/wp/v2/types/{type}?context=edit` | Schema for the current post type |
70+
| `postTypesData` | `/wp/v2/types?context=view` | All available post types |
71+
| `activeThemeData` | `/wp/v2/themes?context=edit&status=active` | Active theme information |
72+
| `settingsOptionsData` | `OPTIONS /wp/v2/settings` | Site settings schema |
73+
74+
### EditorURLCache
75+
76+
The `EditorURLCache` provides disk-based caching for API responses, keyed by URL and HTTP method. It supports three cache policies via `EditorCachePolicy`:
77+
78+
| Policy | Behavior |
79+
|--------|----------|
80+
| `.ignore` | Never use cached responses (force fresh data) |
81+
| `.maxAge(TimeInterval)` | Use cached responses younger than the specified age |
82+
| `.always` | Always use cached responses regardless of age |
83+
84+
Example:
85+
86+
**Swift**
87+
```swift
88+
// Cache responses for up to 1 hour
89+
let service = EditorService(
90+
configuration: config,
91+
cachePolicy: .maxAge(3600)
92+
)
93+
```
94+
95+
**Kotlin**
96+
```kotlin
97+
// TBD
98+
```
99+
100+
### RESTAPIRepository
101+
102+
The `RESTAPIRepository` handles fetching and caching individual API responses. It follows a read-through caching pattern:
103+
104+
1. Check cache for existing response
105+
2. If cache hit and valid per policy, return cached data
106+
3. If cache miss or expired, fetch from network
107+
4. Store response in cache
108+
5. Return response
109+
110+
## Data Flow
111+
112+
### 1. Preparation Phase (Native)
113+
114+
When `EditorService.prepare()` is called:
115+
116+
```
117+
EditorService.prepare()
118+
|-- prepareEditorSettings() -> EditorSettings
119+
|-- prepareAssetBundle() -> EditorAssetBundle
120+
+-- preparePreloadList()
121+
|-- prepareActiveTheme() -> EditorURLResponse
122+
|-- prepareSettingsOptions() -> EditorURLResponse
123+
|-- preparePost(type:) -> EditorURLResponse
124+
|-- preparePostTypes() -> EditorURLResponse
125+
+-- preparePost(id:) -> EditorURLResponse (if editing existing post)
126+
```
127+
128+
### 2. Serialization Phase (Native)
129+
130+
The `EditorPreloadList` is converted to JSON via `build()`:
131+
132+
```json
133+
{
134+
"/wp/v2/types/post?context=edit": {
135+
"body": { "slug": "post", "supports": { ... } },
136+
"headers": { "Link": "<...>; rel=\"https://api.w.org/\"" }
137+
},
138+
"/wp/v2/types?context=view": {
139+
"body": { "post": { ... }, "page": { ... } },
140+
"headers": {}
141+
},
142+
"/wp/v2/themes?context=edit&status=active": {
143+
"body": [ ... ],
144+
"headers": {}
145+
},
146+
"OPTIONS": {
147+
"/wp/v2/settings": {
148+
"body": { ... },
149+
"headers": {}
150+
}
151+
}
152+
}
153+
```
154+
155+
### 3. Injection Phase (Native to Web)
156+
157+
The `GBKitGlobal` struct packages all configuration and preload data, then injects it into the WebView as `window.GBKit`:
158+
159+
```javascript
160+
window.GBKit = {
161+
siteURL: "https://example.com",
162+
siteApiRoot: "https://example.com/wp-json",
163+
authHeader: "Bearer ...",
164+
preloadData: { /* serialized EditorPreloadList */ },
165+
editorSettings: { /* theme styles, colors, etc. */ },
166+
// ... other configuration
167+
};
168+
```
169+
170+
### 4. Consumption Phase (JavaScript)
171+
172+
The `@wordpress/api-fetch` package includes a preloading middleware that intercepts API requests:
173+
174+
```javascript
175+
// In src/utils/api-fetch.js
176+
export function configureApiFetch() {
177+
const { preloadData } = getGBKit();
178+
179+
apiFetch.use(
180+
apiFetch.createPreloadingMiddleware(preloadData ?? defaultPreloadData)
181+
);
182+
}
183+
```
184+
185+
When Gutenberg makes an API request:
186+
187+
1. The preloading middleware checks if the request path exists in `preloadData`
188+
2. If found, the cached response is returned immediately (no network request)
189+
3. If not found, the request proceeds to the network
190+
4. The preload entry is consumed (one-time use) to ensure fresh data on subsequent requests
191+
192+
## Header Filtering
193+
194+
Only certain headers are preserved in preload responses to match WordPress core's behavior:
195+
196+
- `Accept` - Content type negotiation
197+
- `Link` - REST API discovery and pagination
198+
199+
This filtering is performed by `EditorURLResponse.asPreloadResponse()`.
200+
201+
## Cache Management
202+
203+
### Automatic Cleanup
204+
205+
`EditorService` automatically cleans up old asset bundles once per day:
206+
207+
**Swift**
208+
```swift
209+
try await onceEvery(.seconds(86_400)) {
210+
try await self.cleanup()
211+
}
212+
```
213+
214+
**Kotlin**
215+
```kotlin
216+
//tbd
217+
```
218+
219+
### Manual Cache Control
220+
221+
**Swift**
222+
```swift
223+
// Clear unused resources (keeps most recent)
224+
try await service.cleanup()
225+
226+
// Clear all resources (requires re-download)
227+
try await service.purge()
228+
```
229+
230+
**Kotlin**
231+
```kotlin
232+
//tbd
233+
```
234+
235+
## Offline Mode
236+
237+
When `EditorConfiguration.isOfflineModeEnabled` is `true`, the preloading system returns empty dependencies:
238+
239+
```swift
240+
if self.configuration.isOfflineModeEnabled {
241+
return EditorDependencies(
242+
editorSettings: .undefined,
243+
assetBundle: .empty,
244+
preloadList: nil
245+
)
246+
}
247+
```
248+
249+
Offline mode doesn't refer to reguar site that are offline – it's for when you're using GutenbergKit separately from a WordPress
250+
site (for instance, the bundled editor in the demo app, or you just want an editor without the WP integration).
251+
252+
The JavaScript side falls back to `defaultPreloadData` which contains minimal type definitions to allow basic editor functionality.
253+
254+
## Progress Reporting
255+
256+
The preloading system reports its progress to give the user high-quality feedback about the loading process - if the user loads the
257+
editor without `EditorDependencies` present, the editor will display a loading screen with a progress bar. If the user provides `EditorDependencies`
258+
that contain everything the editor needs, the progress bar will never be displayed.
259+
260+
## EditorDependencies
261+
262+
`EditorDependencies` contains all pre-fetched resources needed to initialize the editor instantly.
263+
264+
| Property | Type | Description |
265+
|----------|------|-------------|
266+
| `editorSettings` | `EditorSettings` | Theme styles, colors, typography, block settings |
267+
| `assetBundle` | `EditorAssetBundle` | Cached JavaScript/CSS for plugins/themes |
268+
| `preloadList` | `EditorPreloadList?` | Pre-fetched API responses |
269+
270+
### Obtaining Dependencies
271+
272+
```swift
273+
let service = EditorService(configuration: configuration)
274+
let dependencies = try await service.prepare { progress in
275+
loadingView.progress = progress.fractionCompleted
276+
}
277+
```
278+
279+
### EditorViewController Loading Flows
280+
281+
`EditorViewController` supports two loading flows based on whether dependencies are provided:
282+
283+
#### Flow 1: Dependencies Provided (Recommended)
284+
285+
```swift
286+
let editor = EditorViewController(
287+
configuration: configuration,
288+
dependencies: dependencies // Loads immediately
289+
)
290+
```
291+
292+
The editor skips the progress UI and loads the WebView immediately.
293+
294+
#### Flow 2: No Dependencies (Fallback)
295+
296+
```swift
297+
let editor = EditorViewController(
298+
configuration: configuration
299+
// No dependencies - fetches automatically
300+
)
301+
```
302+
303+
The editor displays a progress bar while fetching, then loads once complete.
304+
305+
### Best Practice: Prepare Early
306+
307+
Fetch dependencies before the user needs the editor:
308+
309+
```swift
310+
class PostListViewController: UIViewController {
311+
private var editorDependencies: EditorDependencies?
312+
private let editorService: EditorService
313+
314+
override func viewDidLoad() {
315+
super.viewDidLoad()
316+
Task {
317+
self.editorDependencies = try? await editorService.prepare { _ in }
318+
}
319+
}
320+
321+
func editPost(_ post: Post) {
322+
let editor = EditorViewController(
323+
configuration: EditorConfiguration(post: post),
324+
dependencies: editorDependencies
325+
)
326+
navigationController?.pushViewController(editor, animated: true)
327+
}
328+
}

ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@
122122
buildRules = (
123123
);
124124
dependencies = (
125+
245D6BE42EDFCD640076D741 /* PBXTargetDependency */,
125126
);
126127
fileSystemSynchronizedGroups = (
127128
2468525B2EAAC62B00ED1F09 /* Views */,
@@ -197,6 +198,13 @@
197198
};
198199
/* End PBXSourcesBuildPhase section */
199200

201+
/* Begin PBXTargetDependency section */
202+
245D6BE42EDFCD640076D741 /* PBXTargetDependency */ = {
203+
isa = PBXTargetDependency;
204+
productRef = 245D6BE32EDFCD640076D741 /* GutenbergKit */;
205+
};
206+
/* End PBXTargetDependency section */
207+
200208
/* Begin XCBuildConfiguration section */
201209
0C4F59972BEFF4980028BD96 /* Debug */ = {
202210
isa = XCBuildConfiguration;
@@ -431,6 +439,10 @@
431439
isa = XCSwiftPackageProductDependency;
432440
productName = GutenbergKit;
433441
};
442+
245D6BE32EDFCD640076D741 /* GutenbergKit */ = {
443+
isa = XCSwiftPackageProductDependency;
444+
productName = GutenbergKit;
445+
};
434446
/* End XCSwiftPackageProductDependency section */
435447
};
436448
rootObject = 0C4F59832BEFF4970028BD96 /* Project object */;

ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ios/Demo-iOS/Sources/ConfigurationItem.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import Foundation
2+
import GutenbergKit
23

34
/// Represents a configuration item for the editor
4-
enum ConfigurationItem: Codable, Identifiable, Equatable {
5+
enum ConfigurationItem: Codable, Identifiable, Equatable, Hashable {
56
case bundledEditor
67
case editorConfiguration(ConfiguredEditor)
78

@@ -24,8 +25,13 @@ enum ConfigurationItem: Codable, Identifiable, Equatable {
2425
}
2526
}
2627

28+
struct RunnableEditor: Equatable, Hashable {
29+
let configuration: EditorConfiguration
30+
let dependencies: EditorDependencies?
31+
}
32+
2733
/// Configuration for an editor with site integration
28-
struct ConfiguredEditor: Codable, Identifiable, Equatable {
34+
struct ConfiguredEditor: Codable, Identifiable, Equatable, Hashable {
2935
let id: String
3036
let name: String
3137
let siteUrl: String

0 commit comments

Comments
 (0)