Repository navigation
Site menu custom post type rows disappear when the app creates an application password #26126
Description
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
- 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".
- Use an iPad simulator or device whose keychain holds no application password for that site. A fresh simulator is the reliable way.
- 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. - Select the site and leave it on Home.
- 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:
trunk — the same steps leave "Projects" and "More" in place:
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.
WordPress-iOS/WordPress/Classes/Networking/WordPressClient.swift
Lines 91 to 97 in 4ac933b
| 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)) | |
| } |
BlogDetailsTableViewModel already knows the record changes, and restarts its observation on applicationPasswordUpdated:
WordPress-iOS/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift
Lines 91 to 101 in 4ac933b
| // 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:
WordPress-iOS/WordPress/Classes/Services/BlogService+Swift.swift
Lines 113 to 120 in 4ac933b
| 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:
WordPress-iOS/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsTableViewModel.swift
Lines 333 to 351 in 4ac933b
| 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:
WordPress-iOS/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController.swift
Lines 426 to 432 in 4ac933b
| 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:
WordPress-iOS/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.swift
Lines 155 to 170 in 6a05436
| 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.viewWillAppearonly syncs on Wi-Fi in any case:
WordPress-iOS/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.swift
Lines 183 to 192 in 4ac933b
| private func preloadBlogData() { | |
| // only preload on wifi | |
| guard ReachabilityUtils.isReachableViaWiFi() else { | |
| return | |
| } | |
| preloadComments() | |
| preloadMetadata() | |
| preloadDomains() | |
| } |
- A wipe followed by a restore also re-runs the selected row's action on iPad — see iPad: reloading the site menu re-runs the selected custom post type row's action #26128.
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
BlogDetailsViewControllerpushed 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":
- When the restart lands on a different transport, sync post types for it (
CustomPostTypeService.refresh()), and keep the current rows until that sync finishes instead of applying the empty first read. - Site menu custom post types go stale when the transport changes without
applicationPasswordUpdated#26127 covers the cases where the transport changes with no notification at all. Re-checking the transport after each metadata sync would fix both.
Activity
dangermattic commentedon Oct 12, 2026
Thanks for reporting! 👍
jkmassel commentedon Oct 31, 2026
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 (
BlogDetailsTableViewModelupdateCustomPostTypesObservationIfNeeded, thenstartCustomPostTypesObservation(syncingFirst: true)).To verify: repeat the original steps on trunk and check that the custom post type rows stay visible.
Generated by Claude Code
jkmassel commentedon Oct 31, 2026
Simulator test plan. Sign in using
docs/simulator-sign-in.md. Build from currenttrunk.Setup: follow the steps in the description, but install current
trunkinstead of #26094's pre-merge build4ac933bd3d. 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