Skip to content

Commit 1bb559a

Browse files
authored
fix: Prevent multiple GutenbergKit starts (#22185)
* feat: Warmup GutenbergKit editor on site selection Optimize editor load by warming editor assets for a given site. * fix: GutenbergKit warmup configures editor asset caching * refactor: Extract common GutenbergKit editor configuration builder Mitigate cryptic configuration bugs by reducing duplication. * build: Update GutenbergKit version * refactor: Remove unnecessary comments * fix: Avoid static GutenbergKit feature values Consider the experimental feature state to ensure the correct editor is loaded. * feat: GutenbergKit warmup considers feature flag state Avoid warming the editor if the feature is not enabled. * refactor: Remove unused imports * style: Address number of returns lint warning * refactor: Address generic error lint warning * refactor: Address complexity lint warning * build: Update GutenbergKit version * refactor: Mirror previous EditorConfiguration usage Avoid unexpected regressions. * refactor: Remove redundant setHideTitle configuration This was not present in the previous implementation. It also sets the value to the default value. * test: Fix MySiteViewModelTest setup * fix: Avoid destroy WebViews shared between warmup and editor launches Integrate GutenbergKit's new warmup-centric utilities that use separate WebViews for warmups and editor launches. This avoids the warmup unexpectedly destroying an active editor WebView. * fix: Avoid unnecessary GutenbergKit starts The editor relies upon a subscription to editor settings within the FluxC store. FluxC can dispatch change events multiple times, leading to unexpected, unnecessary invocations of GutenbergKit's `start` method. This lead to odd outcomes. First, during editor setup, we dispatch an event to request the latest editor settings. FluxC broadcasts two change events: first with the cached settings, and second when the fetched settings resolve. This caused two `start` invocations when opening the editor. Second, the My Site fragment requests the latest settings as a performance optimization. When closing the editor and returning to My Site, the request resulted in a broadcast of updated settings, which attempt to start the editor while it was closing. * fix: Reset editor mounted state on destroy Align this state with the editor started state. * build: Update GutnebergKit version * refactor: Rename app context assignment for clarity * build: Update GutenbergKit version
1 parent 3bb8002 commit 1bb559a

File tree

6 files changed

+270
-46
lines changed

6 files changed

+270
-46
lines changed

WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import org.wordpress.android.viewmodel.SingleLiveEvent
5252
import javax.inject.Inject
5353
import javax.inject.Named
5454
import org.wordpress.android.ui.mysite.cards.applicationpassword.ApplicationPasswordViewModelSlice
55+
import org.wordpress.android.ui.posts.GutenbergKitWarmupHelper
5556

5657
@Suppress("LargeClass", "LongMethod", "LongParameterList")
5758
class MySiteViewModel @Inject constructor(
@@ -79,6 +80,7 @@ class MySiteViewModel @Inject constructor(
7980
private val dashboardCardsViewModelSlice: DashboardCardsViewModelSlice,
8081
private val dashboardItemsViewModelSlice: DashboardItemsViewModelSlice,
8182
private val applicationPasswordViewModelSlice: ApplicationPasswordViewModelSlice,
83+
private val gutenbergKitWarmupHelper: GutenbergKitWarmupHelper,
8284
) : ScopedViewModel(mainDispatcher) {
8385
private val _onSnackbarMessage = MutableLiveData<Event<SnackbarMessageHolder>>()
8486
private val _onNavigation = MutableLiveData<Event<SiteNavigationAction>>()
@@ -285,6 +287,7 @@ class MySiteViewModel @Inject constructor(
285287
dashboardCardsViewModelSlice.onCleared()
286288
dashboardItemsViewModelSlice.onCleared()
287289
accountDataViewModelSlice.onCleared()
290+
gutenbergKitWarmupHelper.clearWarmupState()
288291
super.onCleared()
289292
}
290293

@@ -389,6 +392,8 @@ class MySiteViewModel @Inject constructor(
389392
dashboardItemsViewModelSlice.buildItems(site)
390393
dashboardCardsViewModelSlice.clearValue()
391394
}
395+
// Trigger GutenbergView warmup for the selected site
396+
gutenbergKitWarmupHelper.warmupIfNeeded(site, viewModelScope)
392397
}
393398

394399
private fun onSitePicked(site: SiteModel) {
@@ -397,6 +402,8 @@ class MySiteViewModel @Inject constructor(
397402
dashboardItemsViewModelSlice.clearValue()
398403
dashboardCardsViewModelSlice.clearValue()
399404
dashboardCardsViewModelSlice.resetShownTracker()
405+
// Trigger GutenbergView warmup for the newly selected site
406+
gutenbergKitWarmupHelper.warmupIfNeeded(site, viewModelScope)
400407
dashboardItemsViewModelSlice.resetShownTracker()
401408
if (shouldShowDashboard(site)) {
402409
dashboardCardsViewModelSlice.buildCards(site)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package org.wordpress.android.ui.posts
2+
3+
import org.wordpress.android.util.UrlUtils
4+
import org.wordpress.gutenberg.EditorConfiguration
5+
6+
/**
7+
* Utility object for building EditorConfiguration from settings maps.
8+
* Eliminates duplication between GutenbergKitEditorFragment and GutenbergKitWarmupHelper.
9+
*/
10+
object EditorConfigurationBuilder {
11+
/**
12+
* Builds an EditorConfiguration from the provided settings map.
13+
*
14+
* @param settings The settings map containing all configuration values
15+
* @param editorSettings Optional editor settings string (null for warmup scenarios)
16+
* @return Configured EditorConfiguration instance
17+
*/
18+
fun build(
19+
settings: Map<String, Any?>,
20+
editorSettings: String? = null
21+
): EditorConfiguration {
22+
return EditorConfiguration.Builder().apply {
23+
val postId = settings.getSetting<Int>("postId")?.let { if (it == 0) -1 else it }
24+
val siteURL = settings.getSetting<String>("siteURL") ?: ""
25+
val siteApiNamespace = settings.getStringArray("siteApiNamespace")
26+
27+
// Post settings
28+
setTitle(settings.getSetting<String>("postTitle") ?: "")
29+
setContent(settings.getSetting<String>("postContent") ?: "")
30+
setPostId(postId)
31+
setPostType(settings.getSetting<String>("postType"))
32+
33+
// Site settings
34+
setSiteURL(siteURL)
35+
setSiteApiRoot(settings.getSetting<String>("siteApiRoot") ?: "")
36+
setSiteApiNamespace(siteApiNamespace)
37+
setNamespaceExcludedPaths(settings.getStringArray("namespaceExcludedPaths"))
38+
setAuthHeader(settings.getSetting<String>("authHeader") ?: "")
39+
40+
// Features
41+
setThemeStyles(settings.getSettingOrDefault("themeStyles", false))
42+
setPlugins(settings.getSettingOrDefault("plugins", false))
43+
setLocale(settings.getSetting<String>("locale") ?: "en")
44+
45+
// Editor asset caching configuration
46+
configureEditorAssetCaching(settings, siteURL, siteApiNamespace)
47+
48+
// Cookies
49+
setCookies(settings.getSetting<Map<String, String>>("cookies") ?: emptyMap())
50+
51+
// Editor settings (null for warmup scenarios)
52+
setEditorSettings(editorSettings)
53+
}.build()
54+
}
55+
56+
private fun EditorConfiguration.Builder.configureEditorAssetCaching(
57+
settings: Map<String, Any?>,
58+
siteURL: String,
59+
siteApiNamespace: Array<String>
60+
) {
61+
setEnableAssetCaching(true)
62+
63+
val siteHost = UrlUtils.getHost(siteURL)
64+
val cachedHosts = if (!siteHost.isNullOrEmpty()) {
65+
setOf("s0.wp.com", siteHost)
66+
} else {
67+
setOf("s0.wp.com")
68+
}
69+
setCachedAssetHosts(cachedHosts)
70+
71+
val firstNamespace = siteApiNamespace.firstOrNull() ?: ""
72+
val siteApiRoot = settings.getSetting<String>("siteApiRoot") ?: ""
73+
if (firstNamespace.isNotEmpty() && siteApiRoot.isNotEmpty()) {
74+
setEditorAssetsEndpoint("${siteApiRoot}wpcom/v2/${firstNamespace}editor-assets")
75+
}
76+
}
77+
78+
// Type-safe settings accessors - moved from GutenbergKitEditorFragment
79+
private inline fun <reified T> Map<String, Any?>.getSetting(key: String): T? = this[key] as? T
80+
81+
private inline fun <reified T> Map<String, Any?>.getSettingOrDefault(key: String, default: T): T =
82+
getSetting(key) ?: default
83+
84+
private fun Map<String, Any?>.getStringArray(key: String): Array<String> =
85+
getSetting<Array<String?>>(key)?.asSequence()?.filterNotNull()?.toList()?.toTypedArray() ?: emptyArray()
86+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package org.wordpress.android.ui.posts
2+
3+
import android.content.Context
4+
import kotlinx.coroutines.CoroutineDispatcher
5+
import kotlinx.coroutines.CoroutineScope
6+
import kotlinx.coroutines.launch
7+
import org.wordpress.android.fluxc.model.SiteModel
8+
import org.wordpress.android.fluxc.network.UserAgent
9+
import org.wordpress.android.fluxc.store.AccountStore
10+
import org.wordpress.android.modules.BG_THREAD
11+
import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures
12+
import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures.Feature
13+
import org.wordpress.android.util.AppLog
14+
import org.wordpress.android.util.AppLog.T
15+
import org.wordpress.android.util.PerAppLocaleManager
16+
import org.wordpress.android.util.SiteUtils
17+
import org.wordpress.android.util.config.GutenbergKitFeature
18+
import org.wordpress.android.util.config.GutenbergKitPluginsFeature
19+
import org.wordpress.gutenberg.EditorConfiguration
20+
import org.wordpress.gutenberg.GutenbergView
21+
import javax.inject.Inject
22+
import javax.inject.Named
23+
import javax.inject.Singleton
24+
25+
/**
26+
* Helper class to manage GutenbergView warmup for preloading editor assets.
27+
* This improves editor launch speed by caching WebView assets before the editor is opened.
28+
*/
29+
@Singleton
30+
class GutenbergKitWarmupHelper @Inject constructor(
31+
private val appContext: Context,
32+
private val accountStore: AccountStore,
33+
private val userAgent: UserAgent,
34+
private val perAppLocaleManager: PerAppLocaleManager,
35+
private val gutenbergKitFeature: GutenbergKitFeature,
36+
private val gutenbergKitPluginsFeature: GutenbergKitPluginsFeature,
37+
private val experimentalFeatures: ExperimentalFeatures,
38+
@Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher
39+
) {
40+
private var lastWarmedUpSiteId: Long? = null
41+
private var isWarmupInProgress = false
42+
43+
/**
44+
* Triggers warmup for the given site if not already warmed up.
45+
*
46+
* @param site The site to warm up the editor for
47+
* @param scope The coroutine scope to launch the warmup in
48+
*/
49+
fun warmupIfNeeded(site: SiteModel?, scope: CoroutineScope) {
50+
when {
51+
site == null -> {
52+
AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Skipping warmup - no site provided")
53+
}
54+
lastWarmedUpSiteId == site.siteId && !isWarmupInProgress -> {
55+
AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Already warmed up for site ${site.siteId}")
56+
}
57+
isWarmupInProgress -> {
58+
AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Warmup already in progress")
59+
}
60+
!shouldWarmupForSite(site) -> {
61+
AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Site doesn't support block editor, skipping warmup")
62+
}
63+
else -> {
64+
scope.launch(bgDispatcher) {
65+
performWarmup(site)
66+
}
67+
}
68+
}
69+
}
70+
71+
/**
72+
* Clears the warmup state when switching sites or logging out.
73+
*/
74+
fun clearWarmupState() {
75+
lastWarmedUpSiteId = null
76+
isWarmupInProgress = false
77+
AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Warmup state cleared")
78+
}
79+
80+
private fun shouldWarmupForSite(site: SiteModel): Boolean {
81+
val isGutenbergEnabled = experimentalFeatures.isEnabled(Feature.EXPERIMENTAL_BLOCK_EDITOR) ||
82+
gutenbergKitFeature.isEnabled()
83+
val isGutenbergDisabled = experimentalFeatures.isEnabled(Feature.DISABLE_EXPERIMENTAL_BLOCK_EDITOR)
84+
val isGutenbergFeatureEnabled = isGutenbergEnabled && !isGutenbergDisabled
85+
86+
if (!isGutenbergFeatureEnabled) {
87+
AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Skipping warmup - GutenbergKit features disabled")
88+
return false
89+
}
90+
91+
val shouldWarmup = SiteUtils.isBlockEditorDefaultForNewPost(site)
92+
93+
AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Site ${site.siteId} warmup decision: $shouldWarmup " +
94+
"(isBlockEditorDefault: ${SiteUtils.isBlockEditorDefaultForNewPost(site)}, " +
95+
"webEditor: ${site.webEditor})")
96+
97+
return shouldWarmup
98+
}
99+
100+
private suspend fun performWarmup(site: SiteModel) {
101+
try {
102+
isWarmupInProgress = true
103+
AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Starting warmup for site ${site.siteId}")
104+
105+
val configuration = buildWarmupConfiguration(site)
106+
107+
// Perform the warmup on the main thread as it involves WebView
108+
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
109+
GutenbergView.warmup(appContext, configuration)
110+
}
111+
112+
lastWarmedUpSiteId = site.siteId
113+
AppLog.d(T.EDITOR, "GutenbergKitWarmupHelper: Warmup completed for site ${site.siteId}")
114+
} catch (e: IllegalStateException) {
115+
AppLog.e(T.EDITOR, "GutenbergKitWarmupHelper: Warmup failed - illegal state", e)
116+
} finally {
117+
isWarmupInProgress = false
118+
}
119+
}
120+
121+
private fun buildWarmupConfiguration(site: SiteModel): EditorConfiguration {
122+
// Build the configuration using the same patterns as GutenbergKitSettingsBuilder
123+
val siteConfig = GutenbergKitSettingsBuilder.SiteConfig.fromSiteModel(site)
124+
125+
// Create minimal post config for warmup (no specific post data)
126+
val postConfig = GutenbergKitSettingsBuilder.PostConfig(
127+
remotePostId = null,
128+
isPage = false,
129+
title = "",
130+
content = ""
131+
)
132+
133+
val appConfig = GutenbergKitSettingsBuilder.AppConfig(
134+
accessToken = accountStore.accessToken,
135+
locale = perAppLocaleManager.getCurrentLocaleLanguageCode(),
136+
cookies = null, // No cookies needed for warmup
137+
accountUserId = accountStore.account.userId,
138+
accountUserName = accountStore.account.userName,
139+
userAgent = userAgent,
140+
isJetpackSsoEnabled = false // Default to false for warmup
141+
)
142+
143+
val featureConfig = GutenbergKitSettingsBuilder.FeatureConfig(
144+
isPluginsFeatureEnabled = gutenbergKitPluginsFeature.isEnabled(),
145+
isThemeStylesFeatureEnabled = experimentalFeatures.isEnabled(
146+
Feature.EXPERIMENTAL_BLOCK_EDITOR_THEME_STYLES
147+
)
148+
)
149+
150+
val settings = GutenbergKitSettingsBuilder.buildSettings(
151+
siteConfig = siteConfig,
152+
postConfig = postConfig,
153+
appConfig = appConfig,
154+
featureConfig = featureConfig
155+
)
156+
157+
return EditorConfigurationBuilder.build(settings, editorSettings = null)
158+
}
159+
}

0 commit comments

Comments
 (0)