Skip to content

Site menu custom post type rows disappear when the app creates an application password #26126

Open

Description

@jkmassel

Found while reviewing #26094. Introduced by that PR — trunk is not affected.

On a WordPress.com-connected site, the custom post type rows that #26094 adds to the site menu disappear when the app creates the site's application password. They stay gone until the next metadata sync: a pull to refresh, a relaunch, or a viewWillAppear on Wi-Fi.

Steps to reproduce

  1. Use a Jetpack-connected self-hosted site that has at least one custom post type the app lists (viewable, shown in the UI, with editor support). We enabled Jetpack's Portfolio content type, which the app shows as "Projects".
  2. Use an iPad simulator or device whose keychain holds no application password for that site. A fresh simulator is the reliable way.
  3. Install a build of List custom post types directly in the site menu #26094 (4ac933bd3d) and sign in with Continue With WordPress.com — not the site address, which puts the site on direct access from sign-in.
  4. Select the site and leave it on Home.
  5. Watch the sidebar.

Expected: "Projects" appears under Comments and stays.

Actual: "Projects" appears after the first sync, then disappears when the application password is created.

Screenshots

#26094 — the row is listed at 5:35 PM and gone at 5:37 PM:

On #26094, Projects is listed at 5:35 PM and gone at 5:37 PM

trunk — the same steps leave "Projects" and "More" in place:

On trunk, Projects and More are unchanged after the application password is created

Timeline

From temporary NSLog lines in the view model, BlogService.syncPostTypes(for:) and ApplicationPasswordRepository:

Time Event
17:34:52.134 Site selected. The menu's observation starts on the proxy transport
17:34:52.409 Post types sync starts on the proxy
17:35:16.682 Sync finishes (24 s). The menu reads ["jetpack-portfolio"] and the row appears
17:37:40.230 createPassword finishes, 2 min 48 s after the site was selected, and posts applicationPasswordUpdated
17:37:40.254 The menu's observation restarts on the direct transport
17:37:40.260 The menu reads [] and removes the row
17:40:13.299 No sync in between (2 min 33 s). A manual pull to refresh starts one on direct
17:40:20.975 The menu reads ["jetpack-portfolio"] and the row is back

Root cause

The menu reads custom post types from the wordpress-rs cache, and the cache keeps a separate record of a site for each transport: by site ID through the WordPress.com proxy, by site URL when the site is addressed directly.

let siteInfo: SiteInfo
switch site.transport {
case let .direct(credentials):
siteInfo = .selfHosted(siteUrl: try! ParsedUrl.from(url: site.siteURL), apiRoot: credentials.apiRootURL)
case let .dotComProxy(siteId, _):
siteInfo = .wordPressCom(siteId: WpComSiteId(siteId))
}

https://github.com/Automattic/wordpress-rs/blob/68a8ab594578f2b3e058460e5a944176af20399b/wp_mobile/src/service/mod.rs#L96-L107

BlogDetailsTableViewModel already knows the record changes, and restarts its observation on applicationPasswordUpdated:

// A new application password can change how the site is reached, and the cache keys a
// site's data by its transport: a WordPress.com Atomic site moves from its site ID to its
// URL. A source built before the change keeps reading data that no sync updates anymore.
let passwordUpdates = notificationCenter.publisher(for: SelfHostedSiteAuthenticator.applicationPasswordUpdated)
credentialChangeObservation =
passwordUpdates
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.restartCustomPostTypesObservation()
}
restartCustomPostTypesObservation()

The restart lands on a record that has never been synced. The only sync at site open was built before the password existed, so it wrote to the proxy record:

public func syncPostTypes(for blogId: TaggedManagedObjectID<Blog>) async throws {
let service = try await self.coreDataStack.performQuery { context in
let blog = try context.existingObject(with: blogId)
return CustomPostTypeService(blog: blog)
}
guard let service else { return }
try await service.refresh()
}

The first read after the restart is therefore empty, and it is applied as-is:

for await _ in signals {
guard !Task.isCancelled else { return }
do {
let types = try await service.customTypes()
guard !Task.isCancelled, let self else { return }
self.applyCustomPostTypes(types)
} catch {
Loggers.app.error("Failed to read custom post types: \(error)")
}
}
}
}
func applyCustomPostTypes(_ types: [PostTypeDetailsWithEditContext]) {
guard types != customPostTypes else { return }
customPostTypes = types
configureTableViewData()
reloadTableViewPreservingSelection()
}

An empty, never-synced record is indistinguishable from a site with no custom post types, and nothing syncs post types for the new transport.

On iPad this is the default flow. The sidebar keeps the menu on screen while the Home pane's MySiteViewController creates the password:

Task { [blogID = TaggedManagedObjectID(blog)] in
do {
try await ApplicationPasswordRepository.shared.createPasswordIfNeeded(for: blogID)
} catch {
Loggers.app.error("Failed to create an application password: \(error)")
}
}

Why trunk is not affected

On trunk the top-level rows are pins stored in UserDefaults, written by the first sync's auto-pin, so they do not depend on which cache record is current. The "More" row comes from a CustomPostTypeService built after each sync, for the transport at that time:

private func updateTableView() async {
await withCheckedContinuation { continuation in
blogService.syncBlogAndAllMetadata(blog) {
continuation.resume()
}
}
if let service = CustomPostTypeService(blog: blog) {
tableViewModel?.hasCustomPostTypes = (try? await service.customTypes())?.isEmpty == false
} else {
tableViewModel?.hasCustomPostTypes = false
}
configureTableViewData()
reloadTableViewPreservingSelection()
}

The same steps on trunk (6a05436650) keep "Projects" and "More". The password was created at 22:57:43 and the sidebar was unchanged afterwards.

Impact

  • Every Jetpack-connected site, the first time it is opened on a device with no stored application password for it.
  • List custom post types directly in the site menu #26094 removes the "More" screen that listed every custom post type, so while the rows are gone there is no other route to them.
  • On iPad the rows stay gone until a manual pull to refresh or a relaunch, because the sidebar does not get another viewWillAppear. viewWillAppear only syncs on Wi-Fi in any case:

private func preloadBlogData() {
// only preload on wifi
guard ReachabilityUtils.isReachableViaWiFi() else {
return
}
preloadComments()
preloadMetadata()
preloadDomains()
}

Sites signed in by site address are on direct access from sign-in and are not affected. Confirmed on a self-hosted site without Jetpack: the menu and the sync were both on direct from the first read, and the row never changed.

Verification

Jetpack app, Debug builds of 4ac933bd3d (#26094) and 6a05436650 (its parent on trunk), iPad Pro 13-inch (M5) simulator, iOS 27.0, Xcode 27.0.

We have not run this on:

  • iPhone. There the menu is a separate BlogDetailsViewController pushed from the dashboard's "More" action, so the row should only disappear if the menu is opened before the password is created. That took 2 min 48 s in one run and 67 s in another.
  • Atomic sites. The transport logic is the same; we only ran a Jetpack-connected self-hosted site.

Suggested fix

Treat a change of transport as "sync needed", not only "restart":

Related

Activity

  1. dangermattic commented on Oct 12, 2026

    @dangermattic
    Collaborator

    Thanks for reporting! 👍

  2. added
    [Status] Close CandidateTriage suggests this can be closed; see the latest comment for why and what's left.
    [Status] Needs TestingNeeds to be confirmed on a device or simulator.
    on Oct 31, 2026
  3. jkmassel commented on Oct 31, 2026

    @jkmassel
    ContributorAuthor

    This is likely fixed before #26094 merged. The bug was found on that PR's pre-merge head (4ac933b). Its squash commit (a4ae461) includes "Sync custom post types when the site menu's cache record changes". Now, when the app creates an application password, the menu syncs the new record first and keeps the existing rows until that finishes (BlogDetailsTableViewModel updateCustomPostTypesObservationIfNeeded, then startCustomPostTypesObservation(syncingFirst: true)).

    To verify: repeat the original steps on trunk and check that the custom post type rows stay visible.


    Generated by Claude Code

  4. added
    Simulator TestableCheckable on a simulator. Steps are in the issue body or the comment headed 'Simulator test plan'.
    on Oct 31, 2026
  5. jkmassel commented on Oct 31, 2026

    @jkmassel
    ContributorAuthor

    Simulator test plan. Sign in using docs/simulator-sign-in.md. Build from current trunk.

    Setup: follow the steps in the description, but install current trunk instead of #26094's pre-merge build 4ac933bd3d. That means an iPad simulator with a fresh keychain, a Jetpack-connected self-hosted site with Portfolio ("Projects") enabled, and sign-in with Continue With WordPress.com.

    Pass: "Projects" appears under Comments in the sidebar and stays there after the app creates the application password. Watch for at least 3 minutes.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    My SitePosts + PagesSimulator TestableCheckable on a simulator. Steps are in the issue body or the comment headed 'Simulator test plan'.[Status] Close CandidateTriage suggests this can be closed; see the latest comment for why and what's left.[Status] Needs TestingNeeds to be confirmed on a device or simulator.[Type] Bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions