Skip to content

Enable client-side media uploads in the Media Library - #12585

Open
adamsilverstein wants to merge 34 commits into
WordPress:trunkfrom
adamsilverstein:add/media-library-client-side-uploads
Open

Enable client-side media uploads in the Media Library #12585
adamsilverstein wants to merge 34 commits into
WordPress:trunkfrom
adamsilverstein:add/media-library-client-side-uploads

Conversation

@adamsilverstein

@adamsilverstein adamsilverstein commented Jul 17, 2026

Copy link
Copy Markdown
Member

Trac ticket: https://core.trac.wordpress.org/ticket/65661

Description

Client-side media processing currently only works in the block editor - uploads from the Media Library grid and the Add New Media File screen still send the original file to async-upload.php and generate every sub-size on the server. This PR routes uploads on both screens through the same client-side pipeline the editor uses, so the browser resizes the image and generates the thumbnails.

Why this matters: server-side image processing is a common source of timeouts and memory errors on large images, and results vary depending on what image libraries the host has installed. Processing in the browser avoids both problems, and users get the same upload experience everywhere media is uploaded, not just in the editor.

How it works:

Both screens send the Document-Isolation-Policy header so the page is cross-origin isolated, which the wasm-vips image library requires. This reuses wp_set_up_cross_origin_isolation(), extended to cover the grid and the Add New Media File screen. A shared media-upload-pipeline script configures the @wordpress/upload-media store, queues files, tracks progress, and builds error text; a thin script on each screen intercepts files as they are added to the existing uploader and hands them to that pipeline instead: the original is uploaded via the REST API, the browser generates the sub-sizes and sideloads them, then the attachment is finalized. The existing UI - progress bars, grid tiles, error notices - is reused, so the screens look and behave unchanged. While an upload is in flight the page warns before you navigate away, since an interrupted client-side upload would lose thumbnails that were not sideloaded yet.

If the browser does not support cross-origin isolation (currently Chromium 137+ on a secure origin, exposed as wp_is_document_isolation_policy_supported()) or client-side processing is disabled, the scripts do nothing and uploads fall back to the classic flow unchanged. Audio files also stay on the classic path, since media_handle_upload() derives their title and description from ID3 tags and the REST endpoint does not. Any extra multipart params a plugin adds through plupload_default_params or wp.Uploader.param() are forwarded to the REST request so they still arrive in $_POST.

Error messages come straight from the pipeline, so the platform-specific HEIC wording from WordPress/gutenberg#81130 shows up in the grid's error sidebar and in the Add New Media File error rows the same way it does in the editor. Progress is estimated from the pipeline's remaining operations and sideloaded sub-sizes, since the store does not report a percentage of its own.

Not covered here: the media modal inside the editor (the "Media Library" button on an Image block) still uploads through plupload to the server, so a HEIC dropped there fails where a drop on the block succeeds. That is tracked in WordPress/gutenberg#82409.

Testing Instructions

Test in WordPress Playground

Test in Chrome 137 or newer on a secure origin (https or localhost).

Check that client-side processing is enabled:

  1. Go to Media > Library in grid view.
  2. Open DevTools and run crossOriginIsolated in the console - it should return true. You can also confirm the page response includes the Document-Isolation-Policy: isolate-and-credentialless header in the Network tab. Client-side processing is on by default in a secure context; the wp_client_side_media_processing_enabled filter can turn it off.

Verify uploads go through the client-side pipeline:

  1. Keep the Network tab open and drag a large image onto the grid.
  2. You should see a POST to wp/v2/media creating the attachment, followed by sideload and finalize requests - and no file POST to async-upload.php. That is how you can tell the client-side pipeline handled the upload.
  3. Watch the tile while that happens: the progress bar should advance as the original uploads and thumbnails are sideloaded, rather than sitting at zero until the end.
  4. Confirm the attachment looks normal: thumbnail in the grid, sub-sizes listed in the attachment details. Server files should match what you get before the patch.
  5. Repeat on Media > Add New Media File - same network pattern, the percentage on the row should climb, and the finished upload should show the usual row with Edit and Copy URL links.
  6. Start another large upload and try closing the tab before it finishes: the browser should ask for confirmation. After uploads complete, closing the tab should not prompt.
  7. Test with multiple files at once, including the same file twice - every tile should resolve.

Error handling:

  1. Try a disallowed file type (rename a text file to .xyz): the grid's error sidebar shows the file name and a readable reason, and the placeholder tile is removed rather than left spinning. On Add New Media File the error renders the same notice a server-side failure does, with a Dismiss button that announces the error and returns focus to the browse button.
  2. To see a server error surface, block the wp/v2/media request in DevTools (Network > right-click > Block request URL) and upload again: the server's message appears in the sidebar under the file name.

Fallback:

  1. Repeat an upload in Firefox or Safari (or add ?browser-uploader on media-new.php) - files should upload through async-upload.php as before. In list mode (Media > Library, list view) the Document-Isolation-Policy header is not sent and the grid script is not loaded.
  2. Upload an MP3 or WAV on either screen: it goes through async-upload.php, and the attachment title comes from the file's tags as before.
  3. Open Media > Add New Media File with ?post_id= set to a post that does not exist and upload an image: it uploads unattached, with no error, matching the classic behavior.

Formats:

  1. Verify that all formats supported by client side media work as expected, regardless of the server's support for them. Tests should include:
  • AVIF, WebP, HEIC formats
  • HDR JPEG images with Gain maps, HDR AVIF images
  • WebP and PNGs with various transparency types and color depths
  • PDFs, Audio and Video files also upload as expected
  • GIF's get a companion video created

Automated coverage: npm run test:e2e -- media-library-client-side-upload media-new-client-side-upload runs 23 tests on Chromium (which supports DIP as of the bundled Chrome 149), covering the headers, the pipeline requests, the classic fallback when the page is not isolated, multi-file uploads with duplicates, mid-upload progress, the unload guard, audio staying classic, forwarded upload params, an invalid post_id, and the error message, file name, and accessible Dismiss button for rejected and failed uploads.

AI Use

Code and description both written with 🤖 Claude Code. I will review and test.

The client-side media pipeline needs SharedArrayBuffer, which requires a
cross-origin isolated context. Core only isolates the block editor
screens, so uploads from the Media Library grid cannot use the pipeline.

Hook the existing Document-Isolation-Policy output buffer on
load-upload.php, gated to grid mode for users who can upload files.
The mode is resolved the same way upload.php resolves it later in the
request, without updating the saved user option. List mode has no
pipeline integration and stays untouched, avoiding isolation side
effects on a screen that gets no benefit.
Grid uploads go through wp.Uploader/plupload to async-upload.php, doing
all image processing server-side even when the browser could handle it.

Add a media-library-upload script that configures the
@wordpress/upload-media store and intercepts plupload's FilesAdded at a
higher priority, routing each file through the pipeline: REST upload of
the original, client-side thumbnails via wasm-vips, then sideload and
finalize. The grid UI is preserved by mirroring wp-plupload's
placeholder tiles, progress, queue reset, and error sidebar.
mediaSideload/mediaFinalize are thin apiFetch wrappers because the
@wordpress/media-utils equivalents are private APIs.

When the browser is not cross-origin isolated or lacks client-side
support, the script no-ops and classic plupload keeps handling uploads,
so degraded environments lose nothing.
Assert the Document-Isolation-Policy header is sent on the grid and not
in list mode, that a JPEG upload flows through the REST create,
sideload, and finalize endpoints with no async-upload.php requests, and
that a disallowed file type surfaces in the error sidebar.

Playwright's Chromium build ships without Document-Isolation-Policy
support, so the upload assertions skip when the context is not
cross-origin isolated; the header assertions still run everywhere.
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props adamsilverstein, westonruter.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

The ticket did not exist yet when the tests were written; annotate all
new test methods now that it has been filed.
CI's Playwright Chromium now supports Document-Isolation-Policy, so the
pipeline E2E test runs for real instead of skipping. The previous asset
was the 50x50 phpunit fixture, smaller than every registered sub-size,
so the pipeline correctly generated zero thumbnails and the
sideload-count assertion failed. Swap in the 640x480 canola.jpg fixture
so thumbnail and medium sub-sizes are generated and sideloaded, and
update the skip comments that claimed Playwright lacks DIP support.

See #65661
The suites gated the isolation callback and enqueue but never proved the
callback is actually wired to load-upload.php, that the inline settings
match what the server computes, or that the pipeline's output is real:

* Assert the default-filters.php hook registration, without which none
  of the gating logic runs.
* Assert the inline settings are exactly the JSON encoding of
  wp_get_media_library_upload_settings(), and that the upload_mimes,
  image_strip_meta, and image_max_bit_depth filters flow through to the
  settings the browser pipeline consumes.
* Extend the E2E happy path past request counting: the finalized
  attachment must carry thumbnail and medium sub-sizes in its metadata,
  and the sideloaded thumbnail file must actually be servable.
Interrupting a client-side pipeline upload is worse than interrupting a
classic plupload one: classic uploads complete server-side once the bytes
arrive, but an interrupted pipeline upload loses browser-generated
thumbnails that were not sideloaded yet and leaves the attachment
unfinalized. Trigger the browser's leave confirmation while the progress
map is non-empty; the guard is scoped to the pipeline, so classic uploads
behave exactly as before.

See #65662
…eline

media-new.php was the last admin upload surface without pipeline
integration: plupload-handlers creates a raw plupload.Uploader (wp.Uploader
never loads there) posting to async-upload.php with all image processing
server-side.

Extend cross-origin isolation to the screen via a new
wp_set_up_media_new_cross_origin_isolation() on load-media-new.php, gated
on client-side processing being enabled and the upload_files capability
(the screen itself already requires it). Add a new media-new-upload admin
script that binds a higher-priority FilesAdded handler on the
plupload-handlers uploader instance and routes files through the
@wordpress/upload-media store, sharing its settings with the grid
integration via wp_get_media_library_upload_settings().

The screen's existing UI helpers are reused rather than replicated:
fileQueued() builds the progress item, uploadSuccess() renders the
finished attachment row through the existing async-upload.php markup
endpoint, itemAjaxError() surfaces per-file errors, and uploadComplete()
runs when the queue drains, so the screen looks and behaves unchanged.
The same beforeunload guard as the grid warns while pipeline uploads are
in flight. When the browser is not cross-origin isolated or lacks
client-side support the script no-ops and the classic plupload flow (and
the browser-uploader HTML fallback form) keep working unchanged.

See #65662
Assert the Document-Isolation-Policy header on media-new.php, the
happy-path pipeline upload (create, sideload, and finalize via REST with
no file upload through async-upload.php; the fetch=3 markup POST is
expected and excluded), and the disallowed-file-type error path.

CI's Playwright Chromium supports Document-Isolation-Policy, so the full
pipeline is exercised there; the upload assertions still skip in browsers
where isolation is unavailable, and the existing media-upload spec keeps
covering the classic path as the degradation check.

See #65662
The test asserts that a failed SVG upload on media-new.php shows a
dismissible error. With the client-side pipeline active (CI's Chromium is
cross-origin isolated), the disallowed file is rejected client-side and
the error renders through the standard per-file error UI, where the
dismiss control is a link, instead of the server-rendered
async-upload.php notice, where it is a button. Target the .dismiss
control by class so both variants pass; the error text and the dismissal
behavior asserted are unchanged.

See #65662
…oreunload guards

The beforeunload guards from this branch had no test coverage at all,
and the media-new.php suites had the same blind spots just closed for
the grid on the base branch:

* Assert the load-media-new.php hook registration in
  default-filters.php and that the inline settings are exactly the JSON
  encoding of wp_get_media_library_upload_settings().
* Add an E2E test per screen for the beforeunload guard: hold sideload
  requests via routing so the upload is deterministically in flight,
  then dispatch a synthetic cancelable beforeunload and assert it is
  prevented while uploading and no longer prevented after completion.
* Extend the media-new.php E2E happy path past request counting: the
  finalized attachment must carry thumbnail and medium sub-sizes and
  the sideloaded thumbnail file must actually be servable.
@adamsilverstein adamsilverstein changed the title Media: Enable client-side media uploads in the Media Library grid Media: Enable client-side media uploads in the Media Library grid and media-new.php Jul 19, 2026
@adamsilverstein adamsilverstein changed the title Media: Enable client-side media uploads in the Media Library grid and media-new.php Media: Enable client-side media uploads in the Media Library Jul 20, 2026
Copilot AI review requested due to automatic review settings July 23, 2026 02:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Extends WordPress’s client-side media processing pipeline beyond the block editor to the remaining admin upload surfaces (Media Library grid and Media > Add New), including cross-origin isolation setup, upload routing via the @wordpress/upload-media store, and new automated coverage to validate the end-to-end pipeline and headers.

Changes:

  • Adds Media Library mode resolution and new helpers to enable Document-Isolation-Policy isolation and enqueue upload-routing scripts for upload.php (grid) and media-new.php.
  • Introduces new admin upload integration scripts (media-library-upload, media-new-upload) that intercept plupload flows and route files through the REST-based client-side pipeline.
  • Adds comprehensive PHPUnit + Playwright E2E tests covering header gating, pipeline upload behavior (create/sideload/finalize), beforeunload guards, and error handling.

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/phpunit/tests/media/wpMediaNewCrossOriginIsolation.php Adds PHPUnit coverage for DIP isolation setup on media-new.php.
tests/phpunit/tests/media/wpMediaLibraryCrossOriginIsolation.php Adds PHPUnit coverage for DIP isolation setup and Media Library mode resolution on upload.php grid.
tests/phpunit/tests/media/wpEnqueueMediaNewUpload.php Adds PHPUnit coverage for enqueueing media-new-upload and inline settings.
tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php Adds PHPUnit coverage for enqueueing media-library-upload, inline settings, and filter plumbing into settings.
tests/e2e/specs/media-upload.test.js Adjusts an existing E2E assertion to support both server and client-side rejection UIs.
tests/e2e/specs/media-new-client-side-upload.test.js Adds E2E coverage for media-new.php DIP header, pipeline upload, beforeunload guard, and disallowed types.
tests/e2e/specs/media-library-client-side-upload.test.js Adds E2E coverage for upload.php grid DIP header, pipeline upload, beforeunload guard, and disallowed types (plus list-mode header absence).
src/wp-includes/script-loader.php Registers the new admin script handles and their dependencies/translations.
src/wp-includes/media.php Adds mode helper, isolation setup helpers, shared upload settings helper, and enqueue helpers for the two upload surfaces.
src/wp-includes/default-filters.php Wires the new isolation setup callbacks into the relevant load-* hooks.
src/wp-admin/upload.php Enqueues the new grid upload integration script in Media Library grid mode.
src/wp-admin/media-new.php Enqueues the new Media > Add New upload integration script.
src/js/_enqueues/admin/media-new-upload.js Implements media-new.php plupload interception, pipeline routing, UI mirroring, progress sync, and beforeunload guard.
src/js/_enqueues/admin/media-library-upload.js Implements Media Library grid uploader interception, pipeline routing, UI mirroring, progress sync, and beforeunload guard.
Gruntfile.js Adds build mappings for the two new admin scripts.
Comments suppressed due to low confidence (1)

src/wp-includes/media.php:6846

  • wp_enqueue_media_new_upload() enqueues the pipeline integration on any secure origin, but wp_start_cross_origin_isolation_output_buffer() (and therefore crossOriginIsolated) is Chromium 137+ only. On other browsers this script cannot run and will always no-op, so enqueueing it adds avoidable page weight. Consider gating enqueueing on wp_get_chromium_major_version() >= 137 to skip loading unused bundles in browsers that can’t be isolated by DIP.
	if ( ! wp_is_client_side_media_processing_enabled() ) {
		return;
	}

	wp_enqueue_script( 'media-new-upload' );

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/wp-includes/media.php Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 23, 2026 03:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 16 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php:46

  • tear_down() restores HTTP_HOST but not HTTP_USER_AGENT. If set_up() sets a Chromium UA for deterministic enqueue behavior, tear_down() should restore/unset it to avoid leaking the UA into later tests.
		if ( null === $this->original_http_host ) {
			unset( $_SERVER['HTTP_HOST'] );
		} else {
			$_SERVER['HTTP_HOST'] = $this->original_http_host;
		}

Comment thread tests/phpunit/tests/media/wpEnqueueMediaLibraryUpload.php
…reens.

Move everything the grid and Add New Media File scripts had in common
into a new media-upload-pipeline script: feature detection, configuring
the upload-media store, the REST sideload/finalize/delete helpers,
queueing a file with progress tracking, the error text, and the unload
guard. The two screen scripts become thin adapters for their own UI.

While there, fix what the review turned up in that shared code:

- Read the post to attach to from the screen's validated post_id rather
  than plupload's raw multipart params, which the REST API rejects for a
  deleted or unpermitted post where async-upload.php silently uploads
  unattached.
