Conversation
Generated by 🚫 Danger |
|
| App Name | Jetpack | |
| Configuration | Release-Alpha | |
| Build Number | 30634 | |
| Version | PR #25157 | |
| Bundle ID | com.jetpack.alpha | |
| Commit | 4bec4e1 | |
| Installation URL | 241qvovlg0m10 |
|
| App Name | WordPress | |
| Configuration | Release-Alpha | |
| Build Number | 30634 | |
| Version | PR #25157 | |
| Bundle ID | org.wordpress.alpha | |
| Commit | 4bec4e1 | |
| Installation URL | 659ahvsmoaug8 |
| import WebKit | ||
| import DesignSystem | ||
|
|
||
| class SimpleGBKViewController: UIViewController { |
There was a problem hiding this comment.
Is the editor used for custom posts? Would it work to parameterize the current editor (NewGutenbergViewController) to work with these posts?
There was a problem hiding this comment.
Yes, that'd be the next step. The SimpleGBKViewController was created to quickly test editing custom posts in GBK editor.
af4e4b9 to
014b963
Compare
kean
left a comment
There was a problem hiding this comment.
Hey, in addition to the inline comments, I have two more high-level suggestions/questions:
1. Navigation
On the web, the CPTs are added directly to the menu (under "Comments" and everything else). The "Custom Post Types" menu looks out of place. It also shouldn't be shown if you have no CPTs (default for WordPress sites). If the app does the same as the web, it solves both issues.
"Custom Post Type" is a bit of a more technical term. I don't think it should be prevalent in the UI. The user only cares to know that their type of content is available with the names they provided.
2. Post Status & Filtering
It's not clear posts in which status it shows. It's probably a good idea to match the web's UX and show "All" by default, but the list then needs to clearly communicate the status of the posts for each individual post.
The safer option is to keep the same UX as in the current post list. It's convenient that you can switch between tabs in one tab.
| public func showCustomPostTypes() { | ||
| Task { | ||
| guard let client = try? WordPressClientFactory.shared.instance(for: .init(blog: blog)), | ||
| let service = await client.service |
There was a problem hiding this comment.
Let's use locks – just like in WordPressClientFactory – to ensure thread-safety when accessing .service. It's inconvenient to use async methods when they aren't really needed, and it can lead to the same screen being pushed multiple times if you tap it multiple times.
There was a problem hiding this comment.
The WordPressClient is an actor, so we can't lock it. There is an open PR that also changes WordPressClient, so I don't want to change it too much at this stage. I'll keep a note to myself to see if it can be improved.
There was a problem hiding this comment.
You can simply make the property nonisolated without introducing too many other changes.
There was a problem hiding this comment.
It's not nonisolated, though. The service uses cache, which is expensive to create (it may create database tables) and should be created only once.
There was a problem hiding this comment.
Would it work if WordPressClient creates cache and service in its initializer, and store them in as let properties?
public final class WordPressClient: Sendable {
public let siteURL: URL
public let api: WordPressAPI
public let cache: WordPressApiCache?
public let service: WpSelfHostedService?
public init(api: WordPressAPI, siteURL: URL) {
self.api = api
self.siteURL = siteURL
self.cache = WordPressApiCache.bootstrap()
self.service = try api.createSelfHostedService(cache: cache)
}
}It will have more predictable performance as it will perform the bootstrap on init as opposed to non-deremined point in time when cache or service are needed/accessed. It'll likely once when the user doesn't need to notice anything – e.g. they are switching sites.
It looks like it's also missing error handling. It would be easier to implement if errors are handled once during init as opposed to individual property accessors.
There was a problem hiding this comment.
We don't have any control over which thread WordPressClient.init is called on, but we don't want to create WordPressApiCache on the main thread.
There was a problem hiding this comment.
If you take the current Core Data implementation, it loads the database on app launch when the app has control over it. It seems like the best place to instantiate expensive subsystems.
Just to clarify, I think it's OK to change later. I just don't think it's acceptable to put it in Task when you try a push a new screen. This seems like the least optimal option.
There was a problem hiding this comment.
Yes, I'm planning to look into removing the Task. But I do want to keep creating the cache instance out of the main thread, though. ContextManager, being legacy code and a shared instance, prevents us from keeping it out of the main thread. But since we are starting new here, I just want to try do the right thing.
|
|
||
| private enum ListItem: Identifiable, Equatable { | ||
| case ready(id: Int64, post: DisplayPost, fullPost: AnyPostWithEditContext) | ||
| case stale(id: Int64, post: DisplayPost) |
There was a problem hiding this comment.
It's not clear what these states mean and why they are exposed to the view layer (CustomPostList): stale, fetching, missing, error, ready? It doesn't seem to make much sense.
From the business logic, you either have a post or you don't. In addition the app might be in the process of sending a request to update one of the posts and it could fail – that's about it.
There was a problem hiding this comment.
These states are part of the mechanism by which the Rust library syncs posts and keeps them up to date with the server.
I reused the states in the Swift code for simplicity's sake. I will add a TODO here and address it in a separate PR.
There was a problem hiding this comment.
From the business logic, you either have a post or you don't.
Just for additional context – the underlying layer works based on post IDs instead of the entire object – this makes differential updates dramatically faster. This also allows for accurate skeleton-loading states – if there are 3 posts, we can represent them loading in the UI accurately (instead of showing a full page of posts then culling it down to 3 when they actually load, for instance).
There's a bunch of benefits to this (faster time-to-interactive with lower server load, for instance) – it's how Android has worked for many years.
There was a problem hiding this comment.
if there are 3 posts, we can represent them loading in the UI accurately (instead of showing a full page of posts then culling it down to 3 when they actually load, for instance).
It doesn't seem like a substantial benefit, and it probably won't work because you still need to show something while you are fetching the list of IDs. The app only shows it on the first sync, so it's really irrelevant how it looks.
It would be nice to have a minified model of post that has enough information to display a post in a list. There is nothing useful you can really do with an ID, but it means that app has to deal with the incidental complexity it creates.
| self.date = entity.dateGmt | ||
| self.title = entity.title?.raw | ||
| self.excerpt = entity.excerpt?.raw | ||
| ?? String((entity.content.raw ?? entity.content.rendered).prefix(excerptLimit)) |
| ForEach(items) { item in | ||
| listRow(item) | ||
| .task { | ||
| if !isLoadingMore, items.suffix(5).contains(where: { $0.id == item.id }) { |
There was a problem hiding this comment.
(nit) It couldn't probably use DataViewPaginatedForEach – doesn't have to, but I think it should work.
There was a problem hiding this comment.
I took the same load more mechanism in DataViewPaginatedForEach here. I don't want to just use it here though. Because I don't want to mix the data view loading mechanism and the one in Rust together, also the reused part is pretty simple and straightforward.
| } | ||
| } | ||
|
|
||
| private struct PostRowView: View { |
There was a problem hiding this comment.
Let's extract it to a separate file.
| } | ||
|
|
||
| @ViewBuilder | ||
| private func makeFilterMenu() -> some View { |
There was a problem hiding this comment.
(nit) It's more idiomatic to implement these as properties (filterMenu). It also doesn't need @ViewBuilder.
(nit) Passing a binding to each individual item is a bit odd: FilterMenuItem(filter: $filter. Can it be implemented as a simple picker?
Picker("", selection: viewModel.makeSortFieldBinding()) {
ForEach([SortField.dateSubscribed, .email, .name], id: \.self) { item in
Text(item.localizedTitle).tag(item)
}
}
.pickerStyle(.inline)
WordPress/Classes/ViewRelated/CustomPostTypes/CustomPostList.swift
Outdated
Show resolved
Hide resolved
| for await hook in updates { | ||
| guard let collection, collection.isRelevantUpdate(hook: hook) else { continue } | ||
|
|
||
| DDLogInfo("WpApiCache update: \(hook.action) to \(hook.table) at row \(hook.rowId)") |
There was a problem hiding this comment.
I can't say it's clear what' going on in the subscriber. I tried to debug it, but it wasn't really possible with Rust.
When you open the screen it seems to receive two "relevant" updates (what does that mean?).
WpApiCache update: update to listMetadataState at row 1
List info: Optional(WordPressAPIInternal.ListInfo(state: WordPressAPIInternal.ListState.fetchingFirstPage, errorMessage: nil, currentPage: Optional(1), totalPages: Optional(1), totalItems: Optional(1), perPage: 20))
WpApiCache update: update to listMetadataState at row 1
List info: Optional(WordPressAPIInternal.ListInfo(state: WordPressAPIInternal.ListState.idle, errorMessage: nil, currentPage: Optional(1), totalPages: Optional(1), totalItems: Optional(1), perPage: 20))For each updates, it seems to reload and re-map all the items: collection.loadItems().map(ListItem.init) and update the list with animation. Is that how it's supposed to work? Are we planning to add more granular updates? It seems like it would be inefficient for any large collection of items.
(nit) map(ListItem.init) – this happens on the main thread; possible potential performance improvement.
There was a problem hiding this comment.
It's probably easier to look into the Rust library directly to understand its syncing mechanism.
From the client's point of view, we need to do two things:
- Call
refresh()orloadNextPage()to trigger the syncing. - Observe the "updates", fetch the most updated data (
listInfo()&loadData()results), and display them on screen.
When you open the screen it seems to receive two "relevant" updates (what does that mean?).
Internally, the Rust library stores the syncing data (metadata + post data) in a database. The Rust code sends an update whenever the database is updated. My expectation is that, regardless of the internal mechanism, the client should receive updates only when it's absolutely necessary to refresh the data displayed on screen. That is not the case at the moment. But I think the Rust library should be improved to reduce the number of updates sent to the app.
For each updates, it seems to reload and re-map all the items. [...] Is that how it's supposed to work? Are we planning to add more granular updates?
By "glandular updates", do you mean like only mapping new items and appending them to the list?
There was a problem hiding this comment.
By "glandular updates", do you mean like only mapping new items and appending them to the list?
Yes, and updated to individual items. This is not strictly necessary with SwiftUI's List diff algorithm, but it might be for Android or for other components or parts of the app.
Core Data has pretty good APIs for observing changes with direct SwiftUI binding for collections (FetchRequest) and for individual items (ObservedObject) where you can dive into any level of granularity if needed. I don't know if there is a similar ORM-ish library for Rust, but I would pick something existing, stable, and documented. This is extremely hard to design and implement well.
collection.isRelevantUpdate(hook: hook)
This whole invocation seems like it should be a one-liner on the view level:
cache.databaseUpdatesPublisher()
.debounce(for: .milliseconds(50), scheduler: DispatchQueue.main)
.values
for await hook in updates {
guard let collection, collection.isRelevantUpdate(hook: hook) else { continue }Maybe something like:
for await update in cache.updates(for: collection)The fact that it needs debounce also points to something problematic. The fact that you need to call collection.loadItems() on every update also is. I seems it leaves a lot to be desired right now – too low-level.
There was a problem hiding this comment.
Yes, and updated to individual items. [...] Core Data has pretty good APIs for observing changes with direct SwiftUI binding for collections (FetchRequest) and for individual items (ObservedObject) where you can dive into any level of granularity if needed.
I think there two things in play here:
- The Rust library sends minimal updates, similar to the
NSFetchedResultsControlleras you mentioned. - The Swift observer code only map-and-update the
@State var itemsif absolutely necessary, even if the Rust library does not do the above.
I don't think we intend to make the Rust library do 1. It'd be nice for sure, but, as you mentioned, that can be complicated.
As for 2, I think we can try that in the Swift code. I'm not sure how much performance it'd gain. We can definitely try and see.
The fact that it needs debounce also points to something problematic.
Yes. It is. As I mentioned before, at the moment, the Rust library sends updates that are not necessary to update the UI. It's not too frequently, like it may send two events one immediately after another, even though only one is needed. The frequency of updates affects the list UI, but not the performance. The debounce here is to avoid UI glitches (like list flashes), rather than unblocking the main thread
There was a problem hiding this comment.
Core Data has pretty good APIs for observing changes with direct SwiftUI binding for collections (FetchRequest) and for individual items (ObservedObject) where you can dive into any level of granularity if needed. I don't know if there is a similar ORM-ish library for Rust, but I would pick something existing, stable, and documented. This is extremely hard to design and implement well.
From a high level we don't want an ORM or really any "magic" here – we update a @State var as needed and the Swift diff algorithm deals with the rest.
That said, we don't want the diff to run more often than it has to – we can probably tighten that up on the Rust side. I know that the collection is separate from the DB observer, but I wonder if the collection could be registered with the observer so we don't need to call collection.isRelevantUpdate at all?
There was a problem hiding this comment.
I wonder if the collection could be registered with the observer so we don't need to call collection.isRelevantUpdate at all?
Yep, it could. This would only be a syntactical change: wrapping the WpApiCache update in another helper layer. There is no change to how many updates are sent to the app.
BTW, I created Automattic/wordpress-rs#1128 to document two issues related to sending updates. I don't have a fix for them yet.
|
|
||
| let listInfo = collection.listInfo() | ||
|
|
||
| NSLog("List info: \(String(describing: listInfo))") |
| private struct CustomPostSearchResultView: View { | ||
| let client: WordPressClient | ||
| let service: WpSelfHostedService | ||
| let endpoint: PostEndpointType |
There was a problem hiding this comment.
(nit) There seem to be a potential to warp it in a Service or a ViewModel to reduce the amount of complexity the view needs to know about.
There was a problem hiding this comment.
I have extracted a view model from the previous file. And the code is split into multiple files, which should help with navigating the code.
|
@kean Thanks for the review. I have created a couple of issues to track your suggestions, and I'll address them in separate PRs. |
5070ed6 to
6f27cdd
Compare
|
⬆️ Rebased without any code changes. |
🤖 Build Failure AnalysisThis build has failures. Claude has analyzed them - check the build annotations for details. |
There was a problem hiding this comment.
The PR seems good enough for merge as a start, but there seems to be quite a few major challenges:
- How to incorporate CPTs into the app UX-wise in a reasonable way
- How to make Rust-based data layer work with million of different parts of the app that use Core Data (
AbstractPost) and keeping it maintainable - A bit questionable API design of the data model for posts (especially statuses) and for data observations exposed by the Rust layer
I'd also try and simplify the following, which could be made more scalable (not require many updates when you add new fields):
public var siteURL: URL {
switch self {
case let .dotCom(siteURL, _, _):
return siteURL
case let .selfHosted(_, siteURL, _, _, _):
return siteURL
}
}The enum is not the best way to model it. I'd suggest looking into alternative options, such as protocols:
protocol SiteProtocol {
var siteURL: URL
}
struct DotComSite: SiteProtocol {
// Standard + doctom-specific fields
}
struct SelfHostedSite: SiteProtocol {
/ Standard +self-hosted fields
}The implementation might still have an enum for places that need to deal separately with each type of site, but for most scenarios, you would use either SiteProtocol, polymorphism, or specific types for screens that only work with one particular type of site.
I plan to address this as part of https://linear.app/a8c/issue/CMM-1192
I'm not sure how to answer this. We definitely don't want to make any code unmaintainable. Just so that we have something specific to discuss, in the context of this PR, is there any code that you think is unmaintainable?
I actually think the new syncing mechanism in the Rust layer works pretty well, both in terms of API and how it syncs posts internally. The statuses API seems a bit noisy. But it has its benefits. For example, the app can handle different statuses differently, like proactively fetching the latest post if it's Yes, I will address the problem with the site enum in a separate PR. The tuple is getting out of hand. |
Core Data and |
I was looking for something that would help to keep the codebase maintainable short-term and avoid potential regressions when making changes to the existing code. There was an example with the tags screen being reworked in a similar way to support both Core Data and Rust, and it did lead to a couple of regressions. These types of reworks are associated with risk. |
4bec4e1 to
ca016e6
Compare
|






Description
This PR adds basic support for custom post types, which is behind a local feature flag (on by default in debug builds).
Please note that, during development, I found a few parsing issues. I suspect there are more. We'll need to try a few more plugins that register custom post types (see 1 below).
Supported features:
Plugins
Here are a few plugins that I tested. You can install them, create some test data, and view them in the app.
Testing instructions
You can use cpt.wpmt.co to view the books on the site. Please note that not all books can be loaded (see p1768466053646389-slack-C06S1QS55K9).
You can install WP Job Manager on test sites, which registers a "job listing" custom post type.