Skip to content

Fix #1234: optimize once the client side sub sizes have arrived - #1240

Merged
Miraeld merged 5 commits into
developfrom
fix/1234-optimize-after-client-side-subsizes
Aug 18, 2026
Merged

Fix #1234: optimize once the client side sub sizes have arrived#1240
Miraeld merged 5 commits into
developfrom
fix/1234-optimize-after-client-side-subsizes

Conversation

@Miraeld

@Miraeld Miraeld commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes #1234

WordPress 7.1 can hand an upload over to the browser. The attachment is then created with no sub sizes at all, each one is sent afterwards through a new sideload endpoint, and a last request stores the complete metadata in one go.

Auto optimization ran on that first, empty metadata. Only the full size was ever optimized, every thumbnail was left untouched, and the media still reported success. Users saw "Optimize 6 missing thumbnails" on the media, or worse, on smaller images, "WELL DONE. This media file is already optimized, no further optimization is required." with no way to recover. Next-Gen images had the same gap, so the front end served WebP for the main image and untouched JPEG for every thumbnail.

Uploads made from the block editor now get every size optimized, in a single pass.

Type of change

  • New feature (non-breaking change which adds functionality).
  • Bug fix (non-breaking change which fixes an issue).
  • Enhancement (non-breaking change which improves an existing functionality).
  • Breaking change (fix or feature that would cause existing functionality to not work as before).
  • Sub-task of #(issue number)
  • Chore
  • Release

Detailed scenario

What was tested

Everything below was done by hand on a real WordPress 7.1-RC3-63235 build, in Chrome 151, with Imagify connected and auto optimization on. A small mu-plugin was used during testing to log when the metadata was generated and when auto optimization fired, and to read the optimization data back. It is not part of this PR.

Reproduced the bug first. Uploaded a 4000x3000 JPEG from the block editor on an unpatched build. The optimization data came back with full and full@imagify-webp only, while the media had six sub sizes on disk, and the media row offered "Optimize 6 missing thumbnails". Repeated with an image below 2560px: four sub sizes unoptimized, and the row read "WELL DONE. This media file is already optimized, no further optimization is required." with no recovery link at all. The log showed auto optimization firing twice, once on the empty metadata and once at the end.

Verified the fix on the same flow. Uploaded a 3800x2500 JPEG from the block editor and read the optimization data back:

Before After
Sizes optimized full only full and all 6 sub sizes
Next-Gen versions full only all 7
Times auto optimization fired 2 1
Media Library detail "Optimize 6 missing thumbnails" "Thumbnails Optimized: 7"
Overall saving reported 41% on one file 52.58% across the set

The log showed the create phase no longer triggering anything, and the finalize pass seeing every size:

gen_meta id=145 context='create' sizes=0                      <- nothing triggered
gen_meta id=145 context='update' sizes=6
>>> AUTO_OPT id=145 is_new_upload=true sizes_present=thumbnail,medium,medium_large,large,1536x1536,2048x2048

Confirmed it in the interface, not only in the data. Opened "View Details" on the media in the Media Library list view: "Thumbnails Optimized: 7", "Next-Gen generated: Yes", overall saving 52.58%, and no "Optimize N missing thumbnails" link.

Checked that ordinary uploads still work, which matters most here because the hook registration changed for every WordPress version. Uploaded through Media, Add Media File: auto optimization fired exactly once, as a new upload, with all sizes already present, and the media ended with all 5 sizes and all 5 Next-Gen versions optimized. No second pass.

Re-ran the whole upload test after the last code change. The final commit tightened the input guards on the new hook, so the manual upload test was repeated against it rather than assumed: all 7 sizes plus all 7 Next-Gen versions optimized, one pass, flag cleared, 50.17% saving.

Automated checks on top of the manual work: 6 new unit tests (composer test-unit -- --group AutoOptimization, 8 including the 2 that already covered this class), full suite green at 490, composer run-stan clean, PHPCS clean on both changed files.

How to test

Setup:

  1. Install WordPress 7.1 and serve it over HTTPS, or on localhost. Client side processing needs a secure context, and it is enabled by default there in any browser.
  2. Activate Imagify on this branch and connect it with a valid API key.
  3. Go to Settings, Imagify. Turn "Auto optimize images on upload" on and turn "Backup original images" on. Save.
  4. Prepare two JPEGs: one wider than 2560 pixels, and one narrower, for example 1200x900.

Test the main case:

  1. Create a new post: Posts, Add New.
  2. Insert an Image block, by typing /image and pressing Enter.
  3. Upload the wide JPEG through that block. It has to be the block editor. The classic Media Library has no cross origin isolation, so WordPress falls back to server side processing there and this code path never runs.
  4. Wait for the image to appear in the block, then give the optimization about 15 seconds to finish.

Check the result:

  1. Go to Media, Library and switch to list view.
  2. Find the media and click "View Details" in its Imagify column.
  3. "Thumbnails Optimized" must match the number of sub sizes the media has, so 6 for a wide image on a default install.
  4. There must be no "Optimize N missing thumbnails" link. That link appearing means the bug is still present.
  5. "Next-Gen generated" must say Yes.

Test the case that used to be worse:

  1. Repeat steps 5 to 8 with the 1200x900 JPEG.
  2. Its row must not read "WELL DONE. This media file is already optimized, no further optimization is required.". That message with unoptimized thumbnails, and no recovery link, was the original symptom on small images.
  3. Click "View Details" and confirm "Thumbnails Optimized" matches its sub size count, so 4 on a default install.

Check nothing regressed on ordinary uploads:

  1. Go to Media, Add Media File and upload the wide JPEG there.
  2. Wait for the optimization, then open "View Details". Every size must be optimized, exactly as before this change.
  3. Confirm the media was optimized once, not twice, for example by checking that the reported original file size matches the file you uploaded rather than an already compressed version.

Affected Features & Quality Assurance Scope

  1. Auto optimization on upload, in every context, since the hooks changed for all of them. Ordinary uploads keep their existing path, and the deferred path only applies to attachments WordPress flagged as browser processed.
  2. Next-Gen generation on upload, which inherits the fix because it is part of the same pass.
  3. The Media Library Imagify column, including the "Optimize N missing thumbnails" action and the optimization statistics.
  4. Upload failure recovery through media-create-image-subsizes, which shares this class.
  5. Bulk optimization, custom folders and manual optimization do not go through this path and are untouched.

Technical description

Documentation

Two separate problems had to be solved.

The trigger fired too early. WP_REST_Attachments_Controller::create_item() generates and stores metadata with no sub sizes, then the browser sideloads them one by one, then finalize_item() stores the complete set. Imagify keyed its "generate" step off the first of those, and store_ids_to_optimize() launched from there.

There is no public way to ask WordPress whether the browser is handling an upload, so the state is taken from where WordPress itself declares it: rest_after_insert_attachment fires just before the metadata is generated and carries the request, whose generate_sub_sizes parameter is false exactly when the browser owns the sub sizes. That gets recorded in a one hour transient, because the sub sizes arrive in later requests.

While that flag is set, the create phase stores no step at all, so nothing is optimized. When the finalize request comes through with the update context, the flag is cleared and the "upload" step is restored. That step was set on add_attachment, in the request that created the attachment, and is held in memory only, so it did not outlive it. Restoring it is what makes the media count as a new upload instead of falling into the "already optimized?" branch, which would either re-optimize the full size for a second credit or bail out and leave the media untouched.

The size list was read too early. Fixing the timing alone was not enough, and this is the part worth reviewing closely. optimize() resolves the sizes to work on when it queues the job, and it reads them from the stored metadata. During wp_update_attachment_metadata the new value has not been written yet, so on the finalize pass it still saw zero sub sizes and queued the full size alone. This was found during testing: the trigger was firing at the right moment, as a new upload, and the thumbnails were still not optimized.

Ordinary uploads never hit this because WordPress writes metadata progressively as it builds sub sizes, so by the last call the rows are already there. The client side flow writes everything once, at the end.

So for this path the optimization is deferred: store_ids_to_optimize() sets its "update" step and returns without firing, and do_auto_optimization_after_meta_update(), hooked on added_post_meta and updated_post_meta, picks it up once _wp_attachment_metadata is actually stored. That method already existed for WordPress versions below 5.3, and already guards on the "update" step, so it is now registered for every version. It cannot double fire: do_auto_optimization() clears the steps as its first action, so after an immediate run the meta hooks find nothing to do.

New dependencies

None.

Risks

The added_post_meta and updated_post_meta hooks are now registered on WordPress 5.3 and above, where previously they were not. They are guarded by the "update" step, which only store_ids_to_optimize() sets and which do_auto_optimization() clears before doing anything, so an ordinary upload cannot be optimized twice. This was verified on a real upload: one pass, one set of results.

If the browser never sends its sub sizes, because the tab was closed mid upload, the flag expires after an hour and the media stays unoptimized until something else touches it. WordPress is in the same position, since the sub sizes never arrive either, and it exposes a resume path of its own for that case. Optimizing a partial media instead would be the worse outcome.

The flag is stored per attachment, only for uploads WordPress flagged as browser processed, and it is deleted as soon as the sub sizes arrive, so nothing accumulates.

maybe_store_generate_step() now takes a third parameter. It is optional and defaults to null, which is what WordPress versions before 7.1 pass, so third party code calling it directly keeps working.

Mandatory Checklist

Code validation

  • I validated all the Acceptance Criteria. If possible, provide screenshots or videos.
  • I triggered all changed lines of code at least once without new errors/warnings/notices.
  • I implemented built-in tests to cover the new/changed code.

Code style

  • I wrote a self-explanatory code about what it does.
  • I protected entry points against unexpected inputs.
  • I did not introduce unnecessary complexity.
  • Output messages (errors, notices, logs) are explicit enough for users to understand the issue and are actionnable.

Unticked items justification

Nothing unticked.

Additional Checks

  • In the case of complex code, I wrote comments to explain it.
  • When possible, I prepared ways to observe the implemented system (logs, data, etc.)
  • I added error handling logic when using functions that could throw errors (HTTP/API request, filesystem, etc.)

The flow is observable through the existing imagify_before_auto_optimization hook, whose $is_new_upload argument now reports true on the finalize pass, and through the imagify_awaiting_subsizes_<id> transient which exists between the two. No filesystem or HTTP calls were added.

@codacy-production

codacy-production Bot commented Aug 14, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 complexity · 0 duplication

Metric Results
Complexity 0
Duplication 0

View in Codacy

🟢 Coverage 63.33% diff coverage

Metric Results
Coverage variation Report missing for 0e1ea851
Diff coverage 63.33% diff coverage (50.00%)

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (0e1ea85) Report Missing Report Missing Report Missing
Head commit (738bd7e) 20373 1605 7.88%

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#1240) 30 19 63.33%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

1 Codacy didn't receive coverage data for the commit, or there was an error processing the received data. Check your integration for errors and validate that your coverage setup is correct.

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

WordPress 7.1 can hand an upload to the browser. The attachment is then created
with no sub sizes, each one is sent through the sideload endpoint afterwards, and
a final request stores the whole metadata at once.

Auto optimization ran on that first, empty metadata, so it only ever covered the
full size and every thumbnail was left untouched while the media still reported
success. The finalize pass then re-optimized the full size, spending a second API
credit, or bailed out entirely.

Wait for the request that brings the sub sizes in, treat it as the new upload it
still is, and let it run after the metadata has been stored, since the sizes to
optimize cannot be read before that write.
@Miraeld
Miraeld force-pushed the fix/1234-optimize-after-client-side-subsizes branch from bacd1fd to a497b17 Compare August 14, 2026 15:11
@Miraeld Miraeld self-assigned this Aug 14, 2026
@Honemo

Honemo commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Overall verdict: Changes requested

Nice fix for a tricky timing bug — the "defer until subsizes actually arrive" approach is the right shape, and the manual testing in the PR description is thorough. Before merging, I'd like the core assumption behind the fix nailed down, since it determines whether the bug is truly fixed or just moved.

Blocking

  1. maybe_store_generate_step() assumes a single non-create call per attachment (inc/classes/class-imagify-auto-optimization.php, ~L218-240). The transient is cleared on the first call where $context !== 'create', and that call is trusted to carry complete metadata. If WP 7.1's client-side upload flow can fire wp_update_image_subsizes() more than once per attachment (e.g. one call per sideloaded size instead of one batched call), this would optimize on a still-partial pass — reproducing the original bug. Could you confirm how many times this fires in practice, or better, key the "subsizes arrived" check off actual completeness (e.g. compare $metadata['sizes'] against the registered/expected sizes) instead of "first non-create call seen"?

  2. Test coverage doesn't exercise the real hook sequence. The new unit tests pass $context values directly and mock the transient calls, which proves the code does what it's written to do, but not that it matches WP's actual hook firing order. Given this is exactly the scenario the fix targets, an integration test that fires the real wp_generate_attachment_metadatawp_update_attachment_metadataadded_post_meta/updated_post_meta chain would give much more confidence.

  3. Docblock says $context is null before WP 7.1, but WP core has passed 'create'/'update' here since 5.3.0. Worth fixing since it suggests the "any non-create context = subsizes arrived" logic wasn't checked against WP core's actual behavior.

Non-blocking follow-ups

  • added_post_meta/updated_post_meta are now registered unconditionally for every WP version and every post type. The early return on the meta key keeps it cheap, but it's a new always-on global hook — worth a one-line note on the trade-off.
  • flag_awaiting_client_side_subsizes() sets the transient even for unsupported mime types; harmless (self-expires in 1h) but could be skipped for cleanliness.

Happy to re-review once the multi-call question is settled — that's the one thing standing between this and a solid fix.

Correct the docblock: WordPress has passed the context argument since 5.3, not
7.1. Note on the meta hooks why they are now registered for every version, and
skip flagging an attachment Imagify cannot optimize.

Register the new transient in InternalStateList so a reset and uninstall clear
it, and derive the reset test's query count from that list.

Add an integration test that fires the real hook chain rather than calling the
methods directly: it proves nothing is optimized on the create phase, and that
the pass which does run happens after the metadata is stored, so it reads the
complete set of sub sizes.
get_media_files() reads width, height and mime type for every size, so the
optimization it queues errored on the stub entries.
@Miraeld

Miraeld commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

On the multi-call question: it fires once. sideload_item() never writes attachment metadata — its only write is update_attached_file() plus provenance meta — so sideloads can't reach these hooks at all, finalize_item() applies the filter exactly once (:3400), and across ~12 client-side uploads during testing every attachment logged exactly one 'update' call; the other 'update' caller, wp_update_image_subsizes(), is the upload-failure recovery route that Imagify already intercepts at priority -5. I didn't take the completeness suggestion because it would break: a 1200x900 upload legitimately has only 4 of 6 registered sizes (large/1536/2048 are never generated that small) and the disallowed-sizes setting removes more, so we'd wait forever on small images — and if core ever did split finalize, the tail would surface through "Optimize N missing thumbnails" rather than vanish. The other three are fixed: docblock corrected to 5.3, integration test added that fires the real chain and asserts the create phase optimizes nothing while the pass that runs sees all sub sizes, a note on the now-unconditional meta hooks, the flag skipped for unsupported mime types, and the transient registered in InternalStateList.

…after-client-side-subsizes

# Conflicts:
#	Tests/Unit/classes/Tools/InternalStateList/sharedList.php
#	Tests/Unit/classes/Tools/ResetInternalState/reset.php
#	classes/Tools/InternalStateList.php
@Miraeld
Miraeld added this pull request to the merge queue Aug 18, 2026
Merged via the queue into develop with commit 831ee8a Aug 18, 2026
13 checks passed
@Miraeld
Miraeld deleted the fix/1234-optimize-after-client-side-subsizes branch August 18, 2026 00:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WP 7.1: sub sizes are never optimized because auto optimization fires before they exist

2 participants