diff --git a/includes/class-newspack-popups-inserter.php b/includes/class-newspack-popups-inserter.php index d3647c0f..df125145 100755 --- a/includes/class-newspack-popups-inserter.php +++ b/includes/class-newspack-popups-inserter.php @@ -52,6 +52,28 @@ final class Newspack_Popups_Inserter { */ private static $header_template_part_has_rendered = false; + /** + * Overlay prompts queued for rendering at wp_footer, keyed by popup ID. A + * popup that would otherwise be emitted from multiple injection points + * (singular content, archive header, block-theme header) only renders once. + * Queue order is the insertion order – callers that care about which + * overlay wins the single visible slot (i.e. segmentation specificity) must + * queue most-specific first. + * + * @var array> + */ + private static $queued_overlays = []; + + /** + * Popup IDs whose scroll-trigger page-position marker has already been + * emitted inline this request. Parallel to {@see $queued_overlays} so the + * marker (which lives inside `.entry-content`, separate from the queued + * lightbox) also dedupes across multi-emission paths. + * + * @var array + */ + private static $emitted_markers = []; + /** * Constructor. */ @@ -65,6 +87,15 @@ public function __construct() { add_filter( 'render_block', [ $this, 'insert_inline_prompt_in_block_theme_archives' ], 10, 3 ); add_action( 'wp_before_admin_bar_render', [ $this, 'add_preview_toggle' ] ); + // Flush queued overlay prompts at wp_footer. Echoing from a wp_footer + // callback (with no surrounding container in the callback itself) lands + // the markup as a direct child of , which is what lets it escape + // any ancestor stacking context that would otherwise trap its z-index. + // Priority 9 runs *before* wp_print_footer_scripts (priority 20), so any + // asset that an overlay's content block / shortcode enqueues at render + // time still makes it into the page's footer scripts. + add_action( 'wp_footer', [ __CLASS__, 'print_queued_overlays' ], 9 ); + // Always enqueue scripts, since this plugin's scripts are handling pageview sending via GTAG. add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_scripts' ] ); add_action( 'apple_news_do_fetch_exporter', [ __CLASS__, 'apple_news_do_fetch_exporter' ] ); @@ -456,13 +487,20 @@ function ( $block_groups, $block ) use ( &$block_index, $parsed_blocks, $max_ind } } - // 4. Insert overlay prompts at the top of content. - // To leave the existing behavior (prepending each overlay) in place, - // we reverse our sorted overlays to ensure the most specific appear - // first in the DOM, and get priority for the single available slot. - $overlay_popups = array_reverse( self::sort_overlays_by_specificity( $overlay_popups ) ); - foreach ( $overlay_popups as $overlay_popup ) { - $output = '' . Newspack_Popups_Model::generate_popup( $overlay_popup ) . '' . $output; + // 4. Queue overlay prompts for footer rendering, most-specific first so + // it wins the single visible slot (the client-side reveal walks prompts + // in DOM order and picks the first that passes segment + frequency + // gates). Scroll-triggered overlays carry a page-position marker which + // must remain inline in `.entry-content` – its percentage `top` resolves + // against the article column and drives the IntersectionObserver that + // reveals the lightbox. Wrap the marker in a wp:html block so any + // downstream block-parser pass over the_content output leaves it alone. + foreach ( self::sort_overlays_by_specificity( $overlay_popups ) as $overlay_popup ) { + self::queue_overlay( $overlay_popup ); + $marker = self::emit_position_marker_inline( $overlay_popup ); + if ( '' !== $marker ) { + $output = '' . $marker . '' . $output; + } } return $output; } @@ -551,7 +589,11 @@ function ( $popup ) use ( $shortcoded_popups_ids ) { } /** - * Insert overlay prompts into archive pages if needed. Applies to Newspack Theme only. + * Queue archive-page overlay prompts (Newspack classic theme `after_header` + * hook). The actual lightbox markup is emitted later from + * {@see print_queued_overlays()}; the scroll-trigger page-position marker + * is emitted inline here so its `top` percentage resolves against the + * archive's content container rather than against ``. */ public static function insert_popups_after_header() { /* Posts and pages are covered by the_content hook */ @@ -564,10 +606,76 @@ function ( $popup ) { return Newspack_Popups_Model::should_be_inserted_in_page_content( $popup ) && Newspack_Popups_Model::is_overlay( $popup ); } ); - $popups = self::sort_overlays_by_specificity( array_values( $popups ) ); - foreach ( $popups as $popup ) { - echo Newspack_Popups_Model::generate_popup( $popup ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + foreach ( self::sort_overlays_by_specificity( array_values( $popups ) ) as $popup ) { + self::queue_overlay( $popup ); + echo self::emit_position_marker_inline( $popup ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + } + + /** + * Queue an overlay popup for rendering at wp_footer. + * + * Overlays are deduped by popup ID, so the same overlay reached from + * multiple injection points (singular content + above-header, archive + * header, etc.) only renders once. Insertion order is preserved – callers + * that care about specificity (the most-specific overlay winning the + * single visible slot) must queue most-specific first. + * + * @param array $popup Popup data as returned by Newspack_Popups_Model. + */ + private static function queue_overlay( array $popup ): void { + if ( empty( $popup['id'] ) ) { + return; + } + $id = $popup['id']; + if ( ! isset( self::$queued_overlays[ $id ] ) ) { + self::$queued_overlays[ $id ] = $popup; + } + } + + /** + * Return the inline page-position marker markup for a popup, deduped by ID + * across multiple emission paths so a single popup never produces duplicate + * marker DOM nodes (which would break `document.getElementById` lookups in + * the front-end reveal JS). Returns raw marker HTML; callers that emit into + * a context where the result may be re-parsed by the block parser + * (`the_content` output) are responsible for wrapping it in a `wp:html` + * block themselves. Returns the empty string for non-scroll-triggered + * popups and for popups whose marker has already been emitted this request. + * + * @param array $popup Popup data as returned by Newspack_Popups_Model. + * @return string Raw marker HTML, or '' when no marker should be emitted. + */ + private static function emit_position_marker_inline( array $popup ): string { + if ( empty( $popup['id'] ) || isset( self::$emitted_markers[ $popup['id'] ] ) ) { + return ''; } + $marker = Newspack_Popups_Model::generate_position_marker( $popup ); + if ( '' === $marker ) { + return ''; + } + self::$emitted_markers[ $popup['id'] ] = true; + return $marker; + } + + /** + * Flush queued overlay popups via wp_footer. With no surrounding container + * in the callback, the markup lands as a direct child of `` – escaping + * any ancestor stacking context (a transformed/scaled wrapper, a sticky ad + * container, an element with `isolation: isolate`, etc.) that would + * otherwise trap the lightbox's z-index below sibling content. Inline + * popups remain inline; only the overlay-typed placements are portaled. + * The scroll-trigger page-position marker stays inline at the content + * position (see {@see emit_position_marker_inline()}). + */ + public static function print_queued_overlays(): void { + if ( empty( self::$queued_overlays ) ) { + return; + } + foreach ( self::$queued_overlays as $popup ) { + echo Newspack_Popups_Model::generate_popup( $popup, false ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + self::$queued_overlays = []; } /** @@ -582,13 +690,22 @@ private static function get_before_header_markup() { return ''; } - // Sort only the overlay subset by specificity — above-header inline prompts are + // Sort only the overlay subset by specificity – above-header inline prompts are // not subject to the single visible overlay slot constraint and are left in their - // original order. + // original order. Overlay lightboxes are queued for footer rendering (escaping + // nested stacking contexts); the scroll-trigger marker is prepended inline so + // its `top` percentage still resolves against the header/post container. Inline + // (non-overlay) above-header prompts continue to be emitted inline here. $overlay_popups = self::sort_overlays_by_specificity( array_values( array_filter( $before_header_popups, [ 'Newspack_Popups_Model', 'is_overlay' ] ) ) ); - $inline_popups = array_values( + $markers = ''; + foreach ( $overlay_popups as $popup ) { + self::queue_overlay( $popup ); + $markers .= self::emit_position_marker_inline( $popup ); + } + + $inline_popups = array_values( array_filter( $before_header_popups, function( $popup ) { @@ -597,10 +714,7 @@ function( $popup ) { ) ); - $markup = ''; - foreach ( $overlay_popups as $popup ) { - $markup .= Newspack_Popups_Model::generate_popup( $popup ); - } + $markup = $markers; foreach ( $inline_popups as $popup ) { $markup .= Newspack_Popups_Model::generate_popup( $popup ); } diff --git a/includes/class-newspack-popups-model.php b/includes/class-newspack-popups-model.php index 8b3b8ca5..6b2819a9 100644 --- a/includes/class-newspack-popups-model.php +++ b/includes/class-newspack-popups-model.php @@ -1085,13 +1085,60 @@ public static function get_current_popup() { return self::$current_popup; } + /** + * Generate just the scroll-trigger page-position marker for an overlay popup. + * + * The marker is a `position: absolute; top: X%` element whose position is + * computed against its nearest `position: relative` ancestor (typically the + * post's `.entry-content`). An IntersectionObserver on this marker is what + * reveals the scroll-triggered overlay. The lightbox is portaled to + * wp_footer to escape ancestor stacking-context traps, but the marker + * stays inline at the article container so its percentage offset still + * encodes "scroll progress through the article". + * + * Returns the empty string for popups that aren't scroll-triggered overlays. + * + * @param array $popup A fully-hydrated popup object as returned + * by {@see create_popup_object()}. + * @return string Marker HTML, or '' when no marker is needed. + */ + public static function generate_position_marker( array $popup ): string { + if ( ! self::is_overlay( $popup ) ) { + return ''; + } + $trigger_type = $popup['options']['trigger_type'] ?? ''; + if ( 'scroll' !== $trigger_type ) { + return ''; + } + $element_id = self::canonize_popup_id( $popup['id'] ); + $progress = absint( $popup['options']['trigger_scroll_progress'] ?? 0 ); + return sprintf( + '
', + esc_attr( $element_id ), + $progress + ); + } + /** * Generate markup and styles for an overlay popup. * - * @param string $popup The popup object. + * @param array $popup A fully-hydrated popup object. + * @param bool $include_position_marker When true (default), the + * returned markup includes the + * scroll-trigger page-position + * marker. Pass false when the + * marker is emitted separately + * at the content position via + * {@see generate_position_marker()}, + * so the marker's percentage `top` + * resolves against its + * `position: relative` ancestor + * (typically `.entry-content`) + * rather than against the portaled + * lightbox's footer position. * @return string The generated markup. */ - public static function generate_popup( $popup ) { + public static function generate_popup( $popup, bool $include_position_marker = true ) { $previewed_popup_id = Newspack_Popups::previewed_popup_id(); $is_manual_or_custom_placement = self::is_manual_only( $popup ) || Newspack_Popups_Custom_Placements::is_custom_placement_or_manual( $popup ); @@ -1204,7 +1251,7 @@ class="" - +
(see Newspack_Popups_Inserter::print_queued_overlays), +// so these rules are no longer scoped to .entry-content. +.newspack-lightbox { + .newspack-popup__content-wrapper { + .alignfull, + .alignwide { + margin-left: 0; + margin-right: 0; + max-width: 100%; + padding-left: 0; + padding-right: 0; @media only screen and ( min-width: $width__mobile ) { - .alignfull.wp-block-columns { + &.wp-block-columns { margin-left: -16px; margin-right: -16px; max-width: calc(100% + 32px); width: calc(100% + 32px); } } + + &.wp-block-columns, + &.wp-block-columns .wp-block-column { + padding-left: 0; + padding-right: 0; + } } } } diff --git a/tests/test-block-theme-header-insertion.php b/tests/test-block-theme-header-insertion.php index 34caf85b..bf1bef24 100644 --- a/tests/test-block-theme-header-insertion.php +++ b/tests/test-block-theme-header-insertion.php @@ -255,13 +255,20 @@ public function test_overlay_specificity_order_is_preserved() { $block_content = '
header content
'; $block = $this->get_header_template_part_block(); - $result = Newspack_Popups_Inserter::insert_before_header_in_template_part( $block_content, $block ); + Newspack_Popups_Inserter::insert_before_header_in_template_part( $block_content, $block ); - $segment_pos = strpos( $result, 'Segment overlay' ); - $generic_pos = strpos( $result, 'Generic overlay' ); + // Overlays are portaled to wp_footer rather than inlined into the + // header template-part output; assert ordering against the queued + // flush, which is what gets emitted before . + ob_start(); + Newspack_Popups_Inserter::print_queued_overlays(); + $footer_output = ob_get_clean(); - $this->assertNotFalse( $segment_pos, 'Segment-specific overlay should be rendered.' ); - $this->assertNotFalse( $generic_pos, 'Generic overlay should be rendered.' ); - $this->assertLessThan( $generic_pos, $segment_pos, 'Segment-specific overlay should render before generic overlay.' ); + $segment_pos = strpos( $footer_output, 'Segment overlay' ); + $generic_pos = strpos( $footer_output, 'Generic overlay' ); + + $this->assertNotFalse( $segment_pos, 'Segment-specific overlay should be queued for footer rendering.' ); + $this->assertNotFalse( $generic_pos, 'Generic overlay should be queued for footer rendering.' ); + $this->assertLessThan( $generic_pos, $segment_pos, 'Segment-specific overlay should be emitted before generic overlay.' ); } } diff --git a/tests/test-insertion.php b/tests/test-insertion.php index 584b3f40..0e2a2927 100755 --- a/tests/test-insertion.php +++ b/tests/test-insertion.php @@ -51,12 +51,23 @@ public function test_insertion_on_page() { $overlay_id = self::createPopup( $overlay_content, [ 'placement' => 'center' ] ); $page_with_shortcode = '[newspack-popups id="' . $overlay_id . '"]'; self::renderPost( '', $page_with_shortcode, [], [], 'page' ); - $overlay_text_content = self::$dom_xpath->query( '//*[contains(@class,"newspack-popup-container")]' )->item( 0 )->textContent; - self::assertStringContainsString( - $overlay_content, - $overlay_text_content, - 'Inserts the overlay prompt on a page.' + // Overlays are portaled to wp_footer, so the inline default popup + // (from set_up) is item(0) and the overlay sits later in the combined + // content+footer markup. Locate the overlay by its content instead of + // by DOM position. + $popup_elements = self::$dom_xpath->query( '//*[contains(@class,"newspack-popup-container")]' ); + $found_overlay = false; + foreach ( $popup_elements as $popup_element ) { + if ( false !== strpos( $popup_element->textContent, $overlay_content ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOMNode property. + $found_overlay = true; + break; + } + } + + self::assertTrue( + $found_overlay, + 'Inserts the overlay prompt on a page (portaled to footer, located via content).' ); } diff --git a/tests/test-overlay-queueing.php b/tests/test-overlay-queueing.php new file mode 100644 index 00000000..8d0748db --- /dev/null +++ b/tests/test-overlay-queueing.php @@ -0,0 +1,383 @@ + + * and escapes any ancestor stacking context (transformed wrapper, sticky ad + * container, isolation:isolate, etc.) that would otherwise trap the popup's + * z-index below sibling content. + * + * @package Newspack_Popups + */ + +/** + * OverlayQueueing test case. + */ +class OverlayQueueingTest extends WP_UnitTestCase { + + public function set_up() { // phpcs:ignore Squiz.Commenting.FunctionComment.Missing + parent::set_up(); + // Drain any queued overlays carried over from prior tests. + ob_start(); + Newspack_Popups_Inserter::print_queued_overlays(); + ob_end_clean(); + } + + /** + * Build an overlay popup object via the real model so it carries every + * default option the markup generator expects. + * + * @param string $title Optional title. + * @param string $trigger_type 'time' or 'scroll'. Scroll-triggered overlays + * ship with a page-position marker that must stay + * inline in the content. + * @return array Popup object as consumed by the inserter. + */ + private static function create_overlay_popup_object( $title = 'Overlay prompt', $trigger_type = 'time' ) { + $popup_id = self::factory()->post->create( + [ + 'post_type' => Newspack_Popups::NEWSPACK_POPUPS_CPT, + 'post_title' => $title, + 'post_content' => 'Overlay body for ' . $title, + ] + ); + Newspack_Popups_Model::set_popup_options( + $popup_id, + [ + 'placement' => 'center', + 'trigger_type' => $trigger_type, + ] + ); + return Newspack_Popups_Model::create_popup_object( get_post( $popup_id ) ); + } + + /** + * The returned content from insert_popups_in_post_content must NOT include + * the overlay markup — it should be queued for wp_footer instead. + */ + public function test_overlay_not_inlined_into_returned_content() { + $overlay_popup = self::create_overlay_popup_object(); + $post_content = "\n

Body paragraph.

\n\n"; + + $returned_content = Newspack_Popups_Inserter::insert_popups_in_post_content( + $post_content, + [ $overlay_popup ] + ); + + self::assertStringNotContainsString( + 'newspack-lightbox', + $returned_content, + 'Overlay markup must not be inlined into post content; it is queued for wp_footer.' + ); + self::assertStringNotContainsString( + '', + $returned_content, + 'The legacy wp:html wrapper around inlined overlay markup must no longer appear in returned content.' + ); + self::assertStringContainsString( + 'Body paragraph.', + $returned_content, + 'Original post content must be preserved.' + ); + } + + /** + * After insert_popups_in_post_content queues an overlay, print_queued_overlays + * emits the overlay markup once. + */ + public function test_queued_overlay_is_emitted_at_footer() { + $overlay_popup = self::create_overlay_popup_object(); + Newspack_Popups_Inserter::insert_popups_in_post_content( '

Body.

', [ $overlay_popup ] ); + + ob_start(); + Newspack_Popups_Inserter::print_queued_overlays(); + $footer_output = ob_get_clean(); + + self::assertStringContainsString( + 'newspack-lightbox', + $footer_output, + 'print_queued_overlays must emit the queued overlay markup.' + ); + } + + /** + * Queueing the same overlay popup from multiple call paths (e.g. singular + * content + above-header) must result in a single emission, deduped by ID. + * + * Asserted by counting the number of outer lightbox containers (`id="id_"`) + * for the popup in question, not by total output length – more direct, and + * stable against any future per-emission variability inside the markup. + */ + public function test_dedupe_by_id_across_multiple_queue_calls() { + $overlay_popup = self::create_overlay_popup_object(); + $expected_id = 'id="' . Newspack_Popups_Model::canonize_popup_id( $overlay_popup['id'] ) . '"'; + + // Queue the SAME popup multiple times via repeated calls. + Newspack_Popups_Inserter::insert_popups_in_post_content( '

Body.

', [ $overlay_popup ] ); + Newspack_Popups_Inserter::insert_popups_in_post_content( '

Body.

', [ $overlay_popup ] ); + Newspack_Popups_Inserter::insert_popups_in_post_content( '

Body.

', [ $overlay_popup ] ); + + ob_start(); + Newspack_Popups_Inserter::print_queued_overlays(); + $footer_output = ob_get_clean(); + + self::assertSame( + 1, + substr_count( $footer_output, $expected_id ), + 'Queueing the same popup multiple times must still produce exactly one lightbox container.' + ); + } + + /** + * The dedupe map is shared across injection points: a popup reached from + * `insert_popups_in_post_content` (singular content path) AND the helper + * that backs the classic / block-theme above-header path must still result + * in a single emission. The factory default trigger (`time`) is what would + * normally route a popup through the above-header path in production; the + * test here asserts the dedupe contract on `queue_overlay()` itself, not + * the per-callsite eligibility filtering. + */ + public function test_dedupe_across_injection_points() { + $overlay_popup = self::create_overlay_popup_object( 'Cross-path overlay', 'time' ); + $expected_id = 'id="' . Newspack_Popups_Model::canonize_popup_id( $overlay_popup['id'] ) . '"'; + + Newspack_Popups_Inserter::insert_popups_in_post_content( '

Body.

', [ $overlay_popup ] ); + + // Reach the queue from a second path by invoking the private queue + // helper directly. + $reflection_method = new ReflectionMethod( 'Newspack_Popups_Inserter', 'queue_overlay' ); + $reflection_method->setAccessible( true ); + $reflection_method->invoke( null, $overlay_popup ); + + ob_start(); + Newspack_Popups_Inserter::print_queued_overlays(); + $footer_output = ob_get_clean(); + + self::assertSame( + 1, + substr_count( $footer_output, $expected_id ), + 'A popup queued from two different injection points must emit exactly once.' + ); + } + + /** + * The `insert_popups_after_header` archive path (classic Newspack theme) + * must still emit the scroll-trigger page-position marker inline – the + * IntersectionObserver reveal mechanism needs it to find a marker DOM node + * to observe. The lightbox itself is still queued for the footer flush. + */ + public function test_classic_archive_path_emits_marker_inline_for_scroll_triggered() { + $overlay_popup = self::create_overlay_popup_object( 'Archive scroll overlay', 'scroll' ); + + // Pretend we're rendering an archive page. + global $wp_query; + $prior_singular = $wp_query ? $wp_query->is_singular : false; + $wp_query->is_singular = false; + + // Force `popups_for_post()` to return our overlay rather than running + // the eligibility query, which depends on a fully bootstrapped request. + $reflection_class = new ReflectionClass( 'Newspack_Popups_Inserter' ); + $popups_property = $reflection_class->getProperty( 'popups' ); + $popups_property->setAccessible( true ); + $prior_popups = $popups_property->getValue(); + $popups_property->setValue( null, [ $overlay_popup ] ); + + ob_start(); + Newspack_Popups_Inserter::insert_popups_after_header(); + $inline_output = ob_get_clean(); + + // Restore globals. + $popups_property->setValue( null, $prior_popups ); + $wp_query->is_singular = $prior_singular; + + self::assertStringContainsString( + 'page-position-marker_', + $inline_output, + 'Classic-theme archive scroll-triggered overlays must emit the page-position marker inline.' + ); + self::assertStringNotContainsString( + 'newspack-lightbox', + $inline_output, + 'The lightbox markup must NOT be emitted inline from the archive path; it should be queued for the footer.' + ); + + ob_start(); + Newspack_Popups_Inserter::print_queued_overlays(); + $footer_output = ob_get_clean(); + + self::assertStringContainsString( + 'newspack-lightbox', + $footer_output, + 'The lightbox must be emitted at wp_footer for archive overlays.' + ); + } + + /** + * Inline placements must still be inlined into post content — only + * overlay-typed placements are portaled. + */ + public function test_inline_placement_remains_in_returned_content() { + $inline_popup = [ + 'id' => wp_rand(), + 'content' => 'Inline content.', + 'options' => [ + 'placement' => 'inline', + 'trigger_type' => 'scroll', + 'trigger_scroll_progress' => '0', + 'trigger_blocks_count' => '0', + ], + ]; + $returned_content = Newspack_Popups_Inserter::insert_popups_in_post_content( + "\n

Body.

\n\n", + [ $inline_popup ] + ); + + self::assertStringContainsString( + '[newspack-popup id="' . $inline_popup['id'] . '"]', + $returned_content, + 'Inline popups must still be emitted into post content as the shortcode block.' + ); + + ob_start(); + Newspack_Popups_Inserter::print_queued_overlays(); + $footer_output = ob_get_clean(); + + self::assertSame( + '', + $footer_output, + 'Inline popups must never reach the overlay footer queue.' + ); + } + + /** + * Mixed batch: one inline + one overlay. Inline goes inline; overlay goes + * to the footer queue. + */ + public function test_inline_and_overlay_route_to_their_respective_paths() { + $overlay_popup = self::create_overlay_popup_object(); + $inline_popup = [ + 'id' => wp_rand(), + 'content' => 'Inline content.', + 'options' => [ + 'placement' => 'inline', + 'trigger_type' => 'scroll', + 'trigger_scroll_progress' => '0', + 'trigger_blocks_count' => '0', + ], + ]; + + $returned_content = Newspack_Popups_Inserter::insert_popups_in_post_content( + "\n

Body.

\n\n", + [ $inline_popup, $overlay_popup ] + ); + + self::assertStringContainsString( + '[newspack-popup id="' . $inline_popup['id'] . '"]', + $returned_content, + 'Inline popup must still be inlined.' + ); + self::assertStringNotContainsString( + 'newspack-lightbox', + $returned_content, + 'Overlay popup must not leak into post content when batched with an inline popup.' + ); + + ob_start(); + Newspack_Popups_Inserter::print_queued_overlays(); + $footer_output = ob_get_clean(); + + self::assertStringContainsString( + 'newspack-lightbox', + $footer_output, + 'Overlay popup must be emitted at the footer.' + ); + self::assertStringNotContainsString( + '[newspack-popup id="' . $inline_popup['id'] . '"]', + $footer_output, + 'Inline popup must never leak into the footer queue output.' + ); + } + + /** + * Scroll-triggered overlays must keep their page-position marker inline in + * the post content (the IntersectionObserver mechanism for scroll-trigger + * needs the marker positioned against `.entry-content`). The lightbox itself + * is still portaled to the footer queue. + */ + public function test_scroll_triggered_overlay_marker_stays_inline() { + $overlay_popup = self::create_overlay_popup_object( 'Scroll overlay', 'scroll' ); + + $returned_content = Newspack_Popups_Inserter::insert_popups_in_post_content( + "\n

Body.

\n\n", + [ $overlay_popup ] + ); + + self::assertStringContainsString( + 'page-position-marker_', + $returned_content, + 'Scroll-triggered overlays must emit their page-position marker inline in the post content.' + ); + self::assertStringNotContainsString( + 'newspack-lightbox', + $returned_content, + 'The lightbox itself must still be queued for footer rendering, not emitted inline.' + ); + + ob_start(); + Newspack_Popups_Inserter::print_queued_overlays(); + $footer_output = ob_get_clean(); + + self::assertStringContainsString( + 'newspack-lightbox', + $footer_output, + 'The lightbox should be emitted at wp_footer.' + ); + self::assertStringNotContainsString( + 'page-position-marker_', + $footer_output, + 'The footer-emitted lightbox must NOT carry a duplicate page-position marker; the inline one is the one that drives scroll trigger.' + ); + } + + /** + * Time-triggered overlays have no page-position marker. None should be + * emitted inline, and the lightbox is queued for footer. + */ + public function test_time_triggered_overlay_has_no_inline_marker() { + $overlay_popup = self::create_overlay_popup_object( 'Time overlay', 'time' ); + + $returned_content = Newspack_Popups_Inserter::insert_popups_in_post_content( + "\n

Body.

\n\n", + [ $overlay_popup ] + ); + + self::assertStringNotContainsString( + 'page-position-marker_', + $returned_content, + 'Time-triggered overlays must not produce a page-position marker.' + ); + } + + /** + * Flushing the queue must clear it: a second flush emits nothing. + */ + public function test_flush_clears_the_queue() { + $overlay_popup = self::create_overlay_popup_object(); + Newspack_Popups_Inserter::insert_popups_in_post_content( '

Body.

', [ $overlay_popup ] ); + + ob_start(); + Newspack_Popups_Inserter::print_queued_overlays(); + ob_end_clean(); + + ob_start(); + Newspack_Popups_Inserter::print_queued_overlays(); + $second_flush = ob_get_clean(); + + self::assertSame( + '', + $second_flush, + 'Flushing the queue must drain it; a subsequent flush should be a no-op.' + ); + } +} diff --git a/tests/wp-unittestcase-pagewithpopups.php b/tests/wp-unittestcase-pagewithpopups.php index 3a876755..69027d76 100644 --- a/tests/wp-unittestcase-pagewithpopups.php +++ b/tests/wp-unittestcase-pagewithpopups.php @@ -116,9 +116,25 @@ protected function renderPost( $url_query = '', $post_content_override = null, $ // Reset internal duplicate-prevention. Newspack_Popups_Inserter::$the_content_has_rendered = false; + // Drain any overlays queued from previous renders in this test class. + ob_start(); + Newspack_Popups_Inserter::print_queued_overlays(); + ob_end_clean(); + $content = get_post( $post_id )->post_content; - self::$post_content = apply_filters( 'the_content', $content ); + $filtered_content = apply_filters( 'the_content', $content ); + + // Overlay prompts are now portaled to wp_footer rather than emitted + // inside post content. Concatenate the flushed footer output so the + // existing DOM-based assertions can still find overlay markup via the + // `newspack-popup-container` class, mirroring how the rendered page + // actually looks in the browser (post body + overlays before ). + ob_start(); + Newspack_Popups_Inserter::print_queued_overlays(); + $footer_overlays = ob_get_clean(); + + self::$post_content = $filtered_content . $footer_overlays; $dom = new DomDocument(); @$dom->loadHTML( self::$post_content ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged self::$dom_xpath = new DOMXpath( $dom );