Skip to content

Fix compatibility with TranslatePress - #180

Open
LukePPx wants to merge 1 commit into
MilliPress:mainfrom
LukePPx:fix-translatepress-compatibility
Open

Fix compatibility with TranslatePress#180
LukePPx wants to merge 1 commit into
MilliPress:mainfrom
LukePPx:fix-translatepress-compatibility

Conversation

@LukePPx

@LukePPx LukePPx commented Jul 31, 2026

Copy link
Copy Markdown

fix: capture the outermost output buffer when TranslatePress is active

Summary

MilliCache opens its capture buffer very late — on template_redirect at priority PHP_INT_MAX - 10 — which makes it the innermost output buffer. Any plugin that opens its own output buffer earlier (e.g. on init) becomes the outer buffer and post-processes the HTML after MilliCache has already snapshotted it. MilliCache therefore caches the pre-processed page.

One known victim currently is TranslatePress, which starts its translation buffer on init (priority 0):

  • First request (MISS): the visitor sees the correctly translated page (TranslatePress translates on the way out, in its outer buffer). ✅
  • What gets stored: the untranslated, default-language HTML (MilliCache's inner callback ran before TranslatePress translated). ❌
  • Every subsequent request (HIT): the drop-in serves the stored default-language copy → wrong language. ❌

Root cause

PHP flushes nested output buffers LIFO — innermost first. Because MilliCache opens last, its callback (Response\Processor::process_output_buffer()) runs first and captures the page before the outer buffers have transformed it.

Buffer stack (current):   [ TranslatePress (outer) [ MilliCache (inner) ] ]
Flush order (LIFO):       MilliCache stores raw HTML  →  TranslatePress translates for the browser

Fix (opt-in, auto-enabled for TranslatePress)

The historical behaviour is kept as the default. Only when an init-phase output-buffer post-processor is detected — currently TranslatePress — does MilliCache switch to opening its buffer as the outermost one, so the post-processor nests inside and MilliCache captures the final, fully processed HTML.

Buffer stack (TranslatePress active):   [ MilliCache (outer) [ TranslatePress (inner) ] ]
Flush order (LIFO):                     TranslatePress translates  →  MilliCache stores translated HTML

How the strategy is chosen

Whether TranslatePress is active is only knowable once plugins have loaded, so the choice is deferred to init at PHP_INT_MIN (which still runs before TranslatePress' own init priority-0 buffer):

  • Default / no post-processor: register the historical late buffer on template_redirect (PHP_INT_MAX - 10) — behaviour is byte-for-byte unchanged, including the check_cache_decision() gate and Options::apply_to_state() call.
  • TranslatePress active (front-end page only): open the buffer immediately on init, wrapping TranslatePress.

Engine::run() no longer registers the buffer directly; it calls a new start_capture() helper. A fresh cache hit still exit()s inside the reader, so the capture buffer is only set up for a MISS or a stale-hit background regeneration.

Applying rule overrides on the outermost path

On the outermost path the buffer opens before WP-typed rules have resolved, so Response\Processor::process_output_buffer() now folds the rule-driven TTL/grace/decision overrides into the state at flush time (Options::apply_to_state()) and honours a rule-driven bypass decided after the buffer opened (returns the output unstored). This runs at end-of-request once every hook has fired, so nothing is missed. On the default path the options were already applied at template_redirect, so re-applying is a harmless no-op.

Detection & override

$active = class_exists( 'TRP_Translate_Press', false );
return (bool) apply_filters( 'millicache_capture_outermost_buffer', $active );
  • class_exists( 'TRP_Translate_Press', false ) is reliable at init — TranslatePress' main class is loaded during the plugin include, before plugins_loaded.
  • The new filter millicache_capture_outermost_buffer lets integrators force the outermost strategy for other output-buffer post-processors, or disable it entirely:
// Force it (e.g. for another init-phase HTML post-processor)
add_filter( 'millicache_capture_outermost_buffer', '__return_true' );

// Never use it
add_filter( 'millicache_capture_outermost_buffer', '__return_false' );

Admin and AJAX contexts are explicitly excluded, so only genuine front-end page renders use the outermost buffer. REST, XML-RPC, non-GET/HEAD and CLI requests are already short-circuited in the PHP-phase rules before run(), so they never reach this code.

Why this is safe

  • No behaviour change without TranslatePress. When no init-phase post-processor is present, the code path, hooks and priorities are identical to today.
  • Decision timing. WP-typed rules run on plugins_loaded/init/template_redirect/wp, all before the buffer flushes at shutdown. Reading options at flush is at least as late as the old template_redirect read, so no override is missed.
  • PHP-level bypass unchanged. Engine::start() still gates whether run() is called at all (XML-RPC, REST, non-GET/HEAD, CLI, files, nocache cookies/paths).
  • WP-level bypass preserved. Logged-in / search / cron / AJAX / non-200 / DONOTCACHEPAGE decisions are enforced in the flush callback on the outermost path; a bypassed request simply passes its output through unstored.
  • Redirects aren't cached. Cache\Manager::cache_output() validates status via Writer::should_cache(), so a 3xx captured by the earlier buffer is not stored.
  • Industry-standard approach. Opening the capture buffer as the outermost one is what WP Rocket, WP Super Cache and W3TC do; it is the ecosystem norm precisely because it composes with output-buffer post-processors.

Test plan

Environment: WordPress + TranslatePress (default en_US + a secondary language, e.g. /de/), MilliCache active with the drop-in installed.

  1. Reproduce (before patch): Clear cache. Load /de/some-page/ twice → first translated, second (HIT) default language. ❌
  2. Verify (after patch): Clear cache. Load /de/some-page/ twice → both translated; debug header shows miss then hit. ✅
  3. Default language: /some-page/ still caches and serves correctly.
  4. No cross-language contamination: /de/ and / produce distinct cache entries (request hash already includes the path).
  5. TranslatePress not installed: confirm the buffer still opens on template_redirect (default path unchanged) and everything caches as before.
  6. Bypass: logged-in user / search / DONOTCACHEPAGE on a translated page → x-millicache: bypass, nothing stored, page still translated.
  7. AJAX / REST: front-end admin-ajax.php and REST requests are not wrapped by the outermost buffer (still translated by TranslatePress' own buffer).
  8. Stale-while-revalidate (FastCGI): a stale hit serves instantly and the background regeneration stores the fresh translated page.
  9. Filter override: __return_false reproduces the old bug (confirms toggle); __return_true forces the outermost path without TranslatePress.
  10. Regression: run the existing PHPUnit suite; add cases asserting needs_outermost_buffer() is false by default / when is_admin(), and true when the class exists on a front-end request.

Notes for maintainers

  • Patch is against v1.7.7. @since tags use 1.7.8; adjust to your release-please cut. Conventional-commit title suggested above (fix:).
  • Touches only src/Engine.php (run() + new start_capture() / needs_outermost_buffer()) and src/Engine/Response/Processor.php (process_output_buffer()).
  • Detection is intentionally TranslatePress-specific but filterable; happy to generalise to a registry of known init-phase post-processors if you prefer.

@ouun

ouun commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

@LukePPx thank you very much for digging into this and putting together the PR for full TranslatePress support.

I agree with it and we'll take it on for v1.8.0, but in generic form. Rather than switching strategies conditionally when TranslatePress is detected, we'll make the outermost buffer the default for everyone: the buffer will open unconditionally in the drop-in phase (advanced-cache.php), before any plugin or MU plugin loads.

Two options for how to get there, whichever you prefer:

  1. You rework this PR in that direction (drop needs_outermost_buffer() and the init hook, open the buffer directly in run() after retrieve_and_serve_cache(), keep your Processor changes), and we review from there.
  2. We implement it on our side building on your Processor work, with credit to you in the changelog and release notes.

Either way, we'd love to have you test the 1.8.0 beta against your TranslatePress setup before release. Until then, your MU plugin is a solid interim workaround for your sites.

Thank you again and kind regards,

Philipp

@ouun ouun self-assigned this Aug 3, 2026
@ouun ouun added enhancement New feature or request good first issue Good for newcomers labels Aug 3, 2026
@ouun ouun added this to the 1.8.0 milestone Aug 3, 2026
@LukePPx

LukePPx commented Aug 4, 2026

Copy link
Copy Markdown
Author

@ouun Seems like a good plan.
I spent the day yesterday debugging our site, because we found other plugins that open output buffers before our patched version of millicache on init. So your plan to open a buffer even before plugins are loaded is a much better idea than the implementation in my PR!

We are keen to test 1.8.0. Just let me know, once it's ready.

ouun added a commit that referenced this pull request Aug 4, 2026
Open the capture buffer in the drop-in phase, before any plugin loads,
so output-buffer post-processors (TranslatePress, HTML optimizers) nest
inside it and their final HTML is what gets stored. A sentinel at the
former buffer position (template_redirect, PHP_INT_MAX - 10) records the
cache decision, sticky-negative across replays; the handler stores only
on the end-of-request FINAL flush after a positive sentinel. Buffers
cleaned or flushed mid-request by third parties, chunk overflows past
the 5MB cap, mid-request fastcgi_finish_request() calls, and responses
carrying Content-Encoding all pass through unstored. Rule-driven TTL,
grace, and cache decisions are honored up to the final flush; a late
bypass wins. Single code path, no escape hatch.

New public API for extensions:
millicache()->response()->is_storable().

Thanks to @LukePPx for the report, root-cause analysis, and initial
patch (#180).

Co-authored-by: Lukas Thoma <127091282+LukePPx@users.noreply.github.com>
@ouun

ouun commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Thank you again, @LukePPx, for raising this issue and for your PR.
MilliCache 1.8.0-beta and MilliCache Pro 1.4.0-beta have been released.
As the buffer change affects every request, it is a release that should be tested before reaching production. From our side we

Both opt in to WordPress with define( 'MC_UPDATE_PRERELEASE', true ); in wp-config.php.

For Composer projects, opt-in is per package: use the command composer require "millipress/millicache:^1.8@beta" for MilliCache, or composer require "millipress/millicache-pro:^1.4@beta" for MilliCache Pro. This is because Composer only installs stable versions by default.

Would be great if you could test it with TranslatePress on your side to confirm that it fixes it for you.

@LukePPx

LukePPx commented Aug 11, 2026

Copy link
Copy Markdown
Author

@ouun nothing exciting to report. Both betas seem to work with all our wordpress plugins.
After testing we decided to move the plugins to production and disable our own fix. They are now live >24h without any issues.

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

Labels

enhancement New feature or request good first issue Good for newcomers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants