Skip to content

Fix media uploaded in a non-default WPML language never finishing sync - #1281

Open
gabrielcld2 wants to merge 3 commits into
developfrom
bugfix/wpml-rest-loopback-url
Open

gabrielcld2 wants to merge 3 commits into
developfrom
bugfix/wpml-rest-loopback-url

Conversation

@gabrielcld2

@gabrielcld2 gabrielcld2 commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Fixes media uploaded from a non-default WPML language getting permanently stuck in a "syncing" state

Approach

  • When syncing an asset, the plugin queues an attachment and fires a non-blocking REST request to itself to upload it in the background.
  • WPML hooks WordPress core's home_url filter to inject the browsing language into every generated URL. Utils::rest_url() builds the loopback URL by calling core rest_url( $path ), which applies that filter to the REST base before the endpoint path is appended.
    • For any non-default language this produces a malformed URL: e.g. http://site/wp-json/?lang=fr/cloudinary/v1/queue instead of http://site/wp-json/cloudinary/v1/queue?lang=fr.
  • Because of this the queued attachment is left pending forever with no error anywhere.
  • Fixed in php/integrations/class-wpml.php by hooking the existing cloudinary_rest_url filter:
    • Rebuild the URL with WPML's own supported WPML_URL_Filters::remove_global_hooks() / add_global_hooks() pattern (the same mechanism WPML uses internally for its Google Site Kit and canonical-URL compatibility code) to get a clean, language-unfiltered URL, wrapped in try/finally so a thrown exception can't leave WPML's URL filters disabled for the rest of the request.
    • Re-apply the language via WPML's documented wpml_permalink filter. remove_global_hooks() alone is only enough in directory/domain negotiation mode, where WPML separately hooks core's rest_url filter to re-insert the language after the full URL is built. In "language as a parameter" mode there's no such hook, so the clean URL would be missing ?lang= entirely — not just for the sync loopback, but for every Utils::rest_url() consumer (asset fetch/save, cache purge, analytics, UI state), silently running them under the default language's context instead. wpml_permalink is a no-op when the URL already carries the right language (directory/domain mode) and correctly appends it when it doesn't (parameter mode).
    • Guard the undocumented remove_global_hooks/add_global_hooks methods with method_exists() in case a future WPML release renames or removes them.

QA notes

  • Requires a WPML install (Multilingual CMS + Media Translation) with at least one non-default language configured.
  • Switch wp-admin to a non-default language (e.g. ?lang=fr), upload a new image to the Media Library.
    • Before this fix: it stays in a "syncing" state indefinitely.
    • With this change: upload finishes syncing automatically, and the relationship row's media_context matches the language it was uploaded in (verified via wp_cloudinary_relationships).
  • Sanity check the default-language path is unaffected: upload in the default language and confirm it still syncs immediately with media_context set to the default language.
  • Tested against all three WPML language negotiation modes' URL-handling paths (directory, domain, parameter) by tracing WPML's own source; end-to-end verified in "language as a parameter" mode.

WPML hooks WordPress's `home_url` filter to inject the browsing language
into every generated URL. Since rest_url() applies that filter to the
REST base before the endpoint path is appended, a non-default language
corrupts the background sync loopback URL into e.g.
`/wp-json/?lang=fr/cloudinary/v1/queue` instead of a valid route. The
loopback request "succeeds" (200) by hitting the REST index rather than
the sync endpoint, so queued assets are silently never processed and
stay stuck in a "syncing" state.

@utkarshcloudinary utkarshcloudinary left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed with help of AI and left feedback.

Comment thread php/integrations/class-wpml.php
Comment thread php/integrations/class-wpml.php Outdated
Comment thread php/integrations/class-wpml.php
remove_global_hooks() alone only fixes directory/domain negotiation
mode, where WPML separately hooks core's rest_url filter to re-insert
the language after the full URL is built. In parameter mode
(WPML_Lang_Parameter_Filters), nothing does that, so the cleaned URL
was missing ?lang= entirely - not just for the sync loopback, but for
every Utils::rest_url() consumer (asset fetch/save, cache purge,
analytics, UI state), silently running them under the default
language's context. wpml_permalink is WPML's documented API for
exactly this and is idempotent across all three negotiation modes.

Also wraps the hook removal in try/finally so a thrown exception can't
leave WPML's URL filters permanently disabled for the rest of the
request, and guards the undocumented remove_global_hooks/
add_global_hooks methods with method_exists().

Addresses review feedback on PR #1281.
@gabrielcld2

gabrielcld2 commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

@utkarshcloudinary thanks for the review! I've addressed the issues you found, and did some manual testing again to confirm the solution still works as expected. Please have another look

@utkarshcloudinary utkarshcloudinary left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks good!

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.

3 participants