diff --git a/inc/cleantalk-public.php b/inc/cleantalk-public.php index d141014c2..863a05663 100644 --- a/inc/cleantalk-public.php +++ b/inc/cleantalk-public.php @@ -409,9 +409,28 @@ function apbct_sgo_bypass_query_params($bypass_query_params) return $bypass_query_params; } +/** + * Nesting level of the buffer opened by apbct_buffer__start(). + * + * @param int|null $set + * + * @return int + */ +function apbct_buffer__own_level($set = null) +{ + static $level = 0; + + if ( $set !== null ) { + $level = (int)$set; + } + + return $level; +} + function apbct_buffer__start() { ob_start(); + apbct_buffer__own_level(ob_get_level()); } function apbct_buffer__end() @@ -421,6 +440,19 @@ function apbct_buffer__end() } global $apbct; + + // Buffers opened after ours (WP late-printed styles hoisting, other plugins) must be flushed, + // not discarded — otherwise their replacements never reach the output. + $own_level = apbct_buffer__own_level(); + if ( $own_level > 0 ) { + while ( ob_get_level() > $own_level ) { + $previous_level = ob_get_level(); + if ( ! @ob_end_flush() || ob_get_level() >= $previous_level ) { + break; + } + } + } + $apbct->buffer = ob_get_contents(); ob_end_clean(); } diff --git a/tests/Inc/TestCleantalkPublicBuffer.php b/tests/Inc/TestCleantalkPublicBuffer.php new file mode 100644 index 000000000..679f3176b --- /dev/null +++ b/tests/Inc/TestCleantalkPublicBuffer.php @@ -0,0 +1,119 @@ +level_before = ob_get_level(); + } + + protected function tearDown(): void + { + while ( ob_get_level() > $this->level_before ) { + ob_end_clean(); + } + + global $apbct; + unset($apbct); + parent::tearDown(); + } + + public function testBufferEndCapturesOwnBufferAndLeavesTheOuterOneOpen() + { + global $apbct; + + ob_start(); + echo 'OUTER;'; + $outer_level = ob_get_level(); + + apbct_buffer__start(); + echo 'PAGE;'; + + apbct_buffer__end(); + + $this->assertSame('PAGE;', $apbct->buffer); + $this->assertSame($outer_level, ob_get_level()); + $this->assertSame('OUTER;', ob_get_contents()); + + ob_end_clean(); + } + + public function testBufferStartRecordsItsOwnNestingLevel() + { + ob_start(); + + apbct_buffer__start(); + + $this->assertSame(ob_get_level(), apbct_buffer__own_level()); + + apbct_buffer__end(); + ob_end_clean(); + } + + public function testBufferEndFlushesNestedBuffersAndAppliesTheirCallbacks() + { + global $apbct; + + ob_start(); + echo 'OUTER;'; + $outer_level = ob_get_level(); + + apbct_buffer__start(); + echo 'HEAD:PLACEHOLDER;'; + + // Emulates WP late-printed styles hoisting: a buffer opened after ours that replaces + // its own content only when it is flushed. + ob_start(static function ($chunk) { + return str_replace('PLACEHOLDER', 'REAL_CSS', $chunk); + }); + echo 'BODY:PLACEHOLDER;'; + + ob_start(); + echo 'FOOTER;'; + + apbct_buffer__end(); + + $this->assertSame('HEAD:PLACEHOLDER;BODY:REAL_CSS;FOOTER;', $apbct->buffer); + $this->assertSame($outer_level, ob_get_level()); + $this->assertSame('OUTER;', ob_get_contents()); + + ob_end_clean(); + } + + public function testBufferEndDoesNotLeakPageContentIntoTheOuterBuffer() + { + global $apbct; + + ob_start(); + echo 'OUTER;'; + + apbct_buffer__start(); + echo 'PAGE;'; + ob_start(); + echo 'NESTED;'; + + apbct_buffer__end(); + + $outer_content = ob_get_contents(); + + $this->assertSame('OUTER;', $outer_content); + $this->assertStringNotContainsString('PAGE;', $outer_content); + $this->assertStringNotContainsString('NESTED;', $outer_content); + $this->assertSame('PAGE;NESTED;', $apbct->buffer); + + ob_end_clean(); + } +}