- Forward the remaining multipart params to the REST request so plugins
  that add fields through plupload_default_params or wp.Uploader.param()
  still see them in $_POST.
- Leave audio files to the classic uploader, which derives the title and
  description from ID3 tags where the REST endpoint does not.
- Track queue items by id after the first match, since HEIC conversion
  swaps the item's sourceFile.
- Subscribe only to the upload-media store and skip sub-size children.
- Render failed uploads on media-new.php with the same notice
  async-upload.php returns: a real Dismiss button described by the
  notice, a screen reader announcement, and focus returned to the browse
  button, and restore the e2e assertion that relied on it.

Add wp_is_document_isolation_policy_supported() so the Chromium 137
check lives in one place.

Claude-Session: https://claude.ai/code/session_018sL2Far7Hyjc1mJp2RKAcd
…isolation().

The two screen-specific wrappers duplicated the enabled, capability,
and output-buffer tail of the block editor's isolation callback. Extend
its screen check to the Media Library grid and the Add New Media File
screen instead and hook it on their load actions, keeping the page
builder guard scoped to the editor screens.

Claude-Session: https://claude.ai/code/session_018sL2Far7Hyjc1mJp2RKAcd
@adamsilverstein

Copy link
Copy Markdown
Member Author

Pushed a round of changes after running a code review over the branch; the PR description is updated to match.

Claude worked through the review findings, here is the rundown:

  • The two screen scripts shared about 200 lines, so that glue now lives in one media-upload-pipeline script (store setup, sideload/finalize/delete, queueing with progress, error text, the unload guard) and each screen keeps only its own UI adapter. On the PHP side the two isolation wrappers are gone: wp_set_up_cross_origin_isolation() now covers the grid and the Add New Media File screen, and the Chromium 137 check lives in a new wp_is_document_isolation_policy_supported().
  • Progress bars never moved: @wordpress/upload-media defines updateItemProgress but nothing dispatches it, so item.progress is always undefined. Progress is now estimated from the item's remaining operations and the sub-sizes sideloaded so far. Items are also matched by id after the first tick, because HEIC conversion swaps sourceFile.
  • media_upload_form() hands plupload the raw post_id, which the REST API rejects for a deleted or unpermitted post where async-upload.php silently uploads unattached. The screen script now reads the validated #post_id value instead.
  • Extra multipart params from plupload_default_params or wp.Uploader.param() were dropped on the REST path; they are forwarded now so plugins still see them in $_POST.
  • Audio stays on the classic uploader, since media_handle_upload() sets the title and description from ID3 tags and the REST endpoint does not.
  • Failed uploads on media-new.php rendered through itemAjaxError(), losing the announcement, the real Dismiss button, and the focus return that async-upload.php provides. They now render the same notice, and the loosened assertion in media-upload.test.js is back to the original.
  • The e2e assertion that broke CI expected the file name inside the message span; the sidebar puts it in its own span. Fixed, and the suites now cover the classic fallback, multi-file with duplicates, progress, forwarded params, an invalid post_id, audio, and the accessible error.

The refutations from the review were: the media_library_mode '0' case, the WebP/AVIF gating (intended per the REST controller's generate_sub_sizes waiver), and the inFlightCount ordering, none of which needed a change.

All 45 PHPUnit tests and the 24 e2e tests pass locally against Chrome 149, which honors DIP, so the pipeline tests run for real now rather than skipping.

Comment thread src/js/_enqueues/admin/media-library-upload.js Outdated
Comment thread src/js/_enqueues/admin/media-library-upload.js

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Four moderate issues must be addressed before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 16/18 changed files
  • Comments generated: 4
  • Review effort level: Balanced

Comment thread src/js/_enqueues/admin/media-library-upload.js
Comment thread src/js/_enqueues/admin/media-new-upload.js
Comment thread src/js/_enqueues/admin/media-upload-pipeline.js
Comment thread src/wp-includes/media.php Outdated
…ode().

upload.php falls back to grid mode for any falsey saved option and
renders list mode for a truthy value that is not exactly 'grid'. The
helper treated a saved '0' as a non-grid mode and collapsed a non-string
value to grid, so the isolation decision could disagree with the mode
the page then rendered.

Claude-Session: https://claude.ai/code/session_01KbT1XjMNEosC6ueLGxbspA
@adamsilverstein adamsilverstein changed the title Media: Enable client-side media uploads in the Media Library Enable client-side media uploads in the Media Library Sep 4, 2026
@westonruter

Copy link
Copy Markdown
Member

Let's add the new JS files in js/_enqueues/admin to the list of files being checked by TypeScript in tsconfig.json. This will avoid us having to add fixes later.

@westonruter westonruter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Requesting changes for using ES6 and TypeScript checking.

return;
}

var pipeline = window.wp && wp.mediaUploadPipeline;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's use const and let in all new JS files.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Finished across all three files in eed1755.

Comment on lines +64 to +66
* @param {Object} wpUploader The wp.Uploader instance that queued the file.
* @param {Object} model The placeholder Attachment model.
* @param {Object} attachment The finalized attachment from the pipeline.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These Object types are under-specific.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Replaced with WPUploader, WPAttachment, and PipelineAttachment typedefs in eed1755.

Comment on lines +111 to +112
* @param {Object} wpUploader The wp.Uploader instance that queued the file.
* @param {Object} model The placeholder Attachment model.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's make Object more specific.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

right, thats lazy

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Same treatment here in eed1755.

Comment on lines +144 to +145
* @param {Object} wpUploader The wp.Uploader instance.
* @param {Object} up The plupload uploader instance.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ditto on Object specificity.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Typed in eed1755: WPUploader, plupload.Uploader, and plupload.File[].

Comment thread src/js/_enqueues/admin/media-library-upload.js Outdated
Comment thread src/js/_enqueues/admin/media-new-upload.js Outdated
Comment thread src/js/_enqueues/admin/media-new-upload.js Outdated
adamsilverstein and others added 3 commits September 4, 2026 11:56
Co-authored-by: Weston Ruter <westonruter@gmail.com>
Co-authored-by: Weston Ruter <westonruter@gmail.com>
Add the three new admin upload scripts to the TypeScript project so their
types are checked from the start rather than fixed up later, with typings for
the plupload globals they rely on. Finish the const/let conversion, and
replace the under-specific `Object` and `Array` JSDoc types with real shapes:
the FilesAdded arrays hold plupload file objects, not strings.

Also extend the placeholder tile's early mime scan to the formats the
client-side pipeline accepts, so a WebP or AVIF drop gets the same
`type-image` placeholder a JPEG does.
@adamsilverstein

Copy link
Copy Markdown
Member Author

Good call on adding these to tsconfig.json - it turned up a few loose types worth fixing now rather than after they ship. Pushed in eed1755.

Claude did the work here, rundown below:

All three new scripts are in tsconfig.json now, with a new
typings/media-uploads/index.d.ts describing the plupload surface they use
(plupload.File, plupload.Uploader) and the plupload-handlers globals on
media-new.php. npm run typecheck:js passes.

Making it pass meant a real pass over the types rather than a rename. The
Object params are typedefs for what they actually are now, the Promise
return types carry their value, and the errors the pipeline surfaces are
typed as { code, message } instead of Error, since a rejected apiFetch
is often a plain REST error object rather than an Error.

One correction to the batched suggestion: the files array plupload passes
to FilesAdded holds file objects, not strings - the handler reads
file.status, file.name, and file.getNative() off each entry - so those
are plupload.File[].

const/let is finished across all three files.

Separately, the placeholder tile's early mime scan was copied from
wp-plupload.js and only matched jpg/png/gif, so a WebP or AVIF drop did not
get the type-image class its JPEG equivalent gets. That regex now covers
the formats the client-side pipeline accepts. wp-plupload.js has the same gap
on the classic path, which looks worth a separate fix.

The 23 e2e tests still pass locally against Chrome 149.

Comment on lines +23 to +43
/**
* The parts of a wp.Uploader instance this script relies on.
*
* @typedef {Object} WPUploader
* @property {plupload.Uploader} uploader The plupload uploader it wraps.
* @property {( model: WPAttachment ) => void} added Runs when a file is queued.
* @property {( model: WPAttachment ) => void} success Runs when an upload finished.
* @property {( message: string, data: Object, file: { name: string } ) => void} error Runs when an upload failed.
*/

/**
* The parts of a wp.media.model.Attachment (a Backbone model) this script
* relies on.
*
* @typedef {Object} WPAttachment
* @property {( key: string ) => unknown} get Reads a model attribute.
* @property {( attributes: Object, options?: Object ) => void} set Sets model attributes.
* @property {( key: string, options?: Object ) => void} unset Removes a model attribute.
* @property {() => JQuery.jqXHR} fetch Refetches the attachment from the REST API.
* @property {() => void} destroy Removes the model and its tile.
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not an expert with TypeScript, but why wouldn't these go in typings/media-uploads/index.d.ts as well?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It also seems like WPAttachment should somehow extend from @types/backbone.

Comment on lines +45 to +60
/**
* The placeholder attributes wp-plupload.js builds for an uploading tile.
*
* @typedef {Object} PlaceholderAttributes
* @property {plupload.File} file The file being uploaded.
* @property {boolean} uploading Always true while the upload runs.
* @property {Date} date When the upload started.
* @property {string} filename The file name.
* @property {number} menuOrder Menu order of the attachment.
* @property {number} uploadedTo The post the upload is attached to.
* @property {number} loaded Bytes uploaded so far.
* @property {number} size Size of the file in bytes.
* @property {number} percent Progress percentage.
* @property {string} [type] Mime type guessed from the file name.
* @property {string} [subtype] Mime subtype guessed from the file name.
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same for this. Why not typings/media-uploads/index.d.ts?

if (
! pipeline ||
typeof plupload === 'undefined' ||
typeof jQuery === 'undefined' ||

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we eliminate the explicit dependency on jQuery? I know it's already on the page and always will be. So maybe this isn't worth doing. But I wanted to raise it for discussion.

* @typedef {Object} UploadError
* @property {string} [code] Error code, when the pipeline supplied one.
* @property {string} [message] Human-readable reason.
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ditto above about perhaps having these in typings/media-uploads/index.d.ts

Comment on lines +522 to +525
* @param {Object} callbacks Lifecycle callbacks.
* @param {( attachment: PipelineAttachment ) => void} [callbacks.onSuccess] Called with the finalized attachment.
* @param {( error: UploadError ) => void} [callbacks.onError] Called with the upload error.
* @param {( percent: number ) => void} [callbacks.onProgress] Called with an integer percentage (0-99) whenever it changes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maybe extract this type to define elsewhere?

Comment thread src/wp-includes/media.php
if ( ! $screen->is_block_editor() && 'site-editor' !== $screen->id && ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) ) {
$is_media_screen = 'media' === $screen->id || ( 'upload' === $screen->id && 'grid' === wp_get_media_library_mode() );

if ( ! $is_media_screen && ! $screen->is_block_editor() && 'site-editor' !== $screen->id && ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if ( ! $is_media_screen && ! $screen->is_block_editor() && 'site-editor' !== $screen->id && ! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() ) ) {
if (
! $is_media_screen &&
! $screen->is_block_editor() &&
'site-editor' !== $screen->id &&
! ( 'widgets' === $screen->id && wp_use_widgets_block_editor() )
) {

Comment thread src/wp-includes/media.php
*
* @since 7.2.0
*
* @return string The Media Library mode, 'grid' when none is saved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
* @return string The Media Library mode, 'grid' when none is saved.
* @return string The Media Library mode, 'grid' when none is saved.
* @phpstan-return 'grid'|'list'

Comment thread src/wp-includes/media.php
Comment on lines +6740 to +6752
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['mode'] ) && in_array( $_GET['mode'], array( 'grid', 'list' ), true ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
return $_GET['mode'];
}

$mode = get_user_option( 'media_library_mode', get_current_user_id() );

if ( ! $mode ) {
return 'grid';
}

return is_string( $mode ) ? $mode : 'list';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['mode'] ) && in_array( $_GET['mode'], array( 'grid', 'list' ), true ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
return $_GET['mode'];
}
$mode = get_user_option( 'media_library_mode', get_current_user_id() );
if ( ! $mode ) {
return 'grid';
}
return is_string( $mode ) ? $mode : 'list';
$valid_modes = array( 'grid', 'list' );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['mode'] ) && in_array( $_GET['mode'], $valid_modes, true ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
return $_GET['mode'];
}
$mode = get_user_option( 'media_library_mode', get_current_user_id() );
if ( in_array( $mode, $valid_modes, true ) ) {
return $mode;
}
return 'list';

Tightening the checks. However, it's not clear to me why grid was the default if $mode was empty, whereas if it is not a string it is list.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants