From f4fddbe60d99c781c42fa9881cfcd2b25ddf168f Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 16 Jan 2026 15:57:47 -0500 Subject: [PATCH 01/18] feat: requestLatestContent retrieves content from host Expose function for retrieving the latest content from the native host. This is useful for ensuring the content is updated after WebView refresh or re-initialization. --- src/utils/bridge.js | 33 ++++++ src/utils/bridge.test.js | 213 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 src/utils/bridge.test.js diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 93345ba9..5624b6b7 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -244,6 +244,39 @@ export function getGBKit() { * @property {string} [status] The status of the post. */ +/** + * Requests the latest persisted content from the native host. + * + * Used during editor initialization to recover content after WebView refresh. + * The native host maintains the authoritative content via autosave events. + * + * @return {Promise<{title: string, content: string}|null>} The latest content or null if unavailable. + */ +export async function requestLatestContent() { + if ( window.webkit?.messageHandlers?.requestLatestContent ) { + try { + return await window.webkit.messageHandlers.requestLatestContent.postMessage( + {} + ); + } catch ( error ) { + debug( 'Failed to request content from iOS host', error ); + return null; + } + } + + if ( window.editorDelegate?.requestLatestContent ) { + try { + const result = window.editorDelegate.requestLatestContent(); + return result ? JSON.parse( result ) : null; + } catch ( error ) { + debug( 'Failed to request content from Android host', error ); + return null; + } + } + + return null; +} + /** * Retrieves the current post data from the GBKit global. * diff --git a/src/utils/bridge.test.js b/src/utils/bridge.test.js new file mode 100644 index 00000000..f174fa87 --- /dev/null +++ b/src/utils/bridge.test.js @@ -0,0 +1,213 @@ +/** + * External dependencies + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +/** + * Internal dependencies + */ +import { requestLatestContent } from './bridge'; + +describe( 'requestLatestContent', () => { + let originalWindow; + + beforeEach( () => { + // Store original window properties + originalWindow = { + webkit: window.webkit, + editorDelegate: window.editorDelegate, + }; + // Clear any existing bridge properties + delete window.webkit; + delete window.editorDelegate; + } ); + + afterEach( () => { + // Restore original window properties + if ( originalWindow.webkit !== undefined ) { + window.webkit = originalWindow.webkit; + } + if ( originalWindow.editorDelegate !== undefined ) { + window.editorDelegate = originalWindow.editorDelegate; + } + } ); + + describe( 'iOS bridge', () => { + it( 'should return parsed response from iOS handler', async () => { + const mockContent = { + title: 'Test Title', + content: 'Test Content', + }; + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi.fn().mockResolvedValue( mockContent ), + }, + }, + }; + + const result = await requestLatestContent(); + + expect( result ).toEqual( mockContent ); + expect( + window.webkit.messageHandlers.requestLatestContent.postMessage + ).toHaveBeenCalledWith( {} ); + } ); + + it( 'should return null when iOS handler rejects', async () => { + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi + .fn() + .mockRejectedValue( new Error( 'Handler error' ) ), + }, + }, + }; + + const result = await requestLatestContent(); + + expect( result ).toBeNull(); + } ); + + it( 'should return null when iOS handler returns null', async () => { + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi.fn().mockResolvedValue( null ), + }, + }, + }; + + const result = await requestLatestContent(); + + expect( result ).toBeNull(); + } ); + } ); + + describe( 'Android bridge', () => { + it( 'should return parsed JSON from Android interface', async () => { + const mockContent = { + title: 'Test Title', + content: 'Test Content', + }; + window.editorDelegate = { + requestLatestContent: vi + .fn() + .mockReturnValue( JSON.stringify( mockContent ) ), + }; + + const result = await requestLatestContent(); + + expect( result ).toEqual( mockContent ); + expect( + window.editorDelegate.requestLatestContent + ).toHaveBeenCalled(); + } ); + + it( 'should return null when Android returns null', async () => { + window.editorDelegate = { + requestLatestContent: vi.fn().mockReturnValue( null ), + }; + + const result = await requestLatestContent(); + + expect( result ).toBeNull(); + } ); + + it( 'should return null when Android returns empty string', async () => { + window.editorDelegate = { + requestLatestContent: vi.fn().mockReturnValue( '' ), + }; + + const result = await requestLatestContent(); + + expect( result ).toBeNull(); + } ); + + it( 'should return null when Android returns malformed JSON', async () => { + window.editorDelegate = { + requestLatestContent: vi + .fn() + .mockReturnValue( 'not valid json' ), + }; + + const result = await requestLatestContent(); + + expect( result ).toBeNull(); + } ); + + it( 'should return null when Android method throws', async () => { + window.editorDelegate = { + requestLatestContent: vi.fn().mockImplementation( () => { + throw new Error( 'Method error' ); + } ), + }; + + const result = await requestLatestContent(); + + expect( result ).toBeNull(); + } ); + } ); + + describe( 'no bridge available', () => { + it( 'should return null when no bridge is available', async () => { + // Neither window.webkit nor window.editorDelegate is set + + const result = await requestLatestContent(); + + expect( result ).toBeNull(); + } ); + + it( 'should return null when webkit exists but handler does not', async () => { + window.webkit = { + messageHandlers: {}, + }; + + const result = await requestLatestContent(); + + expect( result ).toBeNull(); + } ); + + it( 'should return null when editorDelegate exists but method does not', async () => { + window.editorDelegate = {}; + + const result = await requestLatestContent(); + + expect( result ).toBeNull(); + } ); + } ); + + describe( 'priority', () => { + it( 'should prefer iOS bridge when both are available', async () => { + const iosContent = { + title: 'iOS Title', + content: 'iOS Content', + }; + const androidContent = { + title: 'Android Title', + content: 'Android Content', + }; + + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi.fn().mockResolvedValue( iosContent ), + }, + }, + }; + window.editorDelegate = { + requestLatestContent: vi + .fn() + .mockReturnValue( JSON.stringify( androidContent ) ), + }; + + const result = await requestLatestContent(); + + expect( result ).toEqual( iosContent ); + expect( + window.editorDelegate.requestLatestContent + ).not.toHaveBeenCalled(); + } ); + } ); +} ); From 2968727dfdc37b7c54b52f2658bedc29c053d251 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 16 Jan 2026 16:03:11 -0500 Subject: [PATCH 02/18] feat: iOS returns latest content across bridge Enable the editor to request the latest content from the host. --- ios/Demo-iOS/Sources/Views/EditorView.swift | 6 ++++ .../Sources/EditorViewController.swift | 29 ++++++++++++++++++- .../EditorViewControllerDelegate.swift | 13 +++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index f47538f2..ab716c06 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -213,6 +213,12 @@ private struct _EditorView: UIViewControllerRepresentable { print(" Response Body: \(responseBody.prefix(200))...") } } + + func editorRequestsLatestContent(_ controller: EditorViewController) -> (title: String, content: String)? { + // Demo app has no persistence layer, so return nil. + // In a real app, return the persisted title and content from autosave. + return nil + } } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index bfa216f0..b03bd7db 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -170,6 +170,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // Set-up communications with the editor. config.userContentController.add(controller, name: "editorDelegate") + // Register async message handler for content recovery requests. + // This allows JavaScript to request the latest persisted content from the native host. + config.userContentController.addScriptMessageHandler(controller, contentWorld: .page, name: "requestLatestContent") + // This is important so they user can't select anything but text across blocks. config.selectionGranularity = .character @@ -659,6 +663,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } } + fileprivate func controllerRequestsLatestContent(_ controller: GutenbergEditorController) -> (title: String, content: String)? { + return delegate?.editorRequestsLatestContent(self) + } + // MARK: - Loading Complete: Editor Ready /// Called when the editor JavaScript emits the `onEditorLoaded` message. @@ -714,10 +722,11 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro @MainActor private protocol GutenbergEditorControllerDelegate: AnyObject { func controller(_ controller: GutenbergEditorController, didReceiveMessage message: EditorJSMessage) + func controllerRequestsLatestContent(_ controller: GutenbergEditorController) -> (title: String, content: String)? } /// Hiding the conformances, and breaking retain cycles. -private final class GutenbergEditorController: NSObject, WKNavigationDelegate, WKScriptMessageHandler { +private final class GutenbergEditorController: NSObject, WKNavigationDelegate, WKScriptMessageHandler, WKScriptMessageHandlerWithReply { weak var delegate: GutenbergEditorControllerDelegate? let configuration: EditorConfiguration private let editorURL: URL? @@ -728,6 +737,24 @@ private final class GutenbergEditorController: NSObject, WKNavigationDelegate, W super.init() } + // MARK: - WKScriptMessageHandlerWithReply + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) async -> (Any?, String?) { + guard message.name == "requestLatestContent" else { + return (nil, "Unknown message handler: \(message.name)") + } + + return await MainActor.run { + guard let content = delegate?.controllerRequestsLatestContent(self) else { + return (nil, nil) // No content available - not an error + } + return ([ + "title": content.title, + "content": content.content + ], nil) + } + } + // MARK: - WKNavigationDelegate func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift index 4f01be0a..202b73d7 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift @@ -59,6 +59,19 @@ public protocol EditorViewControllerDelegate: AnyObject { /// /// - parameter request: The network request details including URL, headers, body, response, and timing. func editor(_ viewController: EditorViewController, didLogNetworkRequest request: RecordedNetworkRequest) + + /// Provides the latest persisted content for recovery after WebView refresh. + /// + /// Called when the WebView requests content during initialization. The host app should return + /// the most recently persisted title and content from autosave. This allows content recovery + /// when the WebView is re-initialized (e.g., due to OS memory pressure or page refresh). + /// + /// Note: The values in `EditorConfiguration.title` and `EditorConfiguration.content` are "initial values" + /// injected at WebView load time. After a WebView refresh, these may be stale. This delegate method + /// allows the host app to provide fresher content from its autosave mechanism. + /// + /// - Returns: A tuple of (title, content), or nil if no persisted content is available. + func editorRequestsLatestContent(_ controller: EditorViewController) -> (title: String, content: String)? } #endif From 3de1f60564b601b24e6a012ca3fa6dd64639fead Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 16 Jan 2026 16:07:10 -0500 Subject: [PATCH 03/18] feat: Android returns latest content across bridge Enable the editor to request the latest content from the host. --- .../org/wordpress/gutenberg/GutenbergView.kt | 46 +++++++++++++++++++ .../example/gutenbergkit/EditorActivity.kt | 7 +++ 2 files changed, 53 insertions(+) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index ecb8505d..a546c3a0 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -108,6 +108,7 @@ class GutenbergView : WebView { private var modalDialogStateListener: ModalDialogStateListener? = null private var networkRequestListener: NetworkRequestListener? = null private var loadingListener: EditorLoadingListener? = null + private var latestContentProvider: LatestContentProvider? = null /** * Stores the contextId from the most recent openMediaLibrary call @@ -158,6 +159,10 @@ class GutenbergView : WebView { networkRequestListener = listener } + fun setLatestContentProvider(provider: LatestContentProvider?) { + latestContentProvider = provider + } + fun setOnFileChooserRequestedListener(listener: (Intent, Int) -> Unit) { onFileChooserRequested = listener } @@ -511,6 +516,29 @@ class GutenbergView : WebView { fun onNetworkRequest(request: RecordedNetworkRequest) } + /** + * Provides the latest persisted content for recovery after WebView refresh. + * + * When the WebView reinitializes (e.g., due to OS memory pressure or page refresh), + * the editor requests the latest content from this provider. The host app should + * return the most recently persisted title and content from autosave. + */ + interface LatestContentProvider { + /** + * Returns the most recently persisted title and content from autosave. + * @return LatestContent if available, null if no persisted content exists. + */ + fun getLatestContent(): LatestContent? + } + + /** + * Represents persisted editor content for recovery. + */ + data class LatestContent( + val title: String, + val content: String + ) + fun getTitleAndContent(originalContent: CharSequence, callback: TitleAndContentCallback, completeComposition: Boolean = false) { if (!isEditorLoaded) { Log.e("GutenbergView", "You can't change the editor content until it has loaded") @@ -731,6 +759,23 @@ class GutenbergView : WebView { } } + /** + * Called by JavaScript to request the latest persisted content. + * + * This method is invoked during editor initialization to recover content + * after WebView refresh. The host app provides content via [LatestContentProvider]. + * + * @return JSON string with title and content fields, or null if unavailable. + */ + @JavascriptInterface + fun requestLatestContent(): String? { + val content = latestContentProvider?.getLatestContent() ?: return null + return JSONObject().apply { + put("title", content.title) + put("content", content.content) + }.toString() + } + fun resetFilePathCallback() { filePathCallback = null } @@ -814,6 +859,7 @@ class GutenbergView : WebView { modalDialogStateListener = null networkRequestListener = null requestInterceptor = DefaultGutenbergRequestInterceptor() + latestContentProvider = null handler.removeCallbacksAndMessages(null) this.destroy() } diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index efe1f29f..78ed9dce 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -344,6 +344,13 @@ fun EditorScreen( loadingError = error.message ?: "Unknown error" } }) + // Demo app has no persistence layer, so return null. + // In a real app, return the persisted title and content from autosave. + setLatestContentProvider(object : GutenbergView.LatestContentProvider { + override fun getLatestContent(): GutenbergView.LatestContent? { + return null + } + }) onGutenbergViewCreated(this) } }, From 45470c3165d6de666b8a2932a0336e09d085202b Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 16 Jan 2026 16:12:07 -0500 Subject: [PATCH 04/18] feat: Retrieve content from native host during initialization Ensure the editor always uses the latest content. This particularly important for subsequent initialization events--e.g., WebView refresh. --- src/utils/bridge.js | 31 ++++++- src/utils/bridge.test.js | 195 ++++++++++++++++++++++++++++++++++++++- src/utils/editor.jsx | 5 +- 3 files changed, 223 insertions(+), 8 deletions(-) diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 5624b6b7..beabb0df 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -278,13 +278,36 @@ export async function requestLatestContent() { } /** - * Retrieves the current post data from the GBKit global. + * Retrieves the current post data from the native host * - * @return {Post} The post object containing the following properties: + * Always requests content from the native host first, as it maintains the + * latest content via autosave. Falls back to `window.GBKit.post` only if the + * native bridge is unavailable (e.g., dev mode). + * + * Note: `window.GBKit.post.title/content` are "initial values" injected at + * WebView load. After a WebView refresh, these may be stale. The native host + * has the authoritative content from autosave. + * + * @return {Promise} The post object. */ -export function getPost() { +export async function getPost() { const { post } = getGBKit(); + + const hostContent = await requestLatestContent(); + + if ( hostContent ) { + debug( 'Using content from native host' ); + return { + id: post?.id ?? -1, + type: post?.type || 'post', + status: post?.status || 'auto-draft', + title: { raw: hostContent.title }, + content: { raw: hostContent.content }, + }; + } + if ( post ) { + debug( 'Native bridge unavailable, using GBKit initial content' ); return { id: post.id, type: post.type || 'post', @@ -294,8 +317,6 @@ export function getPost() { }; } - // Since we don't use the auto-save functionality, draft posts need to have an ID. - // We assign a temporary ID of -1. return { id: -1, type: 'post', diff --git a/src/utils/bridge.test.js b/src/utils/bridge.test.js index f174fa87..f98dedda 100644 --- a/src/utils/bridge.test.js +++ b/src/utils/bridge.test.js @@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; /** * Internal dependencies */ -import { requestLatestContent } from './bridge'; +import { requestLatestContent, getPost } from './bridge'; describe( 'requestLatestContent', () => { let originalWindow; @@ -211,3 +211,196 @@ describe( 'requestLatestContent', () => { } ); } ); } ); + +describe( 'getPost', () => { + let originalWindow; + + beforeEach( () => { + // Store original window properties + originalWindow = { + webkit: window.webkit, + editorDelegate: window.editorDelegate, + GBKit: window.GBKit, + }; + // Clear any existing properties + delete window.webkit; + delete window.editorDelegate; + delete window.GBKit; + } ); + + afterEach( () => { + // Restore original window properties + if ( originalWindow.webkit !== undefined ) { + window.webkit = originalWindow.webkit; + } + if ( originalWindow.editorDelegate !== undefined ) { + window.editorDelegate = originalWindow.editorDelegate; + } + if ( originalWindow.GBKit !== undefined ) { + window.GBKit = originalWindow.GBKit; + } + } ); + + describe( 'content from native host', () => { + it( 'should return host content when available', async () => { + const hostContent = { + title: 'Host Title', + content: 'Host Content', + }; + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi.fn().mockResolvedValue( hostContent ), + }, + }, + }; + window.GBKit = { + post: { + id: 123, + type: 'page', + status: 'draft', + title: 'GBKit%20Title', + content: 'GBKit%20Content', + }, + }; + + const result = await getPost(); + + expect( result ).toEqual( { + id: 123, + type: 'page', + status: 'draft', + title: { raw: 'Host Title' }, + content: { raw: 'Host Content' }, + } ); + } ); + + it( 'should use GBKit post metadata with host content', async () => { + const hostContent = { + title: 'Updated Title', + content: 'Updated Content', + }; + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi.fn().mockResolvedValue( hostContent ), + }, + }, + }; + window.GBKit = { + post: { + id: 456, + type: 'post', + status: 'publish', + title: 'Original%20Title', + content: 'Original%20Content', + }, + }; + + const result = await getPost(); + + // Should use id/type/status from GBKit, but title/content from host + expect( result.id ).toBe( 456 ); + expect( result.type ).toBe( 'post' ); + expect( result.status ).toBe( 'publish' ); + expect( result.title.raw ).toBe( 'Updated Title' ); + expect( result.content.raw ).toBe( 'Updated Content' ); + } ); + } ); + + describe( 'fallback to GBKit', () => { + it( 'should fall back to GBKit when host returns null', async () => { + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi.fn().mockResolvedValue( null ), + }, + }, + }; + window.GBKit = { + post: { + id: 789, + type: 'post', + status: 'draft', + title: 'GBKit%20Title', + content: 'GBKit%20Content', + }, + }; + + const result = await getPost(); + + expect( result ).toEqual( { + id: 789, + type: 'post', + status: 'draft', + title: { raw: 'GBKit Title' }, + content: { raw: 'GBKit Content' }, + } ); + } ); + + it( 'should fall back to GBKit when no bridge available', async () => { + // No bridge set up + window.GBKit = { + post: { + id: 101, + type: 'page', + status: 'publish', + title: 'Fallback%20Title', + content: 'Fallback%20Content', + }, + }; + + const result = await getPost(); + + expect( result ).toEqual( { + id: 101, + type: 'page', + status: 'publish', + title: { raw: 'Fallback Title' }, + content: { raw: 'Fallback Content' }, + } ); + } ); + } ); + + describe( 'default empty post', () => { + it( 'should return default empty post when both are unavailable', async () => { + // No bridge and no GBKit + + const result = await getPost(); + + expect( result ).toEqual( { + id: -1, + type: 'post', + status: 'auto-draft', + title: { raw: '' }, + content: { raw: '' }, + } ); + } ); + + it( 'should use defaults when GBKit post lacks optional fields', async () => { + const hostContent = { + title: 'Title', + content: 'Content', + }; + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi.fn().mockResolvedValue( hostContent ), + }, + }, + }; + // GBKit exists but post is empty/undefined + window.GBKit = {}; + + const result = await getPost(); + + expect( result ).toEqual( { + id: -1, + type: 'post', + status: 'auto-draft', + title: { raw: 'Title' }, + content: { raw: 'Content' }, + } ); + } ); + } ); +} ); diff --git a/src/utils/editor.jsx b/src/utils/editor.jsx index 149d1c1b..b6074a3b 100644 --- a/src/utils/editor.jsx +++ b/src/utils/editor.jsx @@ -18,7 +18,7 @@ import { getDefaultEditorSettings } from './editor-settings'; * @param {Array} [options.allowedBlockTypes] Array of allowed block types * @param {boolean} [options.pluginLoadFailed] Whether plugin loading failed */ -export function initializeEditor( { +export async function initializeEditor( { allowedBlockTypes, pluginLoadFailed, } = {} ) { @@ -42,7 +42,8 @@ export function initializeEditor( { registerCoreBlocks(); unregisterDisallowedBlocks( allowedBlockTypes ); - const post = getPost(); + + const post = await getPost(); createRoot( document.getElementById( 'root' ) ).render( From 1b77ea245dc2b9aba5d2fd6c377cdfaceeae0389 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Fri, 16 Jan 2026 16:40:24 -0500 Subject: [PATCH 05/18] fix: Prevent sending `Any?` as `Sendable` When crossing actor boundaries, Swift requires type to conform to `Sendable`, which `Any?` cannot. Constructing the dictionary outside of the `MainActor` run avoids this incompatibility. --- .../Sources/EditorViewController.swift | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index b03bd7db..53fbecc9 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -744,15 +744,18 @@ private final class GutenbergEditorController: NSObject, WKNavigationDelegate, W return (nil, "Unknown message handler: \(message.name)") } - return await MainActor.run { - guard let content = delegate?.controllerRequestsLatestContent(self) else { - return (nil, nil) // No content available - not an error - } - return ([ - "title": content.title, - "content": content.content - ], nil) + let content = await MainActor.run { + delegate?.controllerRequestsLatestContent(self) + } + + guard let content else { + return (nil, nil) // No content available - not an error } + + return ([ + "title": content.title, + "content": content.content + ] as [String: String], nil) } // MARK: - WKNavigationDelegate From 9d605ba9e124ae471c292849042b7b66ca2c475e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 20 Jan 2026 13:24:52 -0500 Subject: [PATCH 06/18] task: Log errors when retrieving and parsing post content Improve visibility of critical errors. --- src/utils/bridge.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/utils/bridge.js b/src/utils/bridge.js index beabb0df..0cfc1e79 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -2,7 +2,7 @@ * Internal dependencies */ import parseException from './exception-parser'; -import { debug } from './logger'; +import { debug, error } from './logger'; import { isDevMode } from './dev-mode'; import { basicFetch } from './fetch'; @@ -229,7 +229,8 @@ export function getGBKit() { try { return JSON.parse( localStorage.getItem( 'GBKit' ) ) || {}; - } catch ( error ) { + } catch ( err ) { + error( 'Failed to parse GBKit from localStorage', err ); return {}; } } @@ -258,8 +259,8 @@ export async function requestLatestContent() { return await window.webkit.messageHandlers.requestLatestContent.postMessage( {} ); - } catch ( error ) { - debug( 'Failed to request content from iOS host', error ); + } catch ( err ) { + error( 'Failed to request content from iOS host', err ); return null; } } @@ -268,8 +269,8 @@ export async function requestLatestContent() { try { const result = window.editorDelegate.requestLatestContent(); return result ? JSON.parse( result ) : null; - } catch ( error ) { - debug( 'Failed to request content from Android host', error ); + } catch ( err ) { + error( 'Failed to request content from Android host', err ); return null; } } From ce72b1964abaae6d2b3c139c5be736a7e7a72165 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 20 Jan 2026 13:26:33 -0500 Subject: [PATCH 07/18] task: Catch Android exceptions while parsing post content Mitigate crashes from unexpected characters. --- .../java/org/wordpress/gutenberg/GutenbergView.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index a546c3a0..348c3236 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -770,10 +770,15 @@ class GutenbergView : WebView { @JavascriptInterface fun requestLatestContent(): String? { val content = latestContentProvider?.getLatestContent() ?: return null - return JSONObject().apply { - put("title", content.title) - put("content", content.content) - }.toString() + return try { + JSONObject().apply { + put("title", content.title) + put("content", content.content) + }.toString() + } catch (e: JSONException) { + Log.e("GutenbergView", "Failed to serialize latest content", e) + null + } } fun resetFilePathCallback() { From d37ea3d320d7cbce5ec7afbef5dffdc94b5cf09c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 20 Jan 2026 13:34:55 -0500 Subject: [PATCH 08/18] test: Remove misguided platform preference assertion There isn't an explicit preference, but a result of arbitrary code ordering. --- src/utils/bridge.test.js | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/src/utils/bridge.test.js b/src/utils/bridge.test.js index f98dedda..490ccfa8 100644 --- a/src/utils/bridge.test.js +++ b/src/utils/bridge.test.js @@ -177,39 +177,6 @@ describe( 'requestLatestContent', () => { expect( result ).toBeNull(); } ); } ); - - describe( 'priority', () => { - it( 'should prefer iOS bridge when both are available', async () => { - const iosContent = { - title: 'iOS Title', - content: 'iOS Content', - }; - const androidContent = { - title: 'Android Title', - content: 'Android Content', - }; - - window.webkit = { - messageHandlers: { - requestLatestContent: { - postMessage: vi.fn().mockResolvedValue( iosContent ), - }, - }, - }; - window.editorDelegate = { - requestLatestContent: vi - .fn() - .mockReturnValue( JSON.stringify( androidContent ) ), - }; - - const result = await requestLatestContent(); - - expect( result ).toEqual( iosContent ); - expect( - window.editorDelegate.requestLatestContent - ).not.toHaveBeenCalled(); - } ); - } ); } ); describe( 'getPost', () => { From f7b7eb591ec8b9164cb9589cbc615429f380470e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 20 Jan 2026 14:22:41 -0500 Subject: [PATCH 09/18] docs: Update inline draft post comment Note the draft post fallback. --- src/utils/bridge.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 0cfc1e79..e4cc7db1 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -318,6 +318,7 @@ export async function getPost() { }; } + // Fallback to default empty post return { id: -1, type: 'post', From 8045ae062996f1e411928a0a6cc8495d6c3da06a Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 20 Jan 2026 17:08:25 -0500 Subject: [PATCH 10/18] fix: Apply consistent post status fallback Ensure the fallback status matches across the various scenarios for sourcing post content. --- src/utils/bridge.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/bridge.js b/src/utils/bridge.js index e4cc7db1..be6dccfb 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -312,7 +312,7 @@ export async function getPost() { return { id: post.id, type: post.type || 'post', - status: post.status, + status: post.status || 'auto-draft', title: { raw: decodeURIComponent( post.title ) }, content: { raw: decodeURIComponent( post.content ) }, }; From 4df49501fb9ca648361ae9815e4c74eb6716b08e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 21 Jan 2026 10:56:14 -0500 Subject: [PATCH 11/18] fix: Switch default fallback post status to `draft` The `auto-draft` post status is unique to the WordPress server; it represents saves not performed by the user, but by the auto-save functionality. This concept is not present in the WordPress mobile apps. Using auto-draft resulted in emptying of post title content due to client-side filters in Gutenberg core. --- src/utils/bridge.js | 6 +++--- src/utils/bridge.test.js | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/utils/bridge.js b/src/utils/bridge.js index be6dccfb..fd2acf3b 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -301,7 +301,7 @@ export async function getPost() { return { id: post?.id ?? -1, type: post?.type || 'post', - status: post?.status || 'auto-draft', + status: post?.status || 'draft', title: { raw: hostContent.title }, content: { raw: hostContent.content }, }; @@ -312,7 +312,7 @@ export async function getPost() { return { id: post.id, type: post.type || 'post', - status: post.status || 'auto-draft', + status: post.status || 'draft', title: { raw: decodeURIComponent( post.title ) }, content: { raw: decodeURIComponent( post.content ) }, }; @@ -322,7 +322,7 @@ export async function getPost() { return { id: -1, type: 'post', - status: 'auto-draft', + status: 'draft', title: { raw: '' }, content: { raw: '' }, }; diff --git a/src/utils/bridge.test.js b/src/utils/bridge.test.js index 490ccfa8..6928581d 100644 --- a/src/utils/bridge.test.js +++ b/src/utils/bridge.test.js @@ -338,7 +338,7 @@ describe( 'getPost', () => { expect( result ).toEqual( { id: -1, type: 'post', - status: 'auto-draft', + status: 'draft', title: { raw: '' }, content: { raw: '' }, } ); @@ -364,7 +364,7 @@ describe( 'getPost', () => { expect( result ).toEqual( { id: -1, type: 'post', - status: 'auto-draft', + status: 'draft', title: { raw: 'Title' }, content: { raw: 'Content' }, } ); From 996d8ef0e25baa6402591a150398b6680d46dd32 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 21 Jan 2026 11:03:37 -0500 Subject: [PATCH 12/18] feat: Configure iOS post status and type --- .../Sources/Model/EditorConfiguration.swift | 17 +++++++++- .../Sources/Model/GBKitGlobal.swift | 12 +++++-- .../Model/EditorConfigurationTests.swift | 18 ++++++++++ .../Model/GBKitGlobalTests.swift | 33 +++++++++++++++++++ 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift index e1afcad8..45bc02ca 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift @@ -12,8 +12,10 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { public let content: String /// ID of the post being edited public let postID: Int? - /// Type of the post being edited + /// Type of the post being edited (e.g., "post", "page") public let postType: String + /// Status of the post being edited (e.g., "draft", "publish", "pending") + public let status: String /// Toggles application of theme styles public let shouldUseThemeStyles: Bool /// Toggles loading plugin-provided editor assets @@ -55,6 +57,7 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { content: String, postID: Int?, postType: String, + status: String, shouldUseThemeStyles: Bool, shouldUsePlugins: Bool, shouldHideTitle: Bool, @@ -76,6 +79,7 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { self.content = content self.postID = postID self.postType = postType + self.status = status self.shouldUseThemeStyles = shouldUseThemeStyles self.shouldUsePlugins = shouldUsePlugins self.shouldHideTitle = shouldHideTitle @@ -106,6 +110,7 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { content: content, postID: postID, postType: postType, + status: status, shouldUseThemeStyles: shouldUseThemeStyles, shouldUsePlugins: shouldUsePlugins, shouldHideTitle: shouldHideTitle, @@ -150,6 +155,7 @@ public struct EditorConfigurationBuilder { private var content: String private var postID: Int? private var postType: String + private var status: String private var shouldUseThemeStyles: Bool private var shouldUsePlugins: Bool private var shouldHideTitle: Bool @@ -172,6 +178,7 @@ public struct EditorConfigurationBuilder { content: String = "", postID: Int? = nil, postType: String, + status: String = "draft", shouldUseThemeStyles: Bool = false, shouldUsePlugins: Bool = false, shouldHideTitle: Bool = false, @@ -193,6 +200,7 @@ public struct EditorConfigurationBuilder { self.content = content self.postID = postID self.postType = postType + self.status = status self.shouldUseThemeStyles = shouldUseThemeStyles self.shouldUsePlugins = shouldUsePlugins self.shouldHideTitle = shouldHideTitle @@ -265,6 +273,12 @@ public struct EditorConfigurationBuilder { return copy } + public func setStatus(_ status: String) -> EditorConfigurationBuilder { + var copy = self + copy.status = status + return copy + } + public func setSiteApiNamespace(_ siteApiNamespace: [String]) -> EditorConfigurationBuilder { var copy = self copy.siteApiNamespace = siteApiNamespace @@ -365,6 +379,7 @@ public struct EditorConfigurationBuilder { content: content, postID: postID, postType: postType, + status: status, shouldUseThemeStyles: shouldUseThemeStyles, shouldUsePlugins: shouldUsePlugins, shouldHideTitle: shouldHideTitle, diff --git a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift index de3ad4e6..a7d14310 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift @@ -11,10 +11,16 @@ public struct GBKitGlobal: Sendable, Codable { public struct Post: Sendable, Codable { /// The post ID, or -1 for new posts. let id: Int - + + /// The post type (e.g., "post", "page"). + let type: String + + /// The post status (e.g., "draft", "publish", "pending"). + let status: String + /// The post title. let title: String - + /// The post content (Gutenberg block markup). let content: String } @@ -93,6 +99,8 @@ public struct GBKitGlobal: Sendable, Codable { self.locale = configuration.locale self.post = Post( id: configuration.postID ?? -1, + type: configuration.postType, + status: configuration.status, title: configuration.escapedTitle, content: configuration.escapedContent ) diff --git a/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift b/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift index dfa22664..a5f4d19c 100644 --- a/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift +++ b/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift @@ -20,6 +20,7 @@ struct EditorConfigurationBuilderTests: MakesTestFixtures { #expect(config.content == "") #expect(config.postID == nil) #expect(config.postType == "post") + #expect(config.status == "draft") #expect(config.shouldUseThemeStyles == false) #expect(config.shouldUsePlugins == false) #expect(config.shouldHideTitle == false) @@ -214,6 +215,22 @@ struct EditorConfigurationBuilderTests: MakesTestFixtures { #expect(config.enableNetworkLogging == true) } + @Test("Builder uses draft as default status") + func builderDefaultStatus() { + let config = makeConfigurationBuilder().build() + + #expect(config.status == "draft") + } + + @Test("setStatus updates status") + func setStatusUpdatesStatus() { + let config = makeConfigurationBuilder() + .setStatus("publish") + .build() + + #expect(config.status == "publish") + } + // MARK: - Method Chaining Tests @Test("Builder supports method chaining") @@ -308,6 +325,7 @@ struct EditorConfigurationBuilderTests: MakesTestFixtures { .setTitle("Round Trip Title") .setContent("

Round trip content

") .setPostID(789) + .setStatus("publish") .setShouldUseThemeStyles(true) .setShouldUsePlugins(true) .setShouldHideTitle(true) diff --git a/ios/Tests/GutenbergKitTests/Model/GBKitGlobalTests.swift b/ios/Tests/GutenbergKitTests/Model/GBKitGlobalTests.swift index 7920c9cc..619fc62e 100644 --- a/ios/Tests/GutenbergKitTests/Model/GBKitGlobalTests.swift +++ b/ios/Tests/GutenbergKitTests/Model/GBKitGlobalTests.swift @@ -95,6 +95,35 @@ struct GBKitGlobalTests: MakesTestFixtures { #expect(globalWithout.post.id == -1) } + @Test("maps postType to post.type") + func mapsPostType() throws { + let postConfig = makeConfiguration(postType: "post") + let pageConfig = makeConfiguration(postType: "page") + + let postGlobal = try GBKitGlobal(configuration: postConfig, dependencies: makeDependencies()) + let pageGlobal = try GBKitGlobal(configuration: pageConfig, dependencies: makeDependencies()) + + #expect(postGlobal.post.type == "post") + #expect(pageGlobal.post.type == "page") + } + + @Test("maps status to post.status") + func mapsStatus() throws { + let draftConfig = makeConfigurationBuilder() + .setStatus("draft") + .build() + let publishConfig = makeConfigurationBuilder() + .setStatus("publish") + .build() + + let draftGlobal = try GBKitGlobal(configuration: draftConfig, dependencies: makeDependencies()) + let publishGlobal = try GBKitGlobal( + configuration: publishConfig, dependencies: makeDependencies()) + + #expect(draftGlobal.post.status == "draft") + #expect(publishGlobal.post.status == "publish") + } + @Test("maps title with percent encoding") func mapsTitleWithEncoding() throws { let configuration = makeConfiguration(title: "Hello World") @@ -137,6 +166,8 @@ struct GBKitGlobalTests: MakesTestFixtures { #expect(jsonString.contains("themeStyles")) #expect(jsonString.contains("plugins")) #expect(jsonString.contains("post")) + #expect(jsonString.contains("\"type\"")) + #expect(jsonString.contains("\"status\"")) #expect(jsonString.contains("locale")) #expect(jsonString.contains("logLevel")) } @@ -152,6 +183,8 @@ struct GBKitGlobalTests: MakesTestFixtures { #expect(decoded.siteURL == original.siteURL) #expect(decoded.post.id == original.post.id) + #expect(decoded.post.type == original.post.type) + #expect(decoded.post.status == original.post.status) #expect(decoded.post.title == original.post.title) #expect(decoded.themeStyles == original.themeStyles) #expect(decoded.plugins == original.plugins) From 1084f2430ed3daee2329864b4d1c0c1e8be05510 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 21 Jan 2026 11:11:18 -0500 Subject: [PATCH 13/18] feat: Configure Android post status and type --- .../gutenberg/model/EditorConfiguration.kt | 7 ++++ .../model/EditorConfigurationTest.kt | 40 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index c5a6cdc5..8d27ac0d 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -12,6 +12,7 @@ data class EditorConfiguration( val content: String, val postId: Int?, val postType: String, + val postStatus: String?, val themeStyles: Boolean, val plugins: Boolean, val hideTitle: Boolean, @@ -57,6 +58,7 @@ data class EditorConfiguration( private var title: String = "" private var content: String = "" private var postId: Int? = null + private var postStatus: String? = null private var themeStyles: Boolean = false private var plugins: Boolean = false private var hideTitle: Boolean = false @@ -76,6 +78,7 @@ data class EditorConfiguration( fun setContent(content: String) = apply { this.content = content } fun setPostId(postId: Int?) = apply { this.postId = postId } fun setPostType(postType: String) = apply { this.postType = postType } + fun setPostStatus(postStatus: String?) = apply { this.postStatus = postStatus } fun setThemeStyles(themeStyles: Boolean) = apply { this.themeStyles = themeStyles } fun setPlugins(plugins: Boolean) = apply { this.plugins = plugins } fun setHideTitle(hideTitle: Boolean) = apply { this.hideTitle = hideTitle } @@ -98,6 +101,7 @@ data class EditorConfiguration( content = content, postId = postId, postType = postType, + postStatus = postStatus, themeStyles = themeStyles, plugins = plugins, hideTitle = hideTitle, @@ -126,6 +130,7 @@ data class EditorConfiguration( .setTitle(title) .setContent(content) .setPostId(postId) + .setPostStatus(postStatus) .setThemeStyles(themeStyles) .setPlugins(plugins) .setHideTitle(hideTitle) @@ -151,6 +156,7 @@ data class EditorConfiguration( if (content != other.content) return false if (postId != other.postId) return false if (postType != other.postType) return false + if (postStatus != other.postStatus) return false if (themeStyles != other.themeStyles) return false if (plugins != other.plugins) return false if (hideTitle != other.hideTitle) return false @@ -177,6 +183,7 @@ data class EditorConfiguration( result = 31 * result + content.hashCode() result = 31 * result + (postId ?: 0) result = 31 * result + postType.hashCode() + result = 31 * result + (postStatus?.hashCode() ?: 0) result = 31 * result + themeStyles.hashCode() result = 31 * result + plugins.hashCode() result = 31 * result + hideTitle.hashCode() diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt index 685be9bf..efbe7d94 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt @@ -30,6 +30,7 @@ class EditorConfigurationBuilderTest { assertEquals("", config.content) assertNull(config.postId) assertEquals(TEST_POST_TYPE, config.postType) + assertNull(config.postStatus) assertFalse(config.themeStyles) assertFalse(config.plugins) assertFalse(config.hideTitle) @@ -96,6 +97,25 @@ class EditorConfigurationBuilderTest { assertEquals("page", config.postType) } + @Test + fun `setPostStatus updates postStatus`() { + val config = builder() + .setPostStatus("publish") + .build() + + assertEquals("publish", config.postStatus) + } + + @Test + fun `setPostStatus with null clears postStatus`() { + val config = builder() + .setPostStatus("publish") + .setPostStatus(null) + .build() + + assertNull(config.postStatus) + } + @Test fun `setThemeStyles updates themeStyles`() { val config = builder() @@ -292,6 +312,7 @@ class EditorConfigurationBuilderTest { .setContent("

Round trip content

") .setPostId(999) .setPostType("page") + .setPostStatus("draft") .setThemeStyles(true) .setPlugins(true) .setHideTitle(true) @@ -368,6 +389,7 @@ class EditorConfigurationBuilderTest { val original = builder() .setPostId(123) .setPostType("post") + .setPostStatus("publish") .setEditorSettings("""{"test":true}""") .setEditorAssetsEndpoint("https://example.com/assets") .build() @@ -376,6 +398,7 @@ class EditorConfigurationBuilderTest { assertEquals(123, rebuilt.postId) assertEquals("post", rebuilt.postType) + assertEquals("publish", rebuilt.postStatus) assertEquals("""{"test":true}""", rebuilt.editorSettings) assertEquals("https://example.com/assets", rebuilt.editorAssetsEndpoint) } @@ -384,6 +407,7 @@ class EditorConfigurationBuilderTest { fun `toBuilder preserves nullable values when null`() { val original = builder() .setPostId(null) + .setPostStatus(null) .setEditorSettings(null) .setEditorAssetsEndpoint(null) .build() @@ -391,6 +415,7 @@ class EditorConfigurationBuilderTest { val rebuilt = original.toBuilder().build() assertNull(rebuilt.postId) + assertNull(rebuilt.postStatus) assertNull(rebuilt.editorSettings) assertNull(rebuilt.editorAssetsEndpoint) } @@ -540,6 +565,19 @@ class EditorConfigurationTest { assertNotEquals(config1, config2) } + @Test + fun `Configurations with different postStatus are not equal`() { + val config1 = builder() + .setPostStatus("draft") + .build() + + val config2 = builder() + .setPostStatus("publish") + .build() + + assertNotEquals(config1, config2) + } + @Test fun `Configurations with different themeStyles are not equal`() { val config1 = builder() @@ -820,6 +858,7 @@ class EditorConfigurationTest { .setContent("Test Content") .setPostId(123) .setPostType("post") + .setPostStatus("publish") .setThemeStyles(true) .setPlugins(true) .setHideTitle(false) @@ -842,6 +881,7 @@ class EditorConfigurationTest { assertEquals("Test Content", config.content) assertEquals(123, config.postId) assertEquals("post", config.postType) + assertEquals("publish", config.postStatus) assertTrue(config.themeStyles) assertTrue(config.plugins) assertFalse(config.hideTitle) From 10902935aebdb3c64fe22073cbe9f7b595df202b Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 22 Jan 2026 11:21:50 -0500 Subject: [PATCH 14/18] fix: Expose Android post type and status in GBKit global --- .../main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt index 7193ae0d..898d9ed8 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt @@ -69,6 +69,10 @@ data class GBKitGlobal( data class Post( /** The post ID, or -1 for new posts. */ val id: Int, + /** The post type (e.g., `post`, `page`). */ + val type: String, + /** The post status (e.g., `draft`, `publish`, `pending`). */ + val status: String, /** The post title (URL-encoded). */ val title: String, /** The post content (URL-encoded Gutenberg block markup). */ @@ -100,6 +104,8 @@ data class GBKitGlobal( locale = configuration.locale ?: "en", post = Post( id = configuration.postId ?: -1, + type = configuration.postType, + status = configuration.postStatus ?: "draft", title = configuration.title.encodeForEditor(), content = configuration.content.encodeForEditor() ), From 8d6faf8ffe3b74cd98a518c3958422ccf410bc76 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 22 Jan 2026 11:32:16 -0500 Subject: [PATCH 15/18] refactor: Rename iOS `postStatus` field to mirror Android The name is more explicit and matches other fields like `postId`. This also improves cross-platform alignment. --- .../Sources/Model/EditorConfiguration.swift | 20 +++++++++---------- .../Sources/Model/GBKitGlobal.swift | 2 +- .../Model/EditorConfigurationTests.swift | 18 ++++++++--------- .../Model/GBKitGlobalTests.swift | 8 ++++---- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift index 45bc02ca..d2fd8577 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift @@ -15,7 +15,7 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { /// Type of the post being edited (e.g., "post", "page") public let postType: String /// Status of the post being edited (e.g., "draft", "publish", "pending") - public let status: String + public let postStatus: String /// Toggles application of theme styles public let shouldUseThemeStyles: Bool /// Toggles loading plugin-provided editor assets @@ -57,7 +57,7 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { content: String, postID: Int?, postType: String, - status: String, + postStatus: String, shouldUseThemeStyles: Bool, shouldUsePlugins: Bool, shouldHideTitle: Bool, @@ -79,7 +79,7 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { self.content = content self.postID = postID self.postType = postType - self.status = status + self.postStatus = postStatus self.shouldUseThemeStyles = shouldUseThemeStyles self.shouldUsePlugins = shouldUsePlugins self.shouldHideTitle = shouldHideTitle @@ -110,7 +110,7 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { content: content, postID: postID, postType: postType, - status: status, + postStatus: postStatus, shouldUseThemeStyles: shouldUseThemeStyles, shouldUsePlugins: shouldUsePlugins, shouldHideTitle: shouldHideTitle, @@ -155,7 +155,7 @@ public struct EditorConfigurationBuilder { private var content: String private var postID: Int? private var postType: String - private var status: String + private var postStatus: String private var shouldUseThemeStyles: Bool private var shouldUsePlugins: Bool private var shouldHideTitle: Bool @@ -178,7 +178,7 @@ public struct EditorConfigurationBuilder { content: String = "", postID: Int? = nil, postType: String, - status: String = "draft", + postStatus: String = "draft", shouldUseThemeStyles: Bool = false, shouldUsePlugins: Bool = false, shouldHideTitle: Bool = false, @@ -200,7 +200,7 @@ public struct EditorConfigurationBuilder { self.content = content self.postID = postID self.postType = postType - self.status = status + self.postStatus = postStatus self.shouldUseThemeStyles = shouldUseThemeStyles self.shouldUsePlugins = shouldUsePlugins self.shouldHideTitle = shouldHideTitle @@ -273,9 +273,9 @@ public struct EditorConfigurationBuilder { return copy } - public func setStatus(_ status: String) -> EditorConfigurationBuilder { + public func setPostStatus(_ postStatus: String) -> EditorConfigurationBuilder { var copy = self - copy.status = status + copy.postStatus = postStatus return copy } @@ -379,7 +379,7 @@ public struct EditorConfigurationBuilder { content: content, postID: postID, postType: postType, - status: status, + postStatus: postStatus, shouldUseThemeStyles: shouldUseThemeStyles, shouldUsePlugins: shouldUsePlugins, shouldHideTitle: shouldHideTitle, diff --git a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift index a7d14310..40f98112 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift @@ -100,7 +100,7 @@ public struct GBKitGlobal: Sendable, Codable { self.post = Post( id: configuration.postID ?? -1, type: configuration.postType, - status: configuration.status, + status: configuration.postStatus, title: configuration.escapedTitle, content: configuration.escapedContent ) diff --git a/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift b/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift index a5f4d19c..2e057150 100644 --- a/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift +++ b/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift @@ -20,7 +20,7 @@ struct EditorConfigurationBuilderTests: MakesTestFixtures { #expect(config.content == "") #expect(config.postID == nil) #expect(config.postType == "post") - #expect(config.status == "draft") + #expect(config.postStatus == "draft") #expect(config.shouldUseThemeStyles == false) #expect(config.shouldUsePlugins == false) #expect(config.shouldHideTitle == false) @@ -215,20 +215,20 @@ struct EditorConfigurationBuilderTests: MakesTestFixtures { #expect(config.enableNetworkLogging == true) } - @Test("Builder uses draft as default status") - func builderDefaultStatus() { + @Test("Builder uses draft as default postStatus") + func builderDefaultPostStatus() { let config = makeConfigurationBuilder().build() - #expect(config.status == "draft") + #expect(config.postStatus == "draft") } - @Test("setStatus updates status") - func setStatusUpdatesStatus() { + @Test("setPostStatus updates postStatus") + func setPostStatusUpdatesPostStatus() { let config = makeConfigurationBuilder() - .setStatus("publish") + .setPostStatus("publish") .build() - #expect(config.status == "publish") + #expect(config.postStatus == "publish") } // MARK: - Method Chaining Tests @@ -325,7 +325,7 @@ struct EditorConfigurationBuilderTests: MakesTestFixtures { .setTitle("Round Trip Title") .setContent("

Round trip content

") .setPostID(789) - .setStatus("publish") + .setPostStatus("publish") .setShouldUseThemeStyles(true) .setShouldUsePlugins(true) .setShouldHideTitle(true) diff --git a/ios/Tests/GutenbergKitTests/Model/GBKitGlobalTests.swift b/ios/Tests/GutenbergKitTests/Model/GBKitGlobalTests.swift index 619fc62e..e1fcd1aa 100644 --- a/ios/Tests/GutenbergKitTests/Model/GBKitGlobalTests.swift +++ b/ios/Tests/GutenbergKitTests/Model/GBKitGlobalTests.swift @@ -107,13 +107,13 @@ struct GBKitGlobalTests: MakesTestFixtures { #expect(pageGlobal.post.type == "page") } - @Test("maps status to post.status") - func mapsStatus() throws { + @Test("maps postStatus to post.status") + func mapsPostStatus() throws { let draftConfig = makeConfigurationBuilder() - .setStatus("draft") + .setPostStatus("draft") .build() let publishConfig = makeConfigurationBuilder() - .setStatus("publish") + .setPostStatus("publish") .build() let draftGlobal = try GBKitGlobal(configuration: draftConfig, dependencies: makeDependencies()) From fa4ac86bdd924deac711f3dd1704e406fccb4c0d Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 22 Jan 2026 11:33:21 -0500 Subject: [PATCH 16/18] refactor: Align Android `postStatus` default with iOS Structure the `postStatus` field in the same manner across platforms for consistency. --- .../gutenberg/model/EditorConfiguration.kt | 8 ++++---- .../gutenberg/model/EditorConfigurationTest.kt | 14 +------------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 8d27ac0d..2e44cbae 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -12,7 +12,7 @@ data class EditorConfiguration( val content: String, val postId: Int?, val postType: String, - val postStatus: String?, + val postStatus: String, val themeStyles: Boolean, val plugins: Boolean, val hideTitle: Boolean, @@ -58,7 +58,7 @@ data class EditorConfiguration( private var title: String = "" private var content: String = "" private var postId: Int? = null - private var postStatus: String? = null + private var postStatus: String = "draft" private var themeStyles: Boolean = false private var plugins: Boolean = false private var hideTitle: Boolean = false @@ -78,7 +78,7 @@ data class EditorConfiguration( fun setContent(content: String) = apply { this.content = content } fun setPostId(postId: Int?) = apply { this.postId = postId } fun setPostType(postType: String) = apply { this.postType = postType } - fun setPostStatus(postStatus: String?) = apply { this.postStatus = postStatus } + fun setPostStatus(postStatus: String) = apply { this.postStatus = postStatus } fun setThemeStyles(themeStyles: Boolean) = apply { this.themeStyles = themeStyles } fun setPlugins(plugins: Boolean) = apply { this.plugins = plugins } fun setHideTitle(hideTitle: Boolean) = apply { this.hideTitle = hideTitle } @@ -183,7 +183,7 @@ data class EditorConfiguration( result = 31 * result + content.hashCode() result = 31 * result + (postId ?: 0) result = 31 * result + postType.hashCode() - result = 31 * result + (postStatus?.hashCode() ?: 0) + result = 31 * result + postStatus.hashCode() result = 31 * result + themeStyles.hashCode() result = 31 * result + plugins.hashCode() result = 31 * result + hideTitle.hashCode() diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt index efbe7d94..e3f9cd0a 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt @@ -30,7 +30,7 @@ class EditorConfigurationBuilderTest { assertEquals("", config.content) assertNull(config.postId) assertEquals(TEST_POST_TYPE, config.postType) - assertNull(config.postStatus) + assertEquals("draft", config.postStatus) assertFalse(config.themeStyles) assertFalse(config.plugins) assertFalse(config.hideTitle) @@ -106,16 +106,6 @@ class EditorConfigurationBuilderTest { assertEquals("publish", config.postStatus) } - @Test - fun `setPostStatus with null clears postStatus`() { - val config = builder() - .setPostStatus("publish") - .setPostStatus(null) - .build() - - assertNull(config.postStatus) - } - @Test fun `setThemeStyles updates themeStyles`() { val config = builder() @@ -407,7 +397,6 @@ class EditorConfigurationBuilderTest { fun `toBuilder preserves nullable values when null`() { val original = builder() .setPostId(null) - .setPostStatus(null) .setEditorSettings(null) .setEditorAssetsEndpoint(null) .build() @@ -415,7 +404,6 @@ class EditorConfigurationBuilderTest { val rebuilt = original.toBuilder().build() assertNull(rebuilt.postId) - assertNull(rebuilt.postStatus) assertNull(rebuilt.editorSettings) assertNull(rebuilt.editorAssetsEndpoint) } From 5c33db0ca0810ab5952b584048c2de61737afb5c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 22 Jan 2026 15:37:55 -0500 Subject: [PATCH 17/18] refactor: Rename iOS bridge methods Align with Swift naming practices. --- ios/Demo-iOS/Sources/Views/EditorView.swift | 2 +- .../GutenbergKit/Sources/EditorViewController.swift | 8 ++++---- .../Sources/EditorViewControllerDelegate.swift | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index ab716c06..90ca1be8 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -214,7 +214,7 @@ private struct _EditorView: UIViewControllerRepresentable { } } - func editorRequestsLatestContent(_ controller: EditorViewController) -> (title: String, content: String)? { + func editorDidRequestLatestContent(_ controller: EditorViewController) -> (title: String, content: String)? { // Demo app has no persistence layer, so return nil. // In a real app, return the persisted title and content from autosave. return nil diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 53fbecc9..aaca99d2 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -663,8 +663,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } } - fileprivate func controllerRequestsLatestContent(_ controller: GutenbergEditorController) -> (title: String, content: String)? { - return delegate?.editorRequestsLatestContent(self) + fileprivate func controllerDidRequestLatestContent(_ controller: GutenbergEditorController) -> (title: String, content: String)? { + return delegate?.editorDidRequestLatestContent(self) } // MARK: - Loading Complete: Editor Ready @@ -722,7 +722,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro @MainActor private protocol GutenbergEditorControllerDelegate: AnyObject { func controller(_ controller: GutenbergEditorController, didReceiveMessage message: EditorJSMessage) - func controllerRequestsLatestContent(_ controller: GutenbergEditorController) -> (title: String, content: String)? + func controllerDidRequestLatestContent(_ controller: GutenbergEditorController) -> (title: String, content: String)? } /// Hiding the conformances, and breaking retain cycles. @@ -745,7 +745,7 @@ private final class GutenbergEditorController: NSObject, WKNavigationDelegate, W } let content = await MainActor.run { - delegate?.controllerRequestsLatestContent(self) + delegate?.controllerDidRequestLatestContent(self) } guard let content else { diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift index 202b73d7..979acd61 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift @@ -71,7 +71,7 @@ public protocol EditorViewControllerDelegate: AnyObject { /// allows the host app to provide fresher content from its autosave mechanism. /// /// - Returns: A tuple of (title, content), or nil if no persisted content is available. - func editorRequestsLatestContent(_ controller: EditorViewController) -> (title: String, content: String)? + func editorDidRequestLatestContent(_ controller: EditorViewController) -> (title: String, content: String)? } #endif From ba98f11399c285289efa1b46b9d590626be1ce3c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 22 Jan 2026 15:53:24 -0500 Subject: [PATCH 18/18] feat: GutenbergKit handles `webViewWebContentProcessDidTerminate` Improve the robustness of content restoration in the event the `WKWebView` is terminated on iOS due to memory pressure. In testing by manually terminating the `com.apple.WebKit.WebContent` task in the Activity Monitor, the editor recovered without these changes, at least the first time. However, terminating the task more than once led to an empty WebView for the editor. Implementing `webViewWebContentProcessDidTerminate` seems to improve the robustness. Terminating the `com.apple.WebKit.WebContent` task multiple times now always leads to the editor restoring the latest content. --- .../GutenbergKit/Sources/EditorViewController.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index aaca99d2..9edaff7a 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -667,6 +667,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro return delegate?.editorDidRequestLatestContent(self) } + fileprivate func controllerWebContentProcessDidTerminate(_ controller: GutenbergEditorController) { + webView.reload() + } + // MARK: - Loading Complete: Editor Ready /// Called when the editor JavaScript emits the `onEditorLoaded` message. @@ -723,6 +727,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro private protocol GutenbergEditorControllerDelegate: AnyObject { func controller(_ controller: GutenbergEditorController, didReceiveMessage message: EditorJSMessage) func controllerDidRequestLatestContent(_ controller: GutenbergEditorController) -> (title: String, content: String)? + func controllerWebContentProcessDidTerminate(_ controller: GutenbergEditorController) } /// Hiding the conformances, and breaking retain cycles. @@ -786,6 +791,13 @@ private final class GutenbergEditorController: NSObject, WKNavigationDelegate, W return .allow } + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + NSLog("webViewWebContentProcessDidTerminate: reloading editor") + MainActor.assumeIsolated { + delegate?.controllerWebContentProcessDidTerminate(self) + } + } + // MARK: - WKScriptMessageHandler func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {