Skip to content

Conversation

@jkmassel
Copy link
Contributor

Description

This PR improves block editor load times by preloading GutenbergKit dependencies in the background.

What it does:

  • Introduces EditorDependencyManager, a singleton that manages prefetched editor dependencies in memory
  • When the user visits My Site or the Blog Dashboard, dependencies are prefetched in the background
  • When opening the editor, cached dependencies are passed directly to EditorViewController, avoiding the async loading flow and progress bar
  • Uses NSLock-based LockingHashMap for thread-safe cache access, allowing synchronous retrieval from any context (including init methods)

Why:
Previously, every time the user opened the block editor, they would see a loading progress bar while GutenbergKit fetched its dependencies. By prefetching these dependencies while the user browses My Site, we can skip the loading step entirely and open the editor immediately.

Other changes:

  • Removes unused RawBlockEditorSettingsService since GutenbergKit now manages its own dependencies internally
  • Simplifies NewGutenbergViewController by delegating dependency management to the new manager

Testing instructions

  1. Open the app and navigate to My Site for a blog
  2. Wait a moment for prefetch to complete (happens automatically in the background)
  3. Tap to create a new post or edit an existing one
  4. Expected: The editor should open immediately without showing a loading progress bar
  5. Test with both WordPress.com and self-hosted sites (it's expected that this will fail for WP.com until we have a new release of GBKit)
  6. Force quit and reopen the app, then immediately try to open the editor
  7. Expected: On first run (before cache is populated), the editor should still load very quickly but may very briefly show a progress bar

dcalhoun and others added 5 commits January 14, 2026 16:21
GutenbergKit now manages its own dependencies.
Pass dependencies upon initialization to avoid flashing the progress bar
before GutenbergKit's internal cache returns.
`EditorDependencyManager` was marked `@MainActor`, which prevented
synchronous access to cached dependencies from
`NewGutenbergViewController`'s `init` method. This caused the editor to
always show the progress bar (async flow) instead of using the fast path
with cached dependencies.

Changed `EditorDependencyManager` to use `NSLock` for thread-safe cache
access, allowing `dependencies(for:)` to be called synchronously from
any context. The prefetch method remains async but now properly guards
all cache mutations with the lock.
@dangermattic
Copy link
Collaborator

dangermattic commented Jan 20, 2026

2 Warnings
⚠️ This PR is larger than 500 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.
⚠️ This PR is assigned to the milestone 26.6. The due date for this milestone has already passed.
Please assign it to a milestone with a later deadline or check whether the release for this milestone has already been finished.

Generated by 🚫 Danger

@jkmassel jkmassel force-pushed the feat/preload-gutenberg-kit-dependencies branch from adb717f to 9dad7ce Compare January 20, 2026 20:40
@jkmassel jkmassel added the Gutenberg Editing and display of Gutenberg blocks. label Jan 20, 2026
@jkmassel jkmassel added this to the 26.6 milestone Jan 20, 2026
@wpmobilebot
Copy link
Contributor

wpmobilebot commented Jan 20, 2026

App Icon📲 You can test the changes from this Pull Request in Jetpack by scanning the QR code below to install the corresponding build.
App NameJetpack
ConfigurationRelease-Alpha
Build Number30525
VersionPR #25151
Bundle IDcom.jetpack.alpha
Commit951b273
Installation URL5hscasgd4o398
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@wpmobilebot
Copy link
Contributor

wpmobilebot commented Jan 20, 2026

App Icon📲 You can test the changes from this Pull Request in WordPress by scanning the QR code below to install the corresponding build.
App NameWordPress
ConfigurationRelease-Alpha
Build Number30525
VersionPR #25151
Bundle IDorg.wordpress.alpha
Commit951b273
Installation URL4vdiql1hvh81o
Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.

@wpmobilebot
Copy link
Contributor

wpmobilebot commented Jan 20, 2026

🤖 Build Failure Analysis

This build has failures. Claude has analyzed them - check the build annotations for details.

@dcalhoun dcalhoun changed the title Feat/preload gutenberg kit dependencies feat: Preload GutenbergKit dependencies Jan 21, 2026
@jkmassel jkmassel requested a review from dcalhoun January 21, 2026 18:09
Copy link
Member

@dcalhoun dcalhoun left a comment

Choose a reason for hiding this comment

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

Changes LGTM. 👏🏻

I left a few small inline suggestions worth considering before merging.

Testing went well for me overall. As noted in the PR description and my inline comment, editor loading fails for WPCOM Simples sites as expected with the [email protected] release. Self-hosted sites loaded as expected; displaying the progress bar initially and activity indicator for subsequent loads.

  1. Force quit and reopen the app, then immediately try to open the editor
  2. Expected: On first run (before cache is populated), the editor should still load very quickly but may very briefly show a progress bar

While I saw the progress bar for first load after switching to a site for the first time, I was unable to manually trigger it via quitting the app. Presumably the memory was retained between app quit → launch, which should be fine from a UX perspective.

.package(url: "https://github.com/wordpress-mobile/NSURL-IDN", revision: "b34794c9a3f32312e1593d4a3d120572afa0d010"),
.package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"),
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.10.1"),
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.12.0"),
Copy link
Member

Choose a reason for hiding this comment

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

it's expected that this will fail for WP.com until we have a new release of GBKit

We might upgrade to recently published 0.12.1 to receive the latest namespaces endpoint fix for WPCOM Simple sites. From my testing, the following error no longer displays with 0.12.1.

Error message Image

@dcalhoun
Copy link
Member

@claude

@claude
Copy link

claude bot commented Jan 21, 2026

Claude finished @dcalhoun's task in 3m 3s —— View job


Comprehensive Code Review Complete ✅

I've performed a detailed review of this PR across code quality, security, performance, testing, and documentation. Overall, the implementation is well-designed and addresses a real performance problem, but there are several important issues to address.


Summary Assessment

Strengths:

  • Smart architecture with clear separation of concerns
  • Thread-safe implementation using NSLock
  • Good integration points (My Site, Dashboard)
  • Proper error handling with fallback behavior
  • Tests verify EditorConfiguration logic

Critical Issues:

  • Memory leak risk (cached dependencies never expire)
  • Typo in documentation
  • Missing test coverage for the new manager
  • Potential race conditions with task cancellation

Detailed Findings

1. Code Quality & Design

✅ Strengths

  • Clean singleton pattern in EditorDependencyManager.swift:24-26 with proper initialization
  • Good separation of concerns: prefetching logic isolated from editor initialization
  • Proper use of MainActor for thread-safety in async methods
  • Clear naming and well-documented public API

⚠️ Issues Found (See Inline Comments)

I've left 5 inline comments on specific issues:

  1. Line 29 - Memory management: Cache has no expiration or size limits
  2. Line 63 - Typo: "Schdule" → "Schedule"
  3. Lines 130-131 - Potential race condition in task cancellation
  4. Line 118 - Documentation clarity needed for completion timing
  5. Lines 93-107 - Unbounded concurrent prefetch operations

2. Security

✅ No Critical Security Issues

The implementation correctly:

  • Uses the existing EditorConfiguration which already handles authentication properly
  • Doesn't introduce new network endpoints or authentication paths
  • Properly uses @unchecked Sendable with NSLock protection in LockingHashMap

ℹ️ Note on Authentication

EditorConfiguration+Blog.swift:22-30 properly handles both Bearer tokens and Basic authentication with application passwords. Good security practice.


3. Performance

✅ Excellent Performance Improvements

This PR achieves its goal of eliminating the loading progress bar by:

  1. WebKit warmup (MySiteViewController.swift:359) - pre-compiles HTML/JS
  2. Data prefetch (MySiteViewController.swift:363) - pre-fetches settings/assets
  3. Synchronous cache retrieval (NewGutenbergViewController.swift:142) - no async wait in init

The approach is sound and should deliver the promised performance improvement.


4. Testing

⚠️ Insufficient Test Coverage

High Priority: No Tests for EditorDependencyManager

The core new component (193 lines) has zero test coverage. Critical scenarios that need testing:

  1. Thread safety: Concurrent prefetch calls from different threads
  2. Cache hit/miss: Verify cached dependencies are returned correctly
  3. Invalidation: Ensure invalidate() properly clears cache and cancels tasks
  4. Error handling: Verify fallback behavior when prefetch fails
  5. Deduplication: Verify duplicate prefetch calls don't create duplicate work

Recommendation: Add a test suite covering at least the happy path and invalidation scenarios.

✅ Existing Tests Updated Correctly

EditorConfigurationTests.swift was properly updated to match the new EditorConfiguration API.


5. Documentation

✅ Good Documentation

  • Clear class-level documentation with usage examples (EditorDependencyManager.swift:5-22)
  • Method-level documentation explains behavior and parameters
  • Inline comments explain key decisions (e.g., line 98: "Prefetch failed - editor will fall back to async loading")

ℹ️ Note on GutenbergKit Version

Per reviewer dcalhoun's suggestion, consider upgrading to GutenbergKit 0.12.1 to fix the WPCOM Simple sites issue mentioned in the PR description.


Recommendations

Must Fix Before Merge:

  1. Fix typo: "Schdule" → "Schedule" (line 63) - Already suggested inline
  2. ⚠️ Add cache expiration/TTL mechanism to prevent unbounded memory growth - See inline comment
  3. ⚠️ Add test coverage for EditorDependencyManager (at least basic happy path + invalidation)

Should Fix:

  1. Consider atomic task cancellation + removal to prevent race conditions - See inline comment
  2. Clarify documentation for invalidate(for:completion:) about when completion runs - See inline comment
  3. Consider upgrading GutenbergKit to 0.12.1 per dcalhoun's suggestion

Nice to Have:

  1. Add metrics/logging for cache hit rate to validate performance improvement in production
  2. Consider limiting concurrent prefetch operations - See inline comment

Final Verdict

This is a well-architected performance improvement that solves a real user pain point. The core design is solid with good error handling and fallback behavior.

However, the missing test coverage and potential memory leak from unbounded cache growth are significant concerns that should be addressed before merging. The typo is trivial to fix.

The reviewer (dcalhoun) has already approved with minor suggestions, and my findings align with their feedback. Once the critical issues above are addressed, this will be a strong addition to the codebase.


static let shared = EditorDependencyManager()

/// Cached dependencies keyed by blog's ObjectID string representation.
private let cache = LockingHashMap<EditorDependencies>()
Copy link

Choose a reason for hiding this comment

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

Memory Management Concern: This cache has no expiration mechanism or size limits. As users switch between sites, EditorDependencies objects will accumulate indefinitely in memory.

Recommendation: Consider adding a TTL (time-to-live) mechanism or LRU eviction policy. For example:

private struct CachedDependencies {
    let dependencies: EditorDependencies
    let cachedAt: Date
}

func dependencies(for blog: Blog) -> EditorDependencies? {
    guard let cached = cache[key],
          Date().timeIntervalSince(cached.cachedAt) < 3600 else { // 1 hour TTL
        cache.removeValue(forKey: key)
        return nil
    }
    return cached.dependencies
}

Alternatively, implement an LRU cache with a maximum size (e.g., 5-10 entries).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm going to deliberately ignore this – the vast majority of users have 1-2 sites and each cache entry is pretty small – even with dozens of entries the memory usage is only a few MB.

Comment on lines 130 to 131
cache.removeValue(forKey: key)
prefetchTasks[key]?.cancel()
Copy link

Choose a reason for hiding this comment

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

Potential Race Condition: There's a small window between canceling the task and removing it from the map where another thread could call _prefetchDependencies and start a new task that might get orphaned.

Suggestion: Consider using a single atomic operation or wrapping in a lock:

prefetchTasks.withLock { tasks in
    tasks[key]?.cancel()
    tasks.removeValue(forKey: key)
}

Note: This would require extending LockingHashMap to support such operations.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 9b6e92b

///
/// - Parameter blog: The blog to invalidate cache for.
@MainActor
func invalidate(for blog: Blog, completion: @escaping () -> Void) {
Copy link

Choose a reason for hiding this comment

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

Documentation Clarity: The comment says "completion is guaranteed to run on the main actor" but doesn't clarify that the completion runs before the async purge() operation completes (based on line 140 where completion is called, then the Task continues).

If this is intentional, consider clarifying:

/// `completion` is guaranteed to run on the main actor and will be called
/// immediately after clearing the in-memory cache, but before the async purge
/// operation completes.

If completion should wait for purge, move it after the purge:

do {
    try await EditorService(configuration: configuration).purge()
} catch {
    DDLogError("EditorDependencyManager: Failed to clear cache: \(error)")
}
completion()  // Move here
self.invalidationTasks[key] = nil

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The code suggested by Claude is exactly how this already works 🤨

I'm going to ignore this as a false positive.

Copy link
Member

Choose a reason for hiding this comment

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

Mmm. Yeah, I agree we should ignore. This is an odd suggestion.

Comment on lines 93 to 107
let task = Task {
do {
let dependencies = try await service.prepare(progress: { _ in })
self.cache[key] = dependencies
} catch {
// Prefetch failed - editor will fall back to async loading
DDLogError("EditorDependencyManager: Failed to prefetch dependencies: \(error)")
}

self.prefetchTasks.removeValue(forKey: key)
}

prefetchTasks[key] = task

return task
Copy link

Choose a reason for hiding this comment

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

Performance Consideration: If users rapidly switch between many sites, this could create numerous concurrent network prefetch operations without bounds. Each Task runs independently and fetches over the network.

Suggestion: Consider one of:

  1. Limit concurrent prefetches (e.g., max 3 at a time)
  2. Cancel the oldest prefetch when starting a new one
  3. Use a queue-based approach

Example with limit:

private let maxConcurrentPrefetches = 3

if prefetchTasks.count >= maxConcurrentPrefetches {
    // Cancel oldest prefetch or return early
    return nil
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same note here about user site count. This is unlikely to matter in practice.

@jkmassel
Copy link
Contributor Author

Added GBKit 0.12.1 update in 951b273, now setting auto-merge.

@jkmassel jkmassel enabled auto-merge January 21, 2026 21:11
@sonarqubecloud
Copy link

@jkmassel jkmassel added this pull request to the merge queue Jan 21, 2026
Merged via the queue into trunk with commit 75ffabb Jan 21, 2026
29 of 35 checks passed
@jkmassel jkmassel deleted the feat/preload-gutenberg-kit-dependencies branch January 21, 2026 21:44
@dcalhoun dcalhoun restored the feat/preload-gutenberg-kit-dependencies branch January 22, 2026 15:52
@dcalhoun dcalhoun deleted the feat/preload-gutenberg-kit-dependencies branch January 22, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Gutenberg Editing and display of Gutenberg blocks.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants