+ */
+ private $inlined = array();
+
/**
* Initialize the module.
*
@@ -47,7 +68,8 @@ public function run() {
add_action( 'enqueue_block_assets', array( $this, 'enqueue_base_css' ) );
add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_icons_data' ) );
add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor_assets' ) );
- add_action( 'wp_enqueue_scripts', array( $this, 'output_cached_css' ) );
+ add_action( 'wp_enqueue_scripts', array( $this, 'output_singular_css' ), 11 );
+ add_action( 'wp_enqueue_scripts', array( $this, 'maybe_enqueue_style_builder' ) );
add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'register_frontend_animations' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'register_frontend_states' ) );
@@ -55,6 +77,8 @@ public function run() {
add_filter( 'render_block', array( $this, 'render_post_fields' ), 20, 2 );
add_filter( 'render_block', array( $this, 'render_animation_attrs' ), 10, 2 );
add_filter( 'render_block', array( $this, 'render_state_attrs' ), 10, 2 );
+ add_filter( 'render_block', array( $this, 'track_rendered_blocks' ), 10, 2 );
+ add_action( 'wp_footer', array( $this, 'output_late_css' ), 19 );
add_action( 'template_redirect', array( $this, 'maybe_render_css_warm_page' ), 0 );
add_action( 'save_post', array( $this, 'clear_cached_css' ) );
add_filter( 'block_categories_all', array( $this, 'register_category' ) );
@@ -161,18 +185,24 @@ public function enqueue_tailwind_generator() {
* @return void
*/
public function enqueue_base_css() {
+ $css = '[class*="wp-block-atomic-wind-"]{margin:0;max-width:unset;}[class*="wp-block-atomic-wind-"] p{margin:0;}';
+
+ // Always reset frontend blocks, including those outside the main query.
+ if ( ! is_admin() ) {
+ wp_register_style( 'atomic-wind-base', false, [], OTTER_BLOCKS_VERSION );
+ wp_add_inline_style( 'atomic-wind-base', $css );
+ return;
+ }
+
global $post;
-
+
if ( ! $post || ! $this->post_has_atomic_wind_blocks( $post ) ) {
return;
}
-
+
wp_register_style( 'atomic-wind-base', false, [], OTTER_BLOCKS_VERSION );
wp_enqueue_style( 'atomic-wind-base' );
- $css = '[class*="wp-block-atomic-wind-"]{margin:0;max-width:unset;}[class*="wp-block-atomic-wind-"] p{margin:0;}';
- if ( is_admin() ) {
- $css .= '.editor-styles-wrapper .wp-block[class*="wp-block-atomic-wind-"]{margin:0;max-width:unset;}.editor-styles-wrapper [class*="wp-block-atomic-wind-"] p{margin:0;}';
- }
+ $css .= '.editor-styles-wrapper .wp-block[class*="wp-block-atomic-wind-"]{margin:0;max-width:unset;}.editor-styles-wrapper [class*="wp-block-atomic-wind-"] p{margin:0;}';
wp_add_inline_style( 'atomic-wind-base', $css );
}
@@ -252,69 +282,189 @@ public function enqueue_editor_assets() {
}
/**
- * Output cached Tailwind CSS or enqueue the generator + style-builder.
+ * Enqueue the frontend Tailwind fallback.
*
* @return void
*/
- public function output_cached_css() {
- global $post;
+ private function enqueue_generator() {
+ $generator_asset = $this->build_path() . '/tailwind-generator-frontend.asset.php';
- if ( ! $post ) {
+ if ( ! file_exists( $generator_asset ) ) {
return;
}
- if ( ! $this->post_has_atomic_wind_blocks( $post ) ) {
+ $gen = include $generator_asset;
+ wp_enqueue_script(
+ 'atomic-wind-tailwind-generator',
+ $this->plugin_url( 'build/atomic-wind/tailwind-generator-frontend.js' ),
+ $gen['dependencies'],
+ $gen['version'],
+ true
+ );
+ }
+
+ /**
+ * Load the queried post's CSS in the head on singular views.
+ *
+ * The post is guaranteed to render, so inlining its cached CSS early avoids
+ * a flash of unstyled content for the main content. Everything else (hooked
+ * layouts, embeds) stays on the render-tracked footer path.
+ *
+ * @return void
+ */
+ public function output_singular_css() {
+ if ( ! is_singular() ) {
return;
}
- $cached_css = get_post_meta( $post->ID, '_atomic_wind_css', true );
+ $queried = get_queried_object();
- if ( $cached_css ) {
- wp_register_style( 'atomic-wind-tailwind', false, [], OTTER_BLOCKS_VERSION );
- wp_enqueue_style( 'atomic-wind-tailwind' );
- wp_add_inline_style( 'atomic-wind-tailwind', $cached_css );
+ if ( ! $queried instanceof \WP_Post || ! $this->post_has_atomic_wind_blocks( $queried ) ) {
return;
}
- $generator_asset = $this->build_path() . '/tailwind-generator-frontend.asset.php';
+ $this->expected[ $queried->ID ] = substr_count( $queried->post_content, 'E2E inner pattern content
',
+ )
+ );
+
+ // One registered and one unresolvable reference — one insert covers both paths.
+ register_block_pattern(
+ 'otter-e2e/pattern-reference-fixture',
+ array(
+ 'title' => 'E2E Pattern Reference Fixture',
+ 'categories' => array( 'otter-blocks' ),
+ 'content' => 'E2E reference fixture heading
',
+ )
+ );
+ },
+ 100
+);
diff --git a/src/animation/frontend.js b/src/animation/frontend.js
index 832cd46f1..55bc47b8e 100644
--- a/src/animation/frontend.js
+++ b/src/animation/frontend.js
@@ -160,7 +160,7 @@ const speed = [ 'none', 'slow', 'slower', 'fast', 'faster' ];
const elementsScroll = [];
let scrollListenerAttached = false;
-const processElement = ( element ) => {
+const processElement = ( element, visible = isElementInViewport( element ) ) => {
// Skip if already processed
if ( element.classList.contains( 'o-anim-ready' ) ) {
return;
@@ -169,7 +169,7 @@ const processElement = ( element ) => {
const classes = element.classList;
element.animationClasses = [];
- if ( ! isElementInViewport( element ) ) {
+ if ( ! visible ) {
const animationClass = animations.find( ( i ) => {
return Array.from( classes ).find( ( o ) => o === i );
});
@@ -283,8 +283,14 @@ const animateElements = () => {
createCustomAnimationNode( elements );
+ // Measure every element before processing marks any of them o-anim-ready:
+ const inViewport = new Map();
for ( const element of elements ) {
- processElement( element );
+ inViewport.set( element, isElementInViewport( element ) );
+ }
+
+ for ( const element of elements ) {
+ processElement( element, inViewport.get( element ) );
}
attachScrollListener();
diff --git a/src/blocks/blocks/popup/style.scss b/src/blocks/blocks/popup/style.scss
index 7db46eaaf..5e718a98a 100644
--- a/src/blocks/blocks/popup/style.scss
+++ b/src/blocks/blocks/popup/style.scss
@@ -122,6 +122,9 @@ $base-index: 99999 !default;
position: absolute;
right: var( --padding );
+ // Keep the close button above positioned popup content, e.g. Section columns.
+ z-index: 2;
+
@media (max-width: 600px) {
padding-bottom: 0px;
}
@@ -135,7 +138,6 @@ $base-index: 99999 !default;
padding: 0 !important;
border: none;
cursor: pointer;
- z-index: 2;
&:hover {
opacity: .75;
diff --git a/src/blocks/plugins/patterns-library/library.js b/src/blocks/plugins/patterns-library/library.js
index 8fa8859d1..342dda7c4 100644
--- a/src/blocks/plugins/patterns-library/library.js
+++ b/src/blocks/plugins/patterns-library/library.js
@@ -38,6 +38,26 @@ import {
import { accentContent } from './accent';
+import { resolvePatternBlocks } from '../../../onboarding/utils.js';
+
+// WP 7.0 stamps blocks inlined from wp:pattern refs with metadata.patternName
+// and locks them into content-only mode. Inserts are unsynced copies — drop
+// the stamp, keep the rest of metadata (bindings, labels).
+const stripPatternAttribution = (blocks) =>
+ blocks.map((block) => {
+ const metadata = { ...(block.attributes?.metadata || {}) };
+ delete metadata.patternName;
+
+ return {
+ ...block,
+ attributes: {
+ ...block.attributes,
+ metadata: Object.keys(metadata).length ? metadata : undefined,
+ },
+ innerBlocks: stripPatternAttribution(block.innerBlocks || []),
+ };
+ });
+
const CLOUD_EMPTY_CATEGORY = 'cloud-empty';
// Section categories bucketed into meaningful sidebar groups. Mirrors the
@@ -574,8 +594,12 @@ const Library = ({ onClose }) => {
}
// With Pro active the upsell banner removes itself right after
- // insertion anyway — skip it up front.
- const blocks = parse(accentContent(pattern, accent)).filter(
+ // insertion anyway — skip it up front. Inline wp:pattern refs while
+ // parsing: inserted raw they render locked, or not at all for
+ // unregistered slugs.
+ const blocks = stripPatternAttribution(
+ resolvePatternBlocks(parse(accentContent(pattern, accent)), allPatterns),
+ ).filter(
(block) =>
UPSELL_BLOCK !== block.name ||
!Boolean(window.themeisleGutenberg?.hasPro),
@@ -660,7 +684,7 @@ const Library = ({ onClose }) => {
onClose();
},
- [ clientID, accent ],
+ [ clientID, accent, allPatterns ],
);
const resetFilters = () => {
diff --git a/src/blocks/test/e2e/blocks/animations.spec.js b/src/blocks/test/e2e/blocks/animations.spec.js
index 10ed9e4a4..7d8fcc627 100644
--- a/src/blocks/test/e2e/blocks/animations.spec.js
+++ b/src/blocks/test/e2e/blocks/animations.spec.js
@@ -3,11 +3,46 @@
*/
import { test, expect } from '@wordpress/e2e-test-utils-playwright';
+/**
+ * Internal dependencies
+ */
+import { publishAndViewPost } from '../helpers/editor';
+
test.describe( 'Animations', () => {
test.beforeEach( async({ admin }) => {
await admin.createNewPost();
});
+ test( 'nested animated block plays on load inside a transform-animated parent', async({ editor, page }) => {
+
+ await page.setViewportSize({ width: 1280, height: 900 });
+
+ await editor.insertBlock({
+ name: 'core/cover',
+ attributes: {
+ overlayColor: 'black',
+ dimRatio: 100,
+ minHeight: 800,
+ contentPosition: 'top center',
+ className: 'animated slideInDown'
+ },
+ innerBlocks: [
+ {
+ name: 'core/heading',
+ attributes: {
+ content: 'Nested Animated Heading',
+ className: 'animated fadeInLeft delay-1s'
+ }
+ }
+ ]
+ });
+
+ await publishAndViewPost({ editor, page });
+
+ // Without any scrolling, the nested heading must animate in after its delay.
+ await expect( page.getByText( 'Nested Animated Heading' ) ).toBeVisible({ timeout: 10000 });
+ });
+
test( 'can add a typing animation"', async({ editor, page }) => {
await editor.insertBlock({
name: 'core/paragraph',
diff --git a/src/blocks/test/e2e/blocks/design-library.spec.js b/src/blocks/test/e2e/blocks/design-library.spec.js
index 785c1ef30..d1430bec9 100644
--- a/src/blocks/test/e2e/blocks/design-library.spec.js
+++ b/src/blocks/test/e2e/blocks/design-library.spec.js
@@ -244,6 +244,39 @@ test.describe( 'Design Library', () => {
page.locator( '.components-snackbar', { hasText: title })
).toBeVisible();
});
+
+ test( 'expands wp:pattern references into editable blocks on insert', async({ page, editor }) => {
+
+ // Fixture (otter-e2e-pattern-fixtures.php): a heading plus one
+ // registered and one missing wp:pattern reference. Inserts must
+ // arrive as plain editable blocks — refs inlined, missing ones
+ // dropped, never a locked core/pattern wrapper. See #2854.
+ const modal = await openLibrary( page );
+ await waitForGrid( modal );
+
+ await modal.locator( '.o-library__search' ).fill( 'E2E Pattern Reference Fixture' );
+
+ const card = firstCard( modal );
+ await card.hover();
+ await card.getByRole( 'button', { name: 'Insert', exact: true }).click();
+
+ await expect( modal ).toHaveCount( 0 );
+ await waitForEditorReady( page );
+
+ const blocks = await editor.getBlocks();
+ const names = blocks.map( ( block ) => block.name );
+
+ expect( names ).toContain( 'core/heading' );
+ expect( names ).toContain( 'core/paragraph' );
+ expect( names ).not.toContain( 'core/pattern' );
+
+ // WP 7.0 stamps inlined blocks with metadata.patternName and locks
+ // them into content-only mode — inserts must shed it.
+ const stamped = blocks
+ .filter( ( block ) => block.attributes?.metadata?.patternName )
+ .map( ( block ) => block.name );
+ expect( stamped ).toEqual( [] );
+ });
});
test.describe( 'Pro upsells', () => {
diff --git a/src/blocks/test/e2e/blocks/form-turnstile.spec.js b/src/blocks/test/e2e/blocks/form-turnstile.spec.js
index 3d3673712..22c615205 100644
--- a/src/blocks/test/e2e/blocks/form-turnstile.spec.js
+++ b/src/blocks/test/e2e/blocks/form-turnstile.spec.js
@@ -2,8 +2,8 @@
* Internal dependencies
*/
import { test, expect } from '../fixtures';
-import { getBlockByName, expectBlockByName, publishAndViewPost } from '../helpers/editor';
-import { getFormClientId, insertContactForm, insertFormCaptchaBlock } from '../helpers/forms';
+import { getBlockByName, expectBlockByName, publishAndViewPost, publishPostReliable } from '../helpers/editor';
+import { expectFormOptionSavedNotice, findSavedFormEmail, getFormClientId, getSavedFormEmails, insertContactForm, insertFormCaptchaBlock, prepareFormOptionsInspector, showFormOption } from '../helpers/forms';
const CAPTCHA_BLOCK = 'themeisle-blocks/form-captcha';
@@ -12,7 +12,11 @@ test.describe( 'Form Block - Captcha block', () => {
test.beforeEach( async({ admin, otterUtils }) => {
await otterUtils.setOptions({
themeisle_cloudflare_turnstile_site_key: 'turnstile-sitekey',
- themeisle_cloudflare_turnstile_secret_key: 'turnstile-secret'
+ themeisle_cloudflare_turnstile_secret_key: 'turnstile-secret',
+
+ // Start from a clean form-options state (see form.spec.js).
+ themeisle_blocks_form_emails: [],
+ themeisle_blocks_form_fields_option: []
});
await admin.createNewPost();
@@ -80,6 +84,55 @@ test.describe( 'Form Block - Captcha block', () => {
await expect( page.locator( `#${formId} .o-form-server-response.o-success` ) ).toBeVisible({ timeout: 15000 });
});
+ // Regression for #2919: `captchaProvider` was missing from the
+ // `themeisle_blocks_form_emails` REST schema, so the settings endpoint
+ // (which forces `additionalProperties: false`) rejected the entire
+ // form-options save whenever a captcha was present.
+ test( 'saves form options when a Turnstile captcha is present', async({ editor, page }) => {
+ const ccValue = 'otter@turnstile-form.com';
+
+ await insertContactForm({ editor, page });
+
+ const formClientId = await getFormClientId( page );
+ expect( formClientId ).toBeTruthy();
+
+ await insertFormCaptchaBlock( page, formClientId, 'turnstile' );
+
+ await expect.poll( async() => {
+ const form = await getBlockByName( editor, 'themeisle-blocks/form' );
+ return form?.innerBlocks?.filter( ({ name }) => CAPTCHA_BLOCK === name )?.length;
+ }).toBe( 1 );
+
+ // Inserting the captcha selects it; the Form Options panel only shows
+ // in the Form block's own inspector.
+ await page.evaluate( ( clientId ) => {
+ window.wp.data.dispatch( 'core/block-editor' ).selectBlock( clientId );
+ }, formClientId );
+
+ await prepareFormOptionsInspector( editor, page );
+
+ await showFormOption( page, 'Show CC' );
+
+ const cc = page.getByPlaceholder( 'Send copies to' );
+ await cc.fill( ccValue );
+
+ await publishPostReliable( editor, page );
+
+ // Without the schema fix the settings request fails with
+ // `rest_additional_properties_forbidden` and this notice never shows.
+ await expectFormOptionSavedNotice( page );
+
+ const formBlock = await expectBlockByName( editor, 'themeisle-blocks/form' );
+ expect( formBlock.attributes.optionName ).toBeTruthy();
+
+ const databaseEmails = await getSavedFormEmails( page );
+ const savedEmail = findSavedFormEmail( databaseEmails, formBlock.attributes.optionName );
+
+ expect( savedEmail ).toBeTruthy();
+ expect( savedEmail?.cc ).toBe( ccValue );
+ expect( savedEmail?.captchaProvider ).toBe( 'turnstile' );
+ });
+
test( 'keeps a single Captcha block per form', async({ editor, page }) => {
await insertContactForm({ editor, page });
diff --git a/src/blocks/test/e2e/blocks/popup.spec.js b/src/blocks/test/e2e/blocks/popup.spec.js
index f080d160e..5c0e339fb 100644
--- a/src/blocks/test/e2e/blocks/popup.spec.js
+++ b/src/blocks/test/e2e/blocks/popup.spec.js
@@ -115,4 +115,60 @@ test.describe( 'Popup', () => {
await page.keyboard.press( 'Enter' );
await expect( page.getByText( 'Popup Content Test' ) ).toBeHidden();
});
+
+ test( 'inside close button receives clicks when content has positioned columns', async({ editor, page }) => {
+
+ // Section columns are position:relative on desktop; without a z-index on
+ // the header they paint over the close button and swallow its clicks.
+ // See https://github.com/Codeinwp/otter-blocks/issues/2863
+ await page.setViewportSize({ width: 1280, height: 800 });
+
+ await editor.insertBlock({
+ name: 'themeisle-blocks/popup',
+ attributes: {},
+ innerBlocks: [
+ {
+ name: 'themeisle-blocks/advanced-columns',
+ attributes: {
+ columns: 2,
+ layout: 'equal'
+ },
+ innerBlocks: [
+ {
+ name: 'themeisle-blocks/advanced-column',
+ innerBlocks: [
+ {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'Popup Content Test'
+ }
+ }
+ ]
+ },
+ {
+ name: 'themeisle-blocks/advanced-column',
+ innerBlocks: [
+ {
+ name: 'core/paragraph',
+ attributes: {
+ content: 'Second Column'
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ });
+
+ await publishAndViewPost({ editor, page });
+
+ await expect( page.getByText( 'Popup Content Test' ) ).toBeVisible();
+
+ // Playwright refuses to click covered elements, so this fails if the
+ // columns overlay the button.
+ await page.getByRole( 'button', { name: 'Close' }).click();
+
+ await expect( page.getByText( 'Popup Content Test' ) ).toBeHidden();
+ });
});
diff --git a/tests/test-atomic-wind-blocks.php b/tests/test-atomic-wind-blocks.php
index 6eda729dd..0c85a50bc 100644
--- a/tests/test-atomic-wind-blocks.php
+++ b/tests/test-atomic-wind-blocks.php
@@ -992,7 +992,7 @@ public function test_rest_save_style_preserves_backslash_selectors() {
}
// -------------------------------------------------------
- // Security: output_cached_css uses wp_add_inline_style
+ // Security: cached CSS uses wp_add_inline_style.
// -------------------------------------------------------
public function test_cached_css_uses_inline_style_api() {
@@ -1001,12 +1001,12 @@ public function test_cached_css_uses_inline_style_api() {
$this->assertNotFalse(
strpos( $source, 'wp_add_inline_style' ),
- 'output_cached_css should use wp_add_inline_style instead of raw echo'
+ 'Cached CSS should use wp_add_inline_style instead of raw echo'
);
$this->assertFalse(
(bool) preg_match( '/echo.*\$cached_css/', $source ),
- 'output_cached_css should not echo $cached_css directly'
+ 'Cached CSS should not be echoed directly'
);
}
@@ -1212,4 +1212,301 @@ public function test_warm_html_does_not_leak_globals() {
$this->assertSame( $sentinel, $post, 'The global $post must be restored after rendering the warm page.' );
}
+
+ // -------------------------------------------------------
+ // Frontend CSS loading.
+ // -------------------------------------------------------
+
+ /**
+ * Read a private property.
+ *
+ * @param string $name Property name.
+ * @return mixed
+ */
+ private function get_private_prop( $name ) {
+ $ref = new ReflectionProperty( Atomic_Wind_Blocks::class, $name );
+ $ref->setAccessible( true );
+ return $ref->getValue( $this->instance );
+ }
+
+ /**
+ * Reset style and script queues.
+ */
+ private function reset_style_globals() {
+ $GLOBALS['wp_styles'] = new WP_Styles();
+ $GLOBALS['wp_scripts'] = new WP_Scripts();
+ }
+
+ /**
+ * Get inline CSS for a style.
+ *
+ * @param string $handle Style handle.
+ * @return string
+ */
+ private function inline_style_for( $handle ) {
+ $data = wp_styles()->get_data( $handle, 'after' );
+ return is_array( $data ) ? implode( '', $data ) : (string) $data;
+ }
+
+ public function test_track_rendered_blocks_increments_per_current_post() {
+ $this->reset_in_query( false );
+
+ global $post;
+ $post = get_post( $this->post_id );
+ setup_postdata( $post );
+
+ $block = $this->make_block( 'atomic-wind/box' );
+ $this->instance->track_rendered_blocks( '', $block );
+ $this->instance->track_rendered_blocks( '', $block );
+
+ $rendered = $this->get_private_prop( 'rendered' );
+
+ wp_reset_postdata();
+
+ $this->assertSame( 2, $rendered[ $this->post_id ] );
+ }
+
+ public function test_track_rendered_blocks_skips_non_atomic() {
+ $this->reset_in_query( false );
+
+ $this->instance->track_rendered_blocks( '', $this->make_block( 'core/paragraph' ) );
+
+ $this->assertSame( array(), $this->get_private_prop( 'rendered' ) );
+ }
+
+ public function test_track_rendered_blocks_skips_inside_query_loop() {
+ $this->reset_in_query( true );
+
+ $this->instance->track_rendered_blocks( '', $this->make_block( 'atomic-wind/box' ) );
+
+ $this->assertSame( array(), $this->get_private_prop( 'rendered' ) );
+ }
+
+ public function test_output_late_css_inlines_multiple_rendered_posts() {
+ $this->reset_style_globals();
+ $this->reset_in_query( false );
+
+ $a = $this->make_atomic_wind_post( 'flex' );
+ $b = $this->make_atomic_wind_post( 'grid' );
+ update_post_meta( $a, '_atomic_wind_css', '.flex{display:flex}' );
+ update_post_meta( $b, '_atomic_wind_css', '.grid{display:grid}' );
+
+ global $post;
+ $post = get_post( $a );
+ setup_postdata( $post );
+ $this->instance->track_rendered_blocks( '', $this->make_block( 'atomic-wind/box' ) );
+
+ $post = get_post( $b );
+ setup_postdata( $post );
+ $this->instance->track_rendered_blocks( '', $this->make_block( 'atomic-wind/box' ) );
+
+ $this->instance->output_late_css();
+
+ $inline = $this->inline_style_for( 'atomic-wind-tailwind-late' );
+
+ wp_reset_postdata();
+
+ $this->assertStringContainsString( '.flex{display:flex}', $inline );
+ $this->assertStringContainsString( '.grid{display:grid}', $inline );
+ }
+
+ public function test_output_late_css_dedupes_identical_blobs() {
+ $this->reset_style_globals();
+ $this->reset_in_query( false );
+
+ $a = $this->make_atomic_wind_post( 'flex' );
+ $b = $this->make_atomic_wind_post( 'flex' );
+ update_post_meta( $a, '_atomic_wind_css', '.flex{display:flex}' );
+ update_post_meta( $b, '_atomic_wind_css', '.flex{display:flex}' );
+
+ global $post;
+ $post = get_post( $a );
+ setup_postdata( $post );
+ $this->instance->track_rendered_blocks( '', $this->make_block( 'atomic-wind/box' ) );
+
+ $post = get_post( $b );
+ setup_postdata( $post );
+ $this->instance->track_rendered_blocks( '', $this->make_block( 'atomic-wind/box' ) );
+
+ $this->instance->output_late_css();
+
+ $inline = $this->inline_style_for( 'atomic-wind-tailwind-late' );
+
+ wp_reset_postdata();
+
+ $this->assertSame( 1, substr_count( $inline, '.flex{display:flex}' ) );
+ }
+
+ public function test_frontend_css_is_not_enqueued_until_a_block_renders() {
+ $this->reset_style_globals();
+
+ $post_id = $this->make_atomic_wind_post( 'flex' );
+ update_post_meta( $post_id, '_atomic_wind_css', '.flex{display:flex}' );
+
+ $GLOBALS['wp_query']->posts = array( get_post( $post_id ) );
+
+ $this->instance->enqueue_base_css();
+ $this->instance->maybe_enqueue_style_builder();
+
+ $this->assertFalse( wp_style_is( 'atomic-wind-base', 'enqueued' ) );
+ $this->assertFalse( wp_style_is( 'atomic-wind-tailwind', 'enqueued' ) );
+ }
+
+ public function test_output_late_css_inlines_tracked_post_and_skips_generator() {
+ $this->reset_style_globals();
+ $this->reset_in_query( false );
+ $this->instance->enqueue_base_css();
+
+ $late = $this->make_atomic_wind_post( 'flex' );
+ update_post_meta( $late, '_atomic_wind_css', '.late{color:blue}' );
+
+ global $post;
+ $post = get_post( $late );
+ setup_postdata( $post );
+
+ // Render the post's single Atomic Wind block.
+ $this->instance->track_rendered_blocks( '', $this->make_block( 'atomic-wind/box' ) );
+
+ $this->instance->output_late_css();
+
+ $inline = $this->inline_style_for( 'atomic-wind-tailwind-late' );
+
+ wp_reset_postdata();
+
+ $this->assertTrue( wp_style_is( 'atomic-wind-base', 'enqueued' ) );
+ $this->assertStringContainsString( '.late{color:blue}', $inline );
+ $this->assertFalse( wp_script_is( 'atomic-wind-tailwind-generator', 'enqueued' ) );
+ }
+
+ public function test_output_late_css_collects_blocks_rendered_by_normal_footer_callbacks() {
+ $this->reset_style_globals();
+ $this->reset_in_query( false );
+ update_option( 'themeisle_blocks_settings_atomic_wind_blocks', true );
+
+ $late = $this->make_atomic_wind_post( 'flex' );
+ update_post_meta( $late, '_atomic_wind_css', '.footer-callback{color:purple}' );
+
+ global $post;
+ $post = get_post( $late );
+ setup_postdata( $post );
+
+ $render_footer_block = function () {
+ $this->instance->track_rendered_blocks( '', $this->make_block( 'atomic-wind/box' ) );
+ };
+
+ // Avoid unrelated core footer output.
+ remove_all_actions( 'wp_footer' );
+ add_action( 'wp_footer', $render_footer_block, 10 );
+ $this->instance->run();
+ do_action( 'wp_footer' );
+ remove_action( 'wp_footer', $render_footer_block, 10 );
+ remove_action( 'wp_footer', array( $this->instance, 'output_late_css' ), 19 );
+
+ $inline = $this->inline_style_for( 'atomic-wind-tailwind-late' );
+
+ wp_reset_postdata();
+
+ $this->assertStringContainsString( '.footer-callback{color:purple}', $inline );
+ }
+
+ public function test_output_late_css_falls_back_to_generator_when_counts_exceed() {
+ $this->reset_style_globals();
+ $this->reset_in_query( false );
+
+ $late = $this->make_atomic_wind_post( 'flex' );
+ update_post_meta( $late, '_atomic_wind_css', '.late{color:blue}' );
+
+ global $post;
+ $post = get_post( $late );
+ setup_postdata( $post );
+
+ // Render one block more than the post declares.
+ $this->instance->track_rendered_blocks( '', $this->make_block( 'atomic-wind/box' ) );
+ $this->instance->track_rendered_blocks( '', $this->make_block( 'atomic-wind/box' ) );
+
+ $this->instance->output_late_css();
+
+ wp_reset_postdata();
+
+ $this->assertTrue( wp_script_is( 'atomic-wind-tailwind-generator', 'enqueued' ) );
+ }
+
+ public function test_output_singular_css_inlines_queried_post_in_head() {
+ $this->reset_style_globals();
+
+ $singular = $this->make_atomic_wind_post( 'flex' );
+ update_post_meta( $singular, '_atomic_wind_css', '.flex{display:flex}' );
+
+ $this->go_to( get_permalink( $singular ) );
+ $this->instance->enqueue_base_css();
+ $this->instance->output_singular_css();
+
+ $this->assertStringContainsString( '.flex{display:flex}', $this->inline_style_for( 'atomic-wind-tailwind' ) );
+ $this->assertTrue( wp_style_is( 'atomic-wind-base', 'enqueued' ) );
+ $this->assertFalse( wp_script_is( 'atomic-wind-tailwind-generator', 'enqueued' ) );
+ }
+
+ public function test_output_singular_css_enqueues_generator_without_cache() {
+ $this->reset_style_globals();
+
+ $singular = $this->make_atomic_wind_post( 'flex' );
+
+ $this->go_to( get_permalink( $singular ) );
+ $this->instance->output_singular_css();
+
+ $this->assertSame( '', $this->inline_style_for( 'atomic-wind-tailwind' ) );
+ $this->assertTrue( wp_script_is( 'atomic-wind-tailwind-generator', 'enqueued' ) );
+ }
+
+ public function test_output_singular_css_noop_on_non_singular_views() {
+ $this->reset_style_globals();
+
+ $listed = $this->make_atomic_wind_post( 'flex' );
+ update_post_meta( $listed, '_atomic_wind_css', '.flex{display:flex}' );
+
+ $this->go_to( home_url( '/' ) );
+ $this->instance->output_singular_css();
+
+ $this->assertSame( '', $this->inline_style_for( 'atomic-wind-tailwind' ) );
+ $this->assertFalse( wp_script_is( 'atomic-wind-tailwind-generator', 'enqueued' ) );
+ }
+
+ public function test_output_late_css_skips_post_already_inlined_at_head() {
+ $this->reset_style_globals();
+ $this->reset_in_query( false );
+
+ $singular = $this->make_atomic_wind_post( 'flex' );
+ update_post_meta( $singular, '_atomic_wind_css', '.flex{display:flex}' );
+
+ $this->go_to( get_permalink( $singular ) );
+ $this->instance->output_singular_css();
+
+ // The post's single block renders and is tracked, as during a real request.
+ global $post;
+ $post = get_post( $singular );
+ setup_postdata( $post );
+ $this->instance->track_rendered_blocks( '', $this->make_block( 'atomic-wind/box' ) );
+ wp_reset_postdata();
+
+ $this->instance->output_late_css();
+
+ $this->assertSame( '', $this->inline_style_for( 'atomic-wind-tailwind-late' ) );
+ $this->assertFalse( wp_script_is( 'atomic-wind-tailwind-generator', 'enqueued' ) );
+ }
+
+ public function test_enqueue_base_css_registers_frontend_style_without_enqueuing_it() {
+ $this->reset_style_globals();
+
+ global $post;
+ $post = null;
+
+ $this->instance->enqueue_base_css();
+
+ $this->assertTrue( wp_style_is( 'atomic-wind-base', 'registered' ) );
+ $this->assertFalse( wp_style_is( 'atomic-wind-base', 'enqueued' ) );
+ $this->assertStringContainsString(
+ 'wp-block-atomic-wind-',
+ $this->inline_style_for( 'atomic-wind-base' )
+ );
+ }
}
diff --git a/tests/test-options-settings.php b/tests/test-options-settings.php
index 2928e323f..fc22b4bd7 100644
--- a/tests/test-options-settings.php
+++ b/tests/test-options-settings.php
@@ -61,6 +61,7 @@ public function test_form_emails_sanitize_callback_sanitizes_nested_data() {
array(
array(
'form' => ' form-id