Skip to content

Fix #1235: stop overriding core's resize opt-out on client side uploads - #1239

Merged
Miraeld merged 6 commits into
developfrom
fix/1235-respect-core-resize-opt-out
Aug 18, 2026
Merged

Fix #1235: stop overriding core's resize opt-out on client side uploads#1239
Miraeld merged 6 commits into
developfrom
fix/1235-respect-core-resize-opt-out

Conversation

@Miraeld

@Miraeld Miraeld commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes #1235

WordPress 7.1 lets the browser process an upload, and when it does, it switches its own "big image" downscaling off because the browser already supplies the scaled file. Imagify's filter ignored that and always imposed its own value, so the server scaled the image a second time. Users were left with a scaled file and a Next-Gen companion that nothing referenced, and WordPress pointed original_image at that stray file instead of the real upload.

After this change the upload produces a single scaled file, sub sizes keep their normal names, and original_image points at the untouched upload again.

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, auto optimization on and "Resize larger images" on at 2560. A small mu-plugin was used during testing to log what big_image_size_threshold returned and when the metadata was written. It is not part of this PR.

Reproduced the bug first. Uploaded a 4000x3000 JPEG from the block editor on an unpatched build and confirmed the reported symptoms on disk: two scaled files (big-4000px-scaled.jpg and big-4000px-scaled-1.jpg), 673KB of them referenced by nothing, every sub size written with a -1 suffix, and original_image naming the stray file rather than the upload.

Verified the fix on the same flow. Uploaded a 3800x2500 JPEG from the block editor and inspected the uploads folder and the attachment metadata:

Before After
Scaled files produced -scaled.jpg and -scaled-1.jpg one -scaled.jpg
Unreferenced bytes left behind 673KB 0
Sub size names -1024x674-1.jpg -1024x674.jpg
original_image the stray scaled file the real upload
Original dimensions on disk 3800x2500 3800x2500

The log showed the filter standing down exactly once, during the upload, and keeping Imagify's value on all 52 other calls in the same run:

big_image_size_threshold FINAL=false   attachment_id=138   <- the client side upload
big_image_size_threshold FINAL=2560    (52 other calls)

Checked that ordinary uploads still resize. Uploaded a 4000px PNG through Media, Add Media File. It still produced big-4000px-scaled.png at 2560 with the 4000px original intact, so nothing changed for server side uploads.

Checked the premise itself, because the fix only makes sense if the browser scales to Imagify's value rather than a hardcoded one. Read bigImageSizeThreshold out of the editor settings in the console: it returned 2560, Imagify's configured value, and the file the browser produced measured 2560x1684, the same dimensions the server used to produce. Nothing is lost by standing down.

Confirmed the settings page still behaves, since the reset value for the resize width is computed by temporarily unhooking this same callback. Opened the settings page and read the rendered values back: unchanged.

Automated checks on top of the manual work: 12 new unit tests across --group ContextWP and --group ProcessWP, covering the browser upload, a false from any other source, and the resize guard both ways. Full suite green at 502, composer run-stan clean, PHPCS clean on every changed file.

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, turn "Backup original images" on, turn "Resize larger images" on and leave the width at 2560. Save.
  4. Prepare a JPEG wider than 2560 pixels, for example 3800x2500.

Reproduce the fixed behaviour:

  1. Create a new post: Posts, Add New.
  2. Insert an Image block, by typing /image and pressing Enter.
  3. Upload your 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.

Check the files:

  1. Open wp-content/uploads/<year>/<month>/ on the server.
  2. There must be exactly one -scaled file. A second one ending in -scaled-1 means the bug is still present.
  3. Sub sizes must be named <name>-1024x674.jpg and so on, with no -1 suffix before the extension.
  4. The file you uploaded must still be there at its original dimensions. Check with any image viewer that it is still 3800x2500.

Check the metadata:

  1. Read _wp_attachment_metadata for that attachment, for example with wp post meta get <ID> _wp_attachment_metadata --format=json.
  2. original_image must name the file you uploaded, not a -scaled one.

Check nothing regressed on ordinary uploads:

  1. Go to Media, Add Media File and upload the same image there.
  2. A -scaled file at 2560 must be created, and the original must be kept beside it. This confirms Imagify still resizes on the server side path.

Affected Features & Quality Assurance Scope

  1. Resizing on upload, in every context, because the filter callback is shared. The value it returns is unchanged except for the uploads WordPress 7.1 hands to the browser.
  2. The Imagify settings page, because the reset value for "Resize larger images width" is computed by temporarily unhooking this same callback. The callback name changed, so that code had to follow.
  3. Optimization of WP Media Library attachments, through the new can_resize() override.
  4. Custom folders and NextGen Gallery are untouched, since they have their own context and process classes.

Technical description

Documentation

WP_REST_Attachments_Controller::create_item() adds __return_false on big_image_size_threshold when the browser handles the sub sizes, and its own comment says why:

Disable server-side "big image" downscaling; the client supplies its own scaled version via the sideload endpoint. Scaling here would create a conflicting "-scaled" file and orphan the full-size upload.

Imagify hooked the same filter at IMAGIFY_INT_MAX with Context\WP::get_resizing_threshold(), a getter that takes no arguments and therefore could not see, let alone respect, the incoming value.

Three things changed.

A dedicated filter callback. Context\WP::filter_big_image_size_threshold() hands the threshold back untouched for an upload the browser scaled, and otherwise delegates to the existing getter, including when the false came from somewhere else. The getter keeps its old contract, so the direct callers in AbstractContext::can_resize() and AbstractProcess::maybe_resize() are unaffected, and ContextInterface does not change, which matters because CustomFolders, Noop and NGG implement it too.

Standing down loses nothing, because the threshold the browser scales to is produced by this very filter, in WP_REST_Server::get_index(). Imagify's setting is still what governs the result.

The state is taken from where WordPress declares it. rest_after_insert_attachment runs 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 is what identifies the upload, rather than the false on the filter, which says nothing about who sent it or whether a scaled file exists.

A flag, so the original is left alone. Optimization does not run inside the upload request. It is pushed onto a queue and runs later, asynchronously, by which point the filters WordPress set up during the upload are gone. Without a record of what happened, maybe_resize() would see the full size original at 3800px, compare it against the 2560 threshold and shrink the very file WordPress keeps aside as original_image. So the attachment ID is recorded in a one hour transient, and Process\WP::can_resize() declines to resize while that flag is present.

A latent bug in the options class. Imagify_Options::__construct() temporarily unhooks this callback to read WordPress's pristine value, then hooks it back with add_filter( ..., IMAGIFY_INT_MAX ) and no argument count, which silently re-registers it as accepting a single argument. That was harmless while the callback only took one. It is not harmless now: the callback stopped receiving the attachment ID, so the flag was never written. This was caught during testing, when the flag turned out to be missing even though the filter was clearly returning false. The argument count is now repeated there.

New dependencies

None.

Risks

Nothing changes outside the WordPress 7.1 browser upload. Imagify stands down only for an
attachment WordPress itself flagged, by sending generate_sub_sizes as false on the REST
request that created it. A false arriving on the filter from any other source is overridden
with Imagify's value exactly as before, because only the browser flow leaves a scaled file
behind: treating every false as "already scaled" would produce an image nobody resized, since
WordPress was told not to and Imagify would believe the work was done.

Known limitation: the flag is a one hour transient. On a site that purges transients
aggressively, or with an optimization queue backed up for more than an hour, it can be gone by
the time the queued optimization runs. The consequence is the behaviour that shipped before this
PR, the original being resized, with the untouched upload still in the backup folder. Nothing
errors, and there is no data loss.

The flag is written only for those uploads, and it expires on its own, so no cleanup routine is
needed and nothing is stored permanently. The pattern is registered in
classes/Tools/InternalStateList.php so a reset and an uninstall clear it.

One interaction is worth stating plainly. #1234 makes auto optimization run before the browser's sub sizes arrive, which means it still runs against the full size original during the upload. This PR stops that pass from resizing the original, and the original keeps its dimensions, but the file is still compressed in place, with the untouched upload preserved in the backup folder. Fixing #1234 removes that early pass entirely and the original stops being touched at all. This was verified: with both branches merged locally, the uploaded file came out byte identical to the source. Both are improvements on their own and neither depends on the other to be safe.

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 flag is observable: the imagify_client_side_scaled_<id> transient exists for the lifetime of an upload that the browser scaled. No filesystem or HTTP calls were added, so there is nothing new that can throw.

WordPress 7.1 switches its own downscaling off while the browser handles an
upload, because the browser supplies the scaled file itself. Imagify's filter
ignored the incoming value and always won, so the server produced a second
scaled file that nothing referenced and `original_image` ended up pointing at
it instead of the real upload.

Hand a false threshold straight back, and remember the attachment so the later
asynchronous optimization does not shrink the original the browser left intact.
@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 duplication

Metric Results
Duplication 0

View in Codacy

🟢 Coverage 69.57% diff coverage

Metric Results
Coverage variation Report missing for cd27ae41
Diff coverage 69.57% diff coverage (50.00%)

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (cd27ae4) Report Missing Report Missing Report Missing
Head commit (9800a9d) 20333 1556 7.65%

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 (#1239) 23 16 69.57%

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.

Check the flag before delegating to the parent, so a media the browser already
scaled is refused without the rest of the work, and the branch can be exercised
directly.
@Miraeld Miraeld self-assigned this Aug 14, 2026

@Honemo Honemo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lead Review — CHANGES REQUESTED

Nice fix overall — the root-cause writeup and the reasoning around the transient flag are clear and well tested manually. Two things to address before merge:

  • HIGHCanResizeTest doesn't actually exercise the new guard; is_valid() being stubbed false makes it pass regardless of whether the fix exists (see inline comment).
  • MEDIUM — the new imagify_client_side_scaled_{attachment_id} transient (set in Context\WP::flag_client_side_scaling()) isn't registered in classes/Tools/InternalStateList.php::get_locked_transient_patterns(). That file's own docblock asks for new transients to be added there as the single source of truth, so ResetInternalState::reset() and uninstall won't clean this one up — it'll just sit until its 1-hour TTL expires. Could you add _transient_imagify_client_side_scaled_% (and the matching _transient_timeout_ pattern) there?

Everything else checks out: PHPCS clean, full suite green (498 tests), all callers of the renamed filter callback updated consistently, and the scope correctly limited to Process\WP.

One optional thought for later: the transient is never cleared after it's read, only left to expire — worth a short comment noting that tradeoff, or a delete_transient() call if it doesn't conflict with checks across multiple sizes.

*/
$process = Mockery::mock( WP::class )->makePartial();
$process->shouldReceive( 'get_media' )->andReturn( $media );
$process->shouldReceive( 'is_valid' )->andReturn( false );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] This test always stubs is_valid() to return false, so can_resize() short-circuits before it ever reaches the new client-side-scaling guard. I confirmed this by removing the guard entirely and re-running the suite — all 3 tests still passed, so right now this file wouldn't catch a regression that deletes the fix.

Suggest stubbing is_valid() to true (plus whatever else parent::can_resize() needs to reach the real decision), then adding a "flag not set → true" case alongside the existing "flag set → false" one. That way the test actually proves the guard is doing the work.

The can_resize test satisfied everything the parent checks except the guard, so
it passed whether or not the guard existed. Every condition is now stubbed to
answer true, which makes the guard the only thing that can return false, and a
case covers the media the browser did not scale.

Register the new transient in InternalStateList so a reset and uninstall clear
it, and derive the reset test's query count from that list rather than repeating
the number. Note on the flag why it is left to expire instead of deleted.
@Miraeld

Miraeld commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Both fixed — you were right that the test was vacuous: I deleted the guard entirely and all 3 tests still passed, so it's rewritten to stub everything the parent checks as true, making the guard the only thing that can return false (verified it now fails when the guard is removed), plus a case for the media the browser didn't scale. The transient is registered in InternalStateList with its timeout companion, the reset test's query count now derives from that list rather than repeating the number, and there's a note on why the flag is left to expire: it's read once per size, so deleting it on first read would let the remaining sizes resize the file.

Treating every false on the filter as "already scaled" changed behaviour for any
site where a third party returns false for its own reasons: WordPress would skip
the downscale because it was told to, and Imagify would skip its own because it
believed the work was done, leaving an image nobody resized and no scaled file
anywhere.

Take the state from where WordPress declares it instead. rest_after_insert_attachment
runs just before the metadata is generated and carries generate_sub_sizes, which is
false exactly when the browser owns the sub sizes. A false from any other source is
overridden with Imagify's value, as it was before, so nothing changes outside the
7.1 browser flow.
The callback only took four arguments to reach the attachment ID, leaving two
declared and never read. WordPress notes the upload just before the metadata is
generated, in the same request as the filter, so a property carries that instead
and the callback is back to the single argument it uses.

The transient stays: the optimization that consults it runs in a later request.
This also removes the need for the argument count on both registrations.
@Miraeld
Miraeld added this pull request to the merge queue Aug 18, 2026
Merged via the queue into develop with commit 0e1ea85 Aug 18, 2026
13 checks passed
@Miraeld
Miraeld deleted the fix/1235-respect-core-resize-opt-out branch August 18, 2026 00:36
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: our big_image_size_threshold filter overrides core and orphans files on upload

2 participants