Skip to content

fix(ios): let the cache policy refresh plugin and theme assets - #742

Draft
jkmassel wants to merge 4 commits into
fix/own-media-delegate-stronglyfrom
jkmassel/asset-bundle-cache-policy
Draft

jkmassel wants to merge 4 commits into
fix/own-media-delegate-stronglyfrom
jkmassel/asset-bundle-cache-policy

Conversation

@jkmassel

@jkmassel jkmassel commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

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. .maxAge and .ignore refreshed API responses but never plugin or theme assets, so the only way to pick up a plugin or theme change was purge() — which deletes everything first and forces a cold load for the next editor.

The policy now decides when to check the site's editor-assets manifest again:

Policy Before After
.always (default) Manifest checked only when no bundle is on disk Unchanged
.maxAge(t) Same as .always Manifest checked once the last check is older than t
.ignore Same as .always Manifest checked on every prepare()

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

  • Check the policy before using a bundle on disk. EditorService.prepareAssetBundle goes through the new public EditorAssetLibrary.readLatestAssetBundle(), which returns the latest bundle only while the policy trusts it, instead of readAssetBundles().first.
  • Keep the bundle when the manifest hasn't changed. 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.
  • Record the check in the bundle, beside its download date. A check stamps a new optional lastCheckedDate in the bundle's manifest.json and leaves downloadDate alone. 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.
  • Ask the site afresh for a check. Under .maxAge and .ignore the manifest request uses .reloadIgnoringLocalCacheData, so neither URLCache nor a request already in flight can answer it. Under .always the manifest is only requested when there's no bundle to use, and still joins a request in flight.
  • Stop 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.
  • Deprecate downloadAssetBundle(cachePolicy:progress:), whose cachePolicy was never used, in favor of downloadAssetBundle(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.

  • Build the bundle again if it's deleted mid-check. Any service for the site can run cleanup() or purge() between a check finding the bundle and marking it as the latest — including the service doing the check, whose prepare() runs its daily cleanup alongside. The bundle is looked for again before it's marked, under a lock shared with cleanup() and purge(), and built again if it's gone. Without that, the mark recreated the directory holding only manifest.json, and the editor opened without plugin or theme assets, with no error.
  • Keep the bundles the app may still be reading. cleanup() removed every bundle but the latest, including one that an open editor, or EditorDependencies the host prepared earlier, still pointed at. It now keeps any bundle handed out since launch; those go in a cleanup after the next launch.
  • Leave a checked bundle equal to the copy a host holds. EditorAssetBundle's equality ignores lastCheckedDate. 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/var from a listing and /var from a build, and compared unequal.

Smaller fixes a refresh exposes

  • Stop progress passing its total. The bundle download reports a running total, and EditorService added 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.
  • Give each site its own daily cleanup. One UserDefaults key covered every site, so whichever was prepared first each day used the turn, and the others' superseded bundles stayed.
  • Stop JSON.description raising an exception. It handed the Swift enum to JSONSerialization, which raises an Objective-C exception Swift can't catch. Describing a JSON, or an EditorSettings or EditorDependencies holding one — in a log message, the debugger, or a failing test expectation — ended the process. It now encodes with JSONEncoder.

Documentation

A new "Refreshing" section in docs/code/preloading.md, updates to its cleanup and network-fallback sections, and corrected doc comments on EditorService.init, EditorAssetLibrary.init, EditorCachePolicy and NetworkFallbackMode.

Why .ignore doesn't download every asset again

.ignore means "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 .maxAge or .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 .always still 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 .maxAge or .ignore service 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 .always hosts will notice

.always is 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:

  • A service whose manifest matches a bundle another service finished building moments earlier reuses that bundle instead of building it a second time.
  • 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

  • Resetting downloadDate on 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.
  • A latest.json record beside the bundles, naming the latest bundle and when it was checked. It left manifest.json untouched 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

Test plan

  • 33 new tests, replacing 5 that only covered the policy's effect on fetchManifest(). 18 in EditorAssetLibraryTests cover readLatestAssetBundle() under each policy; downloadAssetBundle() keeping, repairing, rebuilding and restoring a bundle; the manifest request's cache policy; a bundle stored without lastCheckedDate; and cleanup(). 11 in EditorServiceTests drive prepare() end to end across services sharing one site's storage, online and off. 4 in JSONTests cover description.
  • Each behavior fails its tests when reverted. A few of the signatures: a bundle deleted mid-check comes back with getEditorRepresentation() failing and its asset missing; cleanup() ignoring handed-out bundles leaves held dependencies unreadable; a failed refresh under .automatic returns assetCount == 0; progress ends at 175 against a total of 100; and the JSON.description tests end the test process with NSInvalidArgumentException: Invalid top-level type in JSON write.
  • Host swift test: 642 + 396 tests pass. EditorAssetLibraryTests, EditorServiceTests and EditorAssetBundleTests (110 tests) pass 25 consecutive runs.
  • make lint-ios and Prettier are clean.
  • GutenbergKit-Package tests 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.

  • Tap Prepare Editor, then Prepare Editor Ignoring Cache: there's still one bundle directory, and its manifest.json now has a lastCheckedDate while its downloadDate is unchanged. Before this PR, the file wasn't touched.
  • In wp-admin, activate a plugin that registers blocks — any except Jetpack, which serves the manifest — then tap Prepare Editor Ignoring Cache: a second bundle directory appears. Before this PR, there was still one.
  • Open the editor: the new plugin's blocks are in the inserter.
  • Stop the local site (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.

@github-actions github-actions Bot added the [Type] Bug An existing feature does not function as intended label Sep 28, 2026
@jkmassel jkmassel added the iOS label Sep 28, 2026
@jkmassel jkmassel self-assigned this Sep 28, 2026
@wpmobilebot

wpmobilebot commented Sep 28, 2026 •

Copy link
Copy Markdown

XCFramework Build

This PR's XCFramework is available for testing. Add the following to your Package.swift:

.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
jkmassel force-pushed the jkmassel/asset-bundle-cache-policy branch from 7026c34 to e1357a2 Compare October 2, 2026 02:52
@jkmassel
jkmassel force-pushed the jkmassel/asset-bundle-cache-policy branch from e1357a2 to f44aaa0 Compare October 2, 2026 03:04
@jkmassel
jkmassel changed the base branch from jkmassel/dependency-fetch-cancelled to jkmassel/dependency-fetch-sharing-reland October 2, 2026 03:04
@jkmassel jkmassel closed this Oct 2, 2026
@jkmassel jkmassel reopened this Oct 2, 2026
@jkmassel
jkmassel force-pushed the jkmassel/asset-bundle-cache-policy branch from f662af1 to 9c0596c Compare October 2, 2026 04:26
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
jkmassel force-pushed the jkmassel/asset-bundle-cache-policy branch from 378d85d to 1163f84 Compare October 2, 2026 15:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

iOS [Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants