Conversation
XCFramework BuildThis PR's XCFramework is available for testing. Add the following to your .package(url: "https://github.com/wordpress-mobile/GutenbergKit", branch: "pr-build/742")Built from 1163f84 |
Base automatically changed from
jkmassel/dependency-fetch-sharing
to
jkmassel/dependency-fetch-cancelled
October 2, 2026 01:27
jkmassel
force-pushed
the
jkmassel/asset-bundle-cache-policy
branch
from
October 2, 2026 02:52
7026c34 to
e1357a2
Compare
2 tasks done
jkmassel
force-pushed
the
jkmassel/asset-bundle-cache-policy
branch
from
October 2, 2026 03:04
e1357a2 to
f44aaa0
Compare
jkmassel
changed the base branch from
jkmassel/dependency-fetch-cancelled
to
jkmassel/dependency-fetch-sharing-reland
October 2, 2026 03:04
This was referenced Oct 2, 2026
jkmassel
force-pushed
the
jkmassel/asset-bundle-cache-policy
branch
from
October 2, 2026 04:26
f662af1 to
9c0596c
Compare
Base automatically changed from
jkmassel/dependency-fetch-sharing-reland
to
fix/own-media-delegate-strongly
October 2, 2026 15:12
`EditorService`'s cache policy was documented to cover asset manifests, but `prepareAssetBundle` returned the newest bundle on disk before the policy was ever consulted. `.maxAge` and `.ignore` refreshed API responses and never assets, so the only way to pick up a plugin or theme change was `purge()`, which forces a cold load. The policy now decides when to check the site's manifest again. An unchanged manifest keeps the bundle already on disk and resets its age rather than downloading every asset again: asset URLs carry their version, so the same manifest means the same assets. A changed manifest builds the new bundle beside the old one, which `cleanup()` removes later. `.always`, the default and what WordPress-iOS uses, behaves as before. The check for an existing bundle runs inside the shared build for its directory, so marking a bundle current doesn't race a build of the same bundle replacing it. `fetchManifest()` no longer consults the policy: once the manifest is fetched, a bundle with the same checksum was built from exactly that manifest, so reusing its parsed copy is always right. `downloadAssetBundle(cachePolicy:progress:)` never used `cachePolicy`. It's now a deprecated overload of `downloadAssetBundle(progress:)`.
`JSON.description` handed the enum itself to `JSONSerialization`, which takes Foundation objects and raises an Objective-C exception for anything else. Swift can't catch one, so describing a `JSON` — or anything holding one, like an `EditorSettings` or `EditorDependencies` — ended the process. Nothing in the library describes one, but a log message, a `po` in the debugger, or a failing test expectation does. It now encodes with `JSONEncoder`, which the type already supports, and writes a number JSON can't represent as a string rather than failing.
A refresh could return a bundle that was no longer on disk. The check for an existing bundle and the write marking it current were separated by a progress report, and a `cleanup()` or `purge()` landing in between left `markCurrent` recreating the directory with only `manifest.json`. The editor then opened without plugin or theme assets, and said nothing. The bundle is now looked for again before it's marked, under a lock shared with `cleanup()` and `purge()`, and built again if it's gone. `cleanup()` keeps every bundle handed out since launch. A refresh makes a second bundle on disk routine, and dependencies a host prepared earlier, or an open editor, may still be reading the first. Those go in a cleanup after the next launch. A check now stamps `lastCheckedDate` in the bundle's manifest rather than resetting `downloadDate`, and bundle equality ignores it, so a check leaves a bundle equal to the copy a host already holds. Bundles are ordered, and aged for the cache policy, by when the manifest last matched them: the last check, or the download for a bundle never checked. `readAssetBundles()` also built each bundle's path from the directory listing, which resolves symlinks. On macOS, where the tests run, it returned a bundle under `/private/var` where a build returned the same bundle under `/var`, and the two compared unequal. It now builds the path from the storage root, as a build does. With automatic network fallback, a `.maxAge` or `.ignore` service that can't reach the site returns what's on disk, however old, before it falls back to empty dependencies. The documented refresh recipe hands its result to the next editor, which would otherwise have opened with nothing while everything it needed was on disk. Also: - A check downloads the assets an earlier build of the bundle failed to, rather than marking a bundle with gaps as fresh. - The manifest request for a check asks for a fresh answer, so neither a stored response nor a request in flight can stand in for it. `.always` still shares a request in flight. - `readLatestAssetBundle()` is public, since it's what applies the library's cache policy. - Automatic cleanup takes its daily turn per site. One key covered every site, so whichever was prepared first each day used it. - Progress no longer passes its total. The bundle download reports a running total, which was added in full on every report. Each fix has a test that fails without it.
…therwise `.ignore` checked the manifest every time, but kept the bundle on disk when the manifest hadn't changed. An asset that changed without its URL changing, or one that was stored wrong, could then only be replaced by a `purge()`, and the policy that takes nothing cached as valid still trusted every cached asset. It now downloads every asset, whether or not the manifest has changed. What it downloads goes into a new bundle, in a directory of its own. A bundle on disk is never changed, because an editor may be reading it. So one manifest can now have more than one bundle: a lookup by checksum returns the one the manifest last matched, and the library takes a bundle's location from the bundle rather than from its checksum. A build under `.ignore` doesn't join a build in flight either, since no other build writes its directory. Two things keep that from costing more than it should: - If the manifest hasn't changed and every asset comes back identical to the bundle on disk, that bundle is returned and the new one is discarded. A host comparing dependencies sees no change, and a refresh that changed nothing takes no more disk space. - An asset that fails to download is copied from the latest bundle on disk, if that has it, so a refresh over a poor connection doesn't leave a gap the last bundle didn't have. Under `.always` and `.maxAge`, a changed manifest downloaded every asset again. Its bundle now takes each asset whose URL is the same as in the latest bundle on disk from that bundle, and downloads the rest. Only a URL with a `ver` is carried. One without can change without its URL saying so, and carrying it would leave only `.ignore` to download it again. `.maxAge(0)` is now the policy for checking a site and downloading only what changed, and the tests of a check that keeps its bundle move from `.ignore` to it. A build with nothing to download now reports progress once.
jkmassel
force-pushed
the
jkmassel/asset-bundle-cache-policy
branch
from
October 2, 2026 15:14
378d85d to
1163f84
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
EditorService(cachePolicy:)is documented to apply to asset manifests, but it never did: once any asset bundle was on disk,prepare()returned the newest one without checking the site's manifest, whatever the policy..maxAgeand.ignorerefreshed API responses but never plugin or theme assets, so the only way to pick up a plugin or theme change waspurge()— which deletes everything first and forces a cold load for the next editor.The policy now decides when to check the site's
editor-assetsmanifest again:.always(default).maxAge(t).alwayst.ignore.alwaysprepare()A host can refresh a site's editor data — on pull-to-refresh, say — with
EditorService(configuration:, cachePolicy: .ignore).prepare(), and an editor opened meanwhile still loads straight from what's on disk.Stacked on #752, which re-lands #701.
Changes
The policy applies to assets
EditorService.prepareAssetBundlegoes through the new publicEditorAssetLibrary.readLatestAssetBundle(), which returns the latest bundle only while the policy trusts it, instead ofreadAssetBundles().first.buildBundle(for:)reuses a complete bundle already on disk for the same manifest checksum rather than downloading every asset again. It downloads only the assets an earlier build of that bundle failed to: a build publishes without them, and an unchanged manifest would otherwise never give them another try.lastCheckedDatein the bundle'smanifest.jsonand leavesdownloadDatealone. Bundles are ordered, and aged for the policy, by their last check — or by their download, for a bundle never checked, which includes every bundle stored before this change. Download date alone gets a site that goes back to an earlier manifest wrong, because that bundle is older than the one it replaced..maxAgeand.ignorethe manifest request uses.reloadIgnoringLocalCacheData, so neitherURLCachenor a request already in flight can answer it. Under.alwaysthe manifest is only requested when there's no bundle to use, and still joins a request in flight.fetchManifest()consulting the policy. There it only decided whether to parse a manifest again when a bundle built from it was already on disk. The checksum is a hash of the whole response, so reusing that bundle's parsed copy is always right.downloadAssetBundle(cachePolicy:progress:), whosecachePolicywas never used, in favor ofdownloadAssetBundle(progress:). Existing callers still compile.A refresh never hands back a bundle that isn't there
Until now a site only ever had one bundle on disk, so
cleanup()had nothing to delete. A refresh makes a second one routine.cleanup()orpurge()between a check finding the bundle and marking it as the latest — including the service doing the check, whoseprepare()runs its daily cleanup alongside. The bundle is looked for again before it's marked, under a lock shared withcleanup()andpurge(), and built again if it's gone. Without that, the mark recreated the directory holding onlymanifest.json, and the editor opened without plugin or theme assets, with no error.cleanup()removed every bundle but the latest, including one that an open editor, orEditorDependenciesthe host prepared earlier, still pointed at. It now keeps any bundle handed out since launch; those go in a cleanup after the next launch.EditorAssetBundle's equality ignoreslastCheckedDate.readAssetBundles()also builds each bundle's path from the storage root rather than from the directory listing, which resolves symlinks — on macOS, where the tests run, the same bundle came back under/private/varfrom a listing and/varfrom a build, and compared unequal.Smaller fixes a refresh exposes
EditorServiceadded that fraction of the bundle's weight in full on every report: with four assets,prepare()reported 175 of 100. Only the increase is counted now, and progress is held to its total.UserDefaultskey covered every site, so whichever was prepared first each day used the turn, and the others' superseded bundles stayed.JSON.descriptionraising an exception. It handed the Swift enum toJSONSerialization, which raises an Objective-C exception Swift can't catch. Describing aJSON, or anEditorSettingsorEditorDependenciesholding one — in a log message, the debugger, or a failing test expectation — ended the process. It now encodes withJSONEncoder.Documentation
A new "Refreshing" section in
docs/code/preloading.md, updates to its cleanup and network-fallback sections, and corrected doc comments onEditorService.init,EditorAssetLibrary.init,EditorCachePolicyandNetworkFallbackMode.Why
.ignoredoesn't download every asset again.ignoremeans "check the site every time", not "throw away what's on disk". WordPress puts each asset's version in its URL (?ver=), and the manifest checksum covers those URLs, so an unchanged manifest means unchanged assets. Downloading them again would make every pull-to-refresh as expensive as a first load.purge()is still the way to force a full download — for a site under development whose files change without a version bump, for instance.When the site can't be reached
Under
.maxAgeor.ignore, if the manifest can't be fetched,prepare()throws rather than returning the stale bundle. That's what the same policy already does for a stale API response. Nothing on disk is touched, so an editor that prepares its own dependencies with.alwaysstill loads the old bundle.With
networkFallbackMode = .automatic,prepare()now returns what's on disk instead of empty dependencies. That mode turns a network error into empty dependencies rather than a throw. For a refresh that was the wrong answer: the recipe above gives the result to the next editor, which would open with no settings, assets or preload data while all three were on disk. A.maxAgeor.ignoreservice now falls back to the same service under.always— whatever is on disk, however old — and returns empty dependencies only if something the editor needs was never cached. This covers API responses as well as assets.What
.alwayshosts will notice.alwaysis the default, and the only policy WordPress-iOS uses. It still checks the manifest only when no bundle is on disk. Beyond the fixes above, three things differ for it:cleanup()keeps bundles handed out since launch, and the automatic one runs once a day per site rather than once a day in all.readAssetBundles()orders by last check rather than download date. The two only differ once a site has been refreshed.What we explored
downloadDateon each check, with no second date. It made a checked bundle unequal to the copy a host already held, and lost when the bundle was downloaded.latest.jsonrecord beside the bundles, naming the latest bundle and when it was checked. It leftmanifest.jsonuntouched by a check and made the order independent of the device's clock. It also meant a second file that could name a bundle no longer on disk. The field in the manifest is simpler, at the cost of the order still depending on the clock — as it did before this PR.Not in this PR
EditorServicehas the same gap, along with the progress and cleanup problems above: Android:EditorService's cache policy never refreshes plugin and theme assets #753. The new docs section says so.prepare()calls on one instance still stop each other's progress, as fix(ios): free editors mid-fetch, and share site requests in flight #752 documents. A refresh through its own service isn't affected.Test plan
fetchManifest(). 18 inEditorAssetLibraryTestscoverreadLatestAssetBundle()under each policy;downloadAssetBundle()keeping, repairing, rebuilding and restoring a bundle; the manifest request's cache policy; a bundle stored withoutlastCheckedDate; andcleanup(). 11 inEditorServiceTestsdriveprepare()end to end across services sharing one site's storage, online and off. 4 inJSONTestscoverdescription.getEditorRepresentation()failing and its asset missing;cleanup()ignoring handed-out bundles leaves held dependencies unreadable; a failed refresh under.automaticreturnsassetCount == 0; progress ends at175against a total of100; and theJSON.descriptiontests end the test process withNSInvalidArgumentException: Invalid top-level type in JSON write.swift test: 642 + 396 tests pass.EditorAssetLibraryTests,EditorServiceTestsandEditorAssetBundleTests(110 tests) pass 25 consecutive runs.make lint-iosand Prettier are clean.GutenbergKit-Packagetests in the iOS Simulator pass in CI, on Buildkite build 3109.Manual, in the demo app on a simulator against the local site (
make wp-env-start), from the site's preparation screen. Bundles live in$(xcrun simctl get_app_container booted org.wordpress.gutenberg.development data)/Library/Application Support/GutenbergKit/<site>/, one directory per bundle, named by manifest checksum.manifest.jsonnow has alastCheckedDatewhile itsdownloadDateis unchanged. Before this PR, the file wasn't touched.make wp-env-stop), tap Prepare Editor Ignoring Cache, and open the editor: the plugin's blocks are still in the inserter. Before this PR, the refresh returned empty dependencies and the editor opened without them.