Commit 0968405071d for woocommerce
commit 0968405071db0cbb74c2003055a28c1d07bbd488
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Wed Oct 28 09:19:41 2026 +0200
Use PHP templates for Coming Soon mode on classic themes (#69687)
* fix(coming-soon): Use PHP templates for Coming Soon on classic themes
Classic themes without block-template support rendered the Coming Soon
page from a block template. To make that work, WooCommerce bootstrapped
the block template registry and controller early on every request, and
the entire-site design showed sample social links that merchants on
classic themes cannot edit. Issue 50520 asks for plain PHP templates
instead.
Add coming-soon-entire-site.php (a standalone document with logo, site
title, login link and tagline) and coming-soon-store-only.php (content
between the theme's header and footer), plus a small classic stylesheet
with RTL support. A new template_include callback selects them. It is
registered from template_redirect at PHP_INT_MAX so that no later
template_include callback, such as a theme builder at 999, can replace
the private page. It honours theme-root coming-soon.php, the WooCommerce
override directory and the coming-soon_template* filters, and returns an
empty path when a filtered template is unusable, so protected content is
never shown.
Stores with a published Coming Soon template or social-links part, or a
theme-owned coming-soon block template, keep the existing block renderer
and its early bootstrap, so saved designs are not discarded. The
saved-design lookup skips query filters because it runs before init.
possibly_init_block_templates() stays directly hooked so it can still
be unhooked, and skips block bootstrap only when the PHP templates
apply. Block themes are unchanged.
Refs #50520
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore: Add changelog entry for classic Coming Soon templates
Refs #50520
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(coming-soon): Keep the banner class on the classic heading
The block Coming Soon page puts the woocommerce-coming-soon-banner
class on its main heading. The new classic PHP template rendered the
heading without it, so anything selecting on that class stopped
matching on classic themes: the Launch Your Store E2E test for
Storefront timed out waiting for it, and merchant CSS aimed at the
heading would no longer apply.
Add the class back to the heading in the entire-site template.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(coming-soon): Decide the renderer once per request
On classic themes in Coming Soon mode, should_use_classic_templates()
ran from after_setup_theme and from both template_include callbacks.
Each run rebuilt two WP_Query lookups for saved templates and repeated
the theme file checks, even though the answer cannot change within a
request.
Cache the result on the handler. The duplicate early returns in
handle_template_include() collapse into one check, matching the classic
callback. The handler is a shared container instance, so the tests
clear the cached value in setUp().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(coming-soon): Name core's query-template hooks literally
The classic Coming Soon path fires the same hooks as core's
get_query_template( 'coming-soon' ), which the block renderer already
calls, so callbacks see one contract on either renderer. The names were
built from a $type variable, which hid them from grep and kept PHPCS
quiet about the hyphen only by accident. Their docblocks also presented
them as new WooCommerce hooks with @since 11.3.0.
Write the names out with a phpcs:ignore, and point the docblocks at
core's documentation, following term_links-product_cat in
wc-product-functions.php.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(coming-soon): Share the classic result assertion
Five tests repeated the same pair of checks: the template path the
classic callback returns, and whether its stylesheet was enqueued. Move
them into one helper that expects the stylesheet only when the
WooCommerce page is selected. Collapse the split filter-argument and
hook-count assertions into single assertSame calls.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(coming-soon): Nest the entire-site link color rule
The entire-site modifier's link color sat in its own block further down
the file. Move it into the existing modifier block so its rules live in
one place. No rule conflicts with it, so the output is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(coming-soon): Drop the saved-design exception on classic themes
Classic themes without block-template-parts kept the block Coming Soon
page when a saved Coming Soon template or social-links part existed, or
when the theme shipped its own coming-soon block template. To detect
that, every request ran up to two queries before init, and those stores
still booted BlockTemplatesRegistry and BlockTemplatesController.
Reviewers agreed the exception is not worth it: classic themes get no
screen to edit those designs, and trunk already drops a block-theme
design after switching to a classic theme. Every classic theme without
block-template-parts now gets the PHP page, and neither block template
class starts for Coming Soon on those themes.
possibly_init_block_templates() is no longer hooked and is kept as a
deprecated no-op for direct callers.
Refs #50520
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(coming-soon): Stop loading a theme's root coming-soon.php
The classic path looked up coming-soon.php in the theme root before the
WooCommerce templates, mirroring core's get_query_template(). Some
classic themes ship a page template with that name that calls
the_content(), so logged-out visitors would get the protected page or
product instead of the Coming Soon page. On trunk this cannot happen:
WooCommerce's plugin block template wins over a theme's PHP file in
locate_block_template().
The PHP templates now load only from WooCommerce's template paths, so
themes override them from yourtheme/woocommerce/coming-soon/. The
coming-soon_template_hierarchy filter is no longer fired, since there
is no hierarchy to search; coming-soon_template still fires with the
arguments the block renderer passes, for the plugin that uses it.
Refs #50520
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(coming-soon): Render the store-only classic template
Only the store-only template's path was asserted, but it is the one
that calls get_header() and get_footer() and the one where the
woo-coming-soon-page meta tag now prints. The new test renders it in a
fixture theme and checks one document, the marker inside the head, the
Coming Soon message, and no shop page content.
Storefront's header.php needs its functions.php, which the test
bootstrap does not load, so the theme-file fixtures move into a
use_fixture_theme() helper that points locate_template() at a temporary
theme folder and removes it in tearDown().
Refs #50520
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(coming-soon): Add the standard override notice to classic templates
Every other WooCommerce template header warns theme developers that an
override can go stale when the template's version is bumped, and links
the template structure docs. The two new Coming Soon templates lacked
that paragraph and @see link.
Refs #50520
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(coming-soon): Use the shared gray-900 token for classic text
The classic stylesheet hard-coded #1e1e1e, the value of $gray-900 in
the legacy variables. Import the variables like coming-soon.scss does
so both Coming Soon stylesheets follow the token if it changes. The
compiled color is the same.
Refs #50520
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(coming-soon): Drop the saved-designs line from the changelog
The entry said saved designs still apply, but the saved-design exception
was removed in 42351c5f011: classic themes now always get the PHP
Coming Soon page. The body ships verbatim to merchants, so it must not
promise the old behavior.
Refs #50520
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(coming-soon): Clear the product global on classic Coming Soon pages
On a product URL, WordPress copies the product query var into the
$product global, so it holds the slug string until a loop runs
the_post(). The classic Coming Soon templates never run the loop, so
theme code in the header or footer that checks `$product &&
is_product()` and then calls a product method crashes. Storefront's
sticky add-to-cart bar does this, which made store-only Coming Soon
product pages return HTTP 500.
This is the same failure Coming Soon caused before: #52375 (Astra,
WooCommerce 9.3.3) and #54294 (Envo Storefront, 9.5.1) both reproduce
locally with the reported stack frames. Those were patched with guards
inside WooCommerce's template functions, which theme code does not go
through.
Unset the global before the template renders, as
wc_setup_product_data() does for non-product posts. Guarded theme code
now skips its product UI, so the page neither crashes nor shows the
price and add-to-cart controls that trunk renders. Themes that load the
product from $post themselves, such as Astra, still show their bar.
Refs #50520
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(coming-soon): Explain the classic handler's registration and theme check
A reviewer read "Registered last so no other template_include callback
can replace the private page" as blocking later template_redirect
callbacks, and asked why the classic-theme check uses template part
support rather than template support.
The template_redirect callback only adds the template_include filter.
Adding it that late puts ours after every other PHP_INT_MAX callback,
since callbacks at one priority run in the order they were added. The
template part check matches the Blocks bootstrap, which loads
WooCommerce block templates on block themes and block-template-parts
themes, so those keep the block page.
Refs #50520
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(coming-soon): Make the classic handler tests easier to read
Review feedback found the classic Coming Soon tests hard to follow:
- assert_classic_result() compared a boolean expression with
wp_style_is(). It now branches, with a message for each case.
- create_saved_design() had one caller, so it is inlined.
- The saved social-links part row came from the saved-design exception
that this branch already removed. Nothing reads saved designs now, so
the row could not fail without the saved-template row failing too.
- WC50520 markers in fixture content and the temp folder name are
replaced with plain names.
Refs #50520
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(coming-soon): Let classic themes style the entire-site page
The entire-site Coming Soon page set its own font, text color,
background, link color and heading weight, so a classic theme's
typography and buttons never showed. Review feedback compared it on
Storefront with and without those rules: the theme's look fits better,
and trunk's block page on classic themes ignored the theme too.
Drop those rules and keep only layout and sizes. The Log in link gets
the button class, so themes that style .button (Storefront) apply their
button. Its outlined fallback now sits in :where(), at zero
specificity, for themes that don't (Twenty Twenty-One). The variables
import went with the last $gray-900 use.
Checked on the dev site: Storefront and Twenty Twenty-One pick up their
fonts, colors and background; RTL (he_IL) loads the -rtl file with Log
in on the left; no horizontal overflow at 390px; Log in keeps its
focus outline.
Refs #50520
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(coming-soon): Use the PHP pages on classic themes with block template parts
Classic themes that declare block-template-parts support kept the
block Coming Soon page. WordPress only lets those themes edit template
parts, and the Site visibility "Editor" link only shows on block
themes, so merchants could not change the pattern's placeholder social
links. Review on #69687 reproduced this on Twenty Twenty-One.
Pick the PHP pages for every theme that is not a block theme. The
block renderer now only runs on block themes, so its classic-theme
branches (header, footer, exit) are gone. That path printed the theme
header before adding the woo-coming-soon-page meta tag, so store-only
pages on those themes also lacked the cache marker; the PHP pages
print it.
Refs #50520
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(coming-soon): Pass no template candidates to coming-soon_template
The classic Coming Soon path fired core's coming-soon_template filter
with array( 'coming-soon.php' ) as the candidate list, copying what
get_query_template() passes on block themes. The classic path never
searches that list, and deliberately skips a theme's root
coming-soon.php because some themes ship a page template with that
name that prints the protected page. Advertising it as a candidate
invites a callback that re-resolves the list with locate_template()
to load exactly that file.
Pass an empty list instead. The only wordpress.org consumers (WPWC
classic Coming Soon and the Brandy theme) never read the argument.
Refs #50520
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(coming-soon): Center the store-only message in flex theme containers
Astra wraps page content in .ast-container, a flex row, so the
store-only Coming Soon wrapper shrank to its content and sat at the
left edge. Trunk's block page was centered. Review on #69687 caught it.
Give the wrapper the full width. It already uses border-box sizing and
has no inline margin, so the 24px padding cannot overflow, and in block
containers such as Storefront's and Twenty Twenty-One's the width
matches what auto gave before. Checked on Astra at 1280px and 500px,
Storefront and Twenty Twenty-One: centered, no horizontal scroll.
Refs #50520
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(coming-soon): Stop later template_include callbacks on classic pages
On classic themes the Coming Soon selector returned its template from
the last template_include callback, so WordPress includes it normally
and fires wp_before_include_template, which core uses since 6.9 to
start its template enhancement output buffer. But every callback
before it still ran. One that prints inside the filter put its page in
front of ours: with a Pagelayer theme template applied to the shop,
visitors got the protected shop template and then the Coming Soon
message, without the woo-coming-soon-page tag caches rely on. A
callback registered after ours at PHP_INT_MAX could replace the page.
Trunk blocked all of that by rendering at priority 10 and calling
exit().
Add a guard at priority 9, below WordPress's default. On classic
Coming Soon requests it unhooks every other template_include callback
except the selector, so nothing from the default priority up can
print, exit or replace the page, while lower priorities still run as
they did on trunk. The guard does nothing when the selector has been
unhooked, since stripping the other callbacks would then leave the
theme's default template. The selector is registered directly, as
registration order no longer matters.
Both callbacks ask whether Coming Soon applies, so the decision is now
cached for the request whatever it is. An excluding
woocommerce_coming_soon_exclude callback and the share-link cookie
then run once.
Refs #50520
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(coming-soon): Trim redundant and obsolete request handler tests
The request handler tests grew across review rounds and kept cases
that no longer test anything. Three parse_request tests were always
skipped and target a flow the handler no longer hooks. Two absence
checks covered code that is gone. The unreadable-file test only
exercised a branch that core already covers, since WordPress 6.9
refuses to include an unreadable template and WooCommerce requires
7.0. Twelve unusable-template cases fed one check through two filters.
Remove the obsolete tests and checks, keep four unusable-template
cases that each hit a distinct branch, run the product-global and
block-renderer tests once, and merge the saved-template and
template-parts cases, and the admin-bypass and unhooked-selector
cases, into data-provided tests. 46 tests (3 skipped) become 32.
Mutating the theme check, the selector check, the request cache, the
removal loop, is_file and the product global each still fails a test.
Refs #50520
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: Update Coming Soon version markers to 11.4.0
Coming Soon PHP templates now target the 11.4 release cycle. Update
the new template headers and both deprecation references so they
identify the release that introduces them.
diff --git a/plugins/woocommerce/changelog/fix-50520-classic-coming-soon-php b/plugins/woocommerce/changelog/fix-50520-classic-coming-soon-php
new file mode 100644
index 00000000000..aec43fb5e3f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-50520-classic-coming-soon-php
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show a Coming Soon page made for classic themes, without block templates or sample social links.
diff --git a/plugins/woocommerce/client/legacy/css/coming-soon-classic.scss b/plugins/woocommerce/client/legacy/css/coming-soon-classic.scss
new file mode 100644
index 00000000000..16f0d6905cb
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/css/coming-soon-classic.scss
@@ -0,0 +1,94 @@
+.woocommerce-coming-soon-classic-page {
+ margin: 0;
+}
+
+.woocommerce-coming-soon-classic {
+ box-sizing: border-box;
+
+ *,
+ *::before,
+ *::after {
+ box-sizing: inherit;
+ }
+
+ &--entire-site {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ min-height: 100svh;
+ padding-inline: clamp(24px, 5vw, 80px);
+ }
+
+ &__header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ width: 100%;
+ max-width: 1280px;
+ margin-inline: auto;
+ padding-block: 26px;
+ }
+
+ &__identity {
+ display: flex;
+ flex: 1 1 0;
+ align-items: center;
+ min-width: 0;
+ gap: 16px;
+ font-size: 20px;
+ overflow-wrap: anywhere;
+
+ .custom-logo-link {
+ flex: none;
+ }
+
+ .custom-logo {
+ width: 60px;
+ height: auto;
+ }
+ }
+
+ &__content {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ max-width: 960px;
+ margin-inline: auto;
+ padding-block: 80px;
+ text-align: center;
+ overflow-wrap: anywhere;
+
+ h1 {
+ margin-block: 0 24px;
+ font-size: clamp(32px, 5vw, 48px);
+ }
+
+ p {
+ margin-block: 0;
+ }
+ }
+
+ &--store-only {
+ width: 100%;
+ padding-inline: 24px;
+ }
+
+ // Zero specificity, so a theme's button styles win.
+ :where(&__login) {
+ padding-block: 12px;
+ padding-inline: 16px;
+ border: 1px solid currentcolor;
+ border-radius: 6px;
+ text-decoration: none;
+ }
+
+ a:focus-visible {
+ outline: 2px solid currentcolor;
+ outline-offset: 4px;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonRequestHandler.php b/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonRequestHandler.php
index 613885d52c7..2769a3c3bd9 100644
--- a/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonRequestHandler.php
+++ b/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonRequestHandler.php
@@ -1,9 +1,6 @@
<?php
namespace Automattic\WooCommerce\Internal\ComingSoon;
-use Automattic\WooCommerce\Blocks\BlockTemplatesController;
-use Automattic\WooCommerce\Blocks\BlockTemplatesRegistry;
-use Automattic\WooCommerce\Blocks\Package as BlocksPackage;
use Automattic\Jetpack\Constants;
/**
@@ -20,11 +17,16 @@ class ComingSoonRequestHandler {
private $coming_soon_helper = null;
/**
- * Whether the coming soon screen should be shown. Cache the result to avoid multiple calls to the helper.
+ * Priority of the classic-theme guard: just below WordPress's default, so lower-priority callbacks still run first.
+ */
+ private const CLASSIC_GUARD_PRIORITY = 9;
+
+ /**
+ * Whether the coming soon screen should be shown, cached once decided for the request. Null until then.
*
- * @var bool
+ * @var bool|null
*/
- private static $show_coming_soon = false;
+ private static $show_coming_soon = null;
/**
* Sets up the hook.
@@ -45,25 +47,21 @@ class ComingSoonRequestHandler {
}
add_filter( 'template_include', array( $this, 'handle_template_include' ) );
+ add_filter( 'template_include', array( $this, 'remove_other_template_include_callbacks' ), self::CLASSIC_GUARD_PRIORITY );
+ add_filter( 'template_include', array( $this, 'handle_classic_template_include' ), PHP_INT_MAX );
add_filter( 'wp_theme_json_data_theme', array( $this, 'experimental_filter_theme_json_theme' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_styles' ) );
- add_action( 'after_setup_theme', array( $this, 'possibly_init_block_templates' ), 999 );
}
);
}
/**
- * Initializes block templates so we can show coming soon page in non-FSE themes.
+ * Formerly initialized block templates so classic themes could show the block Coming Soon page.
+ *
+ * @deprecated 11.4.0 Classic themes now use PHP templates, and block themes initialize block templates in the Blocks bootstrap.
*/
public function possibly_init_block_templates() {
- // No need to initialize block templates since we've already initialized them in the Block Bootstrap.
- if ( wp_is_block_theme() || current_theme_supports( 'block-template-parts' ) ) {
- return;
- }
-
- $container = BlocksPackage::container();
- $container->get( BlockTemplatesRegistry::class )->init();
- $container->get( BlockTemplatesController::class )->init();
+ wc_deprecated_function( __METHOD__, '11.4.0' );
}
/**
@@ -72,54 +70,130 @@ class ComingSoonRequestHandler {
* @internal
*
* @param string $template The path to the previously determined template.
- * @return string The path to the 'coming soon' template or any empty string to prevent further template loading in FSE themes.
+ * @return string An empty string once the block 'coming soon' template is rendered, or the original path otherwise.
*/
public function handle_template_include( $template ) {
- if ( ! $this->should_show_coming_soon() ) {
+ if ( $this->should_use_classic_templates() || ! $this->should_show_coming_soon() ) {
return $template;
}
// A coming soon page needs to be displayed. Set a short cache duration to prevents ddos attacks.
header( 'Cache-Control: max-age=60' );
- $is_fse_theme = wp_is_block_theme();
- $is_store_coming_soon = $this->coming_soon_helper->is_store_coming_soon();
add_theme_support( 'block-templates' );
$coming_soon_template = get_query_template( 'coming-soon' );
- if ( ! $is_fse_theme && $is_store_coming_soon ) {
- get_header();
+ $this->add_coming_soon_meta();
+
+ if ( ! empty( $coming_soon_template ) && file_exists( $coming_soon_template ) ) {
+ include $coming_soon_template;
}
- add_action(
- 'wp_head',
- function () {
- echo "<meta name='woo-coming-soon-page' content='yes'>";
- }
- );
+ // Since we've already rendered a template, return empty string to ensure no other template is rendered.
+ return '';
+ }
- if ( ! empty( $coming_soon_template ) && file_exists( $coming_soon_template ) ) {
- if ( ! $is_fse_theme && $is_store_coming_soon && function_exists( 'get_the_block_template_html' ) ) {
- // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
- echo get_the_block_template_html();
- } else {
- include $coming_soon_template;
+ /**
+ * Unhooks every other template_include callback on classic Coming Soon requests, keeping only the PHP template selector.
+ * Callbacks from the default priority up can then neither print, exit, nor replace the page. Lower priorities have already run.
+ *
+ * @internal
+ *
+ * @param string $template The previously selected template path.
+ * @return string The unchanged template path.
+ */
+ public function remove_other_template_include_callbacks( $template ) {
+ global $wp_filter;
+
+ $selector = array( $this, 'handle_classic_template_include' );
+
+ // Without the selector, removing the other callbacks would leave WordPress's default template, showing the protected page.
+ if ( false === has_filter( 'template_include', $selector ) || ! $this->should_use_classic_templates() || ! $this->should_show_coming_soon() ) {
+ return $template;
+ }
+
+ foreach ( $wp_filter['template_include']->callbacks as $priority => $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ if ( $selector !== $callback['function'] ) {
+ remove_filter( 'template_include', $callback['function'], $priority );
+ }
}
}
- if ( ! $is_fse_theme && $is_store_coming_soon ) {
- get_footer();
+ return $template;
+ }
+
+ /**
+ * Selects a PHP Coming Soon template, as the last template_include callback left on classic Coming Soon requests.
+ *
+ * @internal
+ *
+ * @param string $template The previously selected template path.
+ * @return string The Coming Soon PHP path, an empty path on lookup failure, or the original path on bypass requests.
+ */
+ public function handle_classic_template_include( $template ) {
+ if ( ! $this->should_use_classic_templates() || ! $this->should_show_coming_soon() ) {
+ return $template;
+ }
+
+ if ( ! headers_sent() ) {
+ header( 'Cache-Control: max-age=60' );
}
- if ( $is_fse_theme ) {
- // Since we've already rendered a template, return empty string to ensure no other template is rendered.
+ /*
+ * A theme's root coming-soon.php is deliberately not looked up: some classic themes ship a page template
+ * with that name that prints the current post, which would expose the protected page.
+ */
+ $template_name = $this->coming_soon_helper->is_store_coming_soon() ? 'coming-soon-store-only.php' : 'coming-soon-entire-site.php';
+ $selected = wc_locate_template( 'coming-soon/' . $template_name );
+
+ /*
+ * Fire core's coming-soon_template filter so callbacks can replace the page on either renderer.
+ * The candidate list is empty because WordPress's template hierarchy is not searched here.
+ */
+ /** This filter is documented in wp-includes/template.php */
+ $selected = apply_filters( 'coming-soon_template', $selected, 'coming-soon', array() ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores, WooCommerce.Commenting.CommentHooks.MissingSinceComment -- WordPress names and documents this hook.
+ if ( ! is_string( $selected ) || ! is_file( $selected ) || ! is_readable( $selected ) ) {
+ // Never fall back to protected content when a template override cannot be loaded.
return '';
- } else {
- // In non-FSE themes, other templates will still be rendered.
- // We need to exit to prevent further processing.
- exit();
}
+
+ wp_enqueue_style(
+ 'woocommerce-coming-soon-classic',
+ WC()->plugin_url() . '/assets/css/coming-soon-classic.css',
+ array(),
+ Constants::get_constant( 'WC_VERSION' )
+ );
+ wp_style_add_data( 'woocommerce-coming-soon-classic', 'rtl', 'replace' );
+ $this->add_coming_soon_meta();
+
+ // WordPress copies the product query var, a slug, into $product. Theme code expects a WC_Product or nothing.
+ unset( $GLOBALS['product'] );
+
+ return $selected;
+ }
+
+ /**
+ * Marks the page as Coming Soon for caches and other integrations.
+ */
+ private function add_coming_soon_meta(): void {
+ add_action(
+ 'wp_head',
+ function () {
+ echo "<meta name='woo-coming-soon-page' content='yes'>";
+ }
+ );
+ }
+
+ /**
+ * Uses the PHP templates on every classic theme, including those with block template part support.
+ * Only block themes let merchants edit the block Coming Soon template, so only they get the block page.
+ *
+ * @return bool Whether the PHP templates should be used.
+ */
+ private function should_use_classic_templates() {
+ return ! wp_is_block_theme();
}
/**
@@ -128,11 +202,20 @@ class ComingSoonRequestHandler {
* @return bool
*/
private function should_show_coming_soon() {
- // Early exit if already determined that the coming soon screen should be shown.
- if ( self::$show_coming_soon ) {
- return true;
+ // The guard and the selector both ask, so decide once per request: the exclusion filter fires and the share cookie is set only once.
+ if ( null === self::$show_coming_soon ) {
+ self::$show_coming_soon = $this->decide_show_coming_soon();
}
+ return self::$show_coming_soon;
+ }
+
+ /**
+ * Decides whether the coming soon screen should be shown for the current request.
+ *
+ * @return bool
+ */
+ private function decide_show_coming_soon() {
// Early exit if the user is logged in as administrator / shop manager.
if ( current_user_can( 'manage_woocommerce' ) ) {
return false;
@@ -172,7 +255,6 @@ class ComingSoonRequestHandler {
}
}
- self::$show_coming_soon = true;
return true;
}
diff --git a/plugins/woocommerce/templates/coming-soon/coming-soon-entire-site.php b/plugins/woocommerce/templates/coming-soon/coming-soon-entire-site.php
new file mode 100644
index 00000000000..7d4ffdf101c
--- /dev/null
+++ b/plugins/woocommerce/templates/coming-soon/coming-soon-entire-site.php
@@ -0,0 +1,62 @@
+<?php
+/**
+ * Entire-site Coming Soon document for classic themes.
+ *
+ * This template can be overridden by copying it to yourtheme/woocommerce/coming-soon/coming-soon-entire-site.php.
+ *
+ * HOWEVER, on occasion WooCommerce will need to update template files and you
+ * (the theme developer) will need to copy the new files to your theme to
+ * maintain compatibility. We try to do this as little as possible, but it does
+ * happen. When this occurs the version of the template file will be bumped and
+ * the readme will list any important changes.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates
+ * @version 11.4.0
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+?>
+<!doctype html>
+<html <?php language_attributes(); ?>>
+<head>
+ <meta charset="<?php bloginfo( 'charset' ); ?>">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <?php if ( ! current_theme_supports( 'title-tag' ) ) : ?>
+ <title><?php echo esc_html( wp_get_document_title() ); ?></title>
+ <?php endif; ?>
+ <?php wp_head(); ?>
+</head>
+<body <?php body_class( 'woocommerce-coming-soon-classic-page' ); ?>>
+<?php wp_body_open(); ?>
+<div class="woocommerce-coming-soon-classic woocommerce-coming-soon-classic--entire-site">
+ <header class="woocommerce-coming-soon-classic__header">
+ <div class="woocommerce-coming-soon-classic__identity">
+ <?php if ( has_custom_logo() ) : ?>
+ <?php the_custom_logo(); ?>
+ <?php endif; ?>
+ <a href="<?php echo esc_url( home_url( '/' ) ); ?>"><?php echo esc_html( get_bloginfo( 'name' ) ); ?></a>
+ </div>
+ <a class="woocommerce-coming-soon-classic__login button" href="<?php echo esc_url( wp_login_url() ); ?>"><?php esc_html_e( 'Log in', 'woocommerce' ); ?></a>
+ </header>
+ <main class="woocommerce-coming-soon-classic__content">
+ <h1 class="woocommerce-coming-soon-banner">
+ <?php
+ echo esc_html(
+ sprintf(
+ /* translators: %s is the site name. */
+ __( '%s is coming soon', 'woocommerce' ),
+ get_bloginfo( 'name' )
+ )
+ );
+ ?>
+ </h1>
+ <?php if ( get_bloginfo( 'description' ) ) : ?>
+ <p><?php echo esc_html( get_bloginfo( 'description' ) ); ?></p>
+ <?php endif; ?>
+ </main>
+</div>
+<?php wp_footer(); ?>
+</body>
+</html>
diff --git a/plugins/woocommerce/templates/coming-soon/coming-soon-store-only.php b/plugins/woocommerce/templates/coming-soon/coming-soon-store-only.php
new file mode 100644
index 00000000000..1a2ce771e5e
--- /dev/null
+++ b/plugins/woocommerce/templates/coming-soon/coming-soon-store-only.php
@@ -0,0 +1,29 @@
+<?php
+/**
+ * Store-only Coming Soon content for classic themes.
+ *
+ * This template can be overridden by copying it to yourtheme/woocommerce/coming-soon/coming-soon-store-only.php.
+ *
+ * HOWEVER, on occasion WooCommerce will need to update template files and you
+ * (the theme developer) will need to copy the new files to your theme to
+ * maintain compatibility. We try to do this as little as possible, but it does
+ * happen. When this occurs the version of the template file will be bumped and
+ * the readme will list any important changes.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates
+ * @version 11.4.0
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+get_header();
+?>
+<div class="woocommerce-coming-soon-classic woocommerce-coming-soon-classic--store-only">
+ <div class="woocommerce-coming-soon-classic__content">
+ <h1><?php esc_html_e( 'Great things are on the horizon', 'woocommerce' ); ?></h1>
+ <p><?php esc_html_e( 'Something big is brewing! Our store is in the works and will be launching soon!', 'woocommerce' ); ?></p>
+ </div>
+</div>
+<?php
+get_footer();
diff --git a/plugins/woocommerce/tests/php/src/Internal/ComingSoon/ComingSoonRequestHandlerTest.php b/plugins/woocommerce/tests/php/src/Internal/ComingSoon/ComingSoonRequestHandlerTest.php
index 7659ad06c49..377977c24ae 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ComingSoon/ComingSoonRequestHandlerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ComingSoon/ComingSoonRequestHandlerTest.php
@@ -3,13 +3,18 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\ComingSoon;
+use Automattic\WooCommerce\Blocks\BlockTemplatesController;
+use Automattic\WooCommerce\Blocks\Package as BlocksPackage;
+use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonHelper;
use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonRequestHandler;
/**
- * Tests for the coming soon cache invalidator class.
+ * Tests for the Coming Soon request handler.
*/
class ComingSoonRequestHandlerTest extends \WC_Unit_Test_Case {
+ private const ENTIRE_SITE_TEMPLATE = WC_ABSPATH . 'templates/coming-soon/coming-soon-entire-site.php';
+
/**
* System under test.
*
@@ -17,58 +22,603 @@ class ComingSoonRequestHandlerTest extends \WC_Unit_Test_Case {
*/
private $sut;
+ /**
+ * Process state not restored by the WordPress test transaction.
+ *
+ * @var array
+ */
+ private $original_state;
+
+ /**
+ * Static visibility cache, isolated for each request fixture.
+ *
+ * @var \ReflectionProperty
+ */
+ private $visibility;
+
+ /**
+ * Fixture theme folder created by use_fixture_theme(), removed in tearDown().
+ *
+ * @var string|null
+ */
+ private $fixture_theme = null;
+
/**
* Setup.
*/
public function setUp(): void {
parent::setUp();
+ $this->visibility = new \ReflectionProperty( ComingSoonRequestHandler::class, 'show_coming_soon' );
+ $this->visibility->setAccessible( true );
+ $this->original_state = array(
+ 'theme' => get_stylesheet(),
+ 'features' => $GLOBALS['_wp_theme_features'],
+ 'styles' => $GLOBALS['wp_styles'] ?? null,
+ 'get' => $_GET, // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Snapshot simulated request data, not a submitted action.
+ 'cookie' => $_COOKIE,
+ 'visibility' => $this->visibility->getValue(),
+ 'paths' => array( $GLOBALS['wp_stylesheet_path'] ?? null, $GLOBALS['wp_template_path'] ?? null ),
+ );
+ switch_theme( 'storefront' );
+ remove_theme_support( 'block-template-parts' );
+ remove_theme_support( 'block-templates' );
+ $GLOBALS['wp_styles'] = new \WP_Styles(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Isolate queued/rendered assets from other request fixtures.
+ $this->visibility->setValue( null, null );
+ wp_set_current_user( 0 );
+ unset( $_GET['woo-share'], $_COOKIE['woo-share'] );
+ update_option( 'woocommerce_coming_soon', 'yes' );
+ update_option( 'woocommerce_store_pages_only', 'no' );
+ update_option( 'woocommerce_private_link', 'no' );
$this->sut = wc_get_container()->get( ComingSoonRequestHandler::class );
}
/**
- * @testdox Test request parser displays a coming soon page to public visitor.
+ * Restore process state.
*/
- public function test_coming_soon_mode_shown_to_visitor() {
- $this->markTestSkipped( 'The die statement breaks the test. To be improved.' );
- update_option( 'woocommerce_coming_soon', 'yes' );
- $wp = new \WP();
- $wp->request = '/';
- do_action_ref_array( 'parse_request', array( &$wp ) );
+ public function tearDown(): void {
+ try {
+ switch_theme( $this->original_state['theme'] );
+ $GLOBALS['_wp_theme_features'] = $this->original_state['features']; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore theme capabilities changed by the request fixture.
+ $GLOBALS['wp_styles'] = $this->original_state['styles']; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the original assets registry after rendering a document.
+ $_GET = $this->original_state['get'];
+ $_COOKIE = $this->original_state['cookie'];
+ $this->visibility->setValue( null, $this->original_state['visibility'] );
+ list( $GLOBALS['wp_stylesheet_path'], $GLOBALS['wp_template_path'] ) = $this->original_state['paths']; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the theme paths a fixture theme replaced.
+ if ( $this->fixture_theme ) {
+ $this->remove_fixture_tree( $this->fixture_theme );
+ }
+ } finally {
+ parent::tearDown();
+ }
+ }
+
+ /**
+ * @testdox Should select the appropriate PHP document without rendering inside the filter.
+ * @testWith ["no", "coming-soon-entire-site.php"]
+ * ["yes", "coming-soon-store-only.php"]
+ *
+ * @param string $store_only Whether to restrict Coming Soon to store pages.
+ * @param string $filename Expected PHP template filename.
+ */
+ public function test_classic_template_selection( string $store_only, string $filename ): void {
+ update_option( 'woocommerce_store_pages_only', $store_only );
+ $shop_id = $this->factory->post->create( array( 'post_type' => 'page' ) );
+ update_option( 'woocommerce_shop_page_id', $shop_id );
+ $this->go_to( get_permalink( $shop_id ) );
+
+ $template = null;
+ $output = $this->capture(
+ function () use ( &$template ) {
+ $template = $this->sut->handle_classic_template_include( '/original.php' );
+ }
+ );
- $this->assertSame( $wp->query_vars['page_id'], 99 );
+ $this->assertSame( WC_ABSPATH . 'templates/coming-soon/' . $filename, $template );
+ $this->assertSame( '', $output, 'Selection must let WordPress render the template.' );
+ $this->assertTrue( wp_style_is( 'woocommerce-coming-soon-classic', 'enqueued' ) );
+ $this->assertSame( WC()->plugin_url() . '/assets/css/coming-soon-classic.css', wp_styles()->registered['woocommerce-coming-soon-classic']->src );
}
/**
- * @testdox Test request parser displays a live page to public visitor.
+ * @testdox Should return the original path without PHP assets on bypass requests.
+ * @testWith ["live"]
+ * ["administrator"]
+ * ["shop_manager"]
+ * ["private_cookie"]
+ * ["excluded"]
+ * ["non_store"]
+ * ["store_404"]
+ * ["block_theme"]
+ *
+ * @param string $scenario Access or theme bypass condition.
*/
- public function test_live_mode_shown_to_visitor() {
- $this->markTestSkipped( 'The die statement breaks the test. To be improved.' );
- update_option( 'woocommerce_coming_soon', 'no' );
- $wp = new \WP();
- $wp->request = '/';
- do_action_ref_array( 'parse_request', array( &$wp ) );
+ public function test_classic_template_bypass( string $scenario ): void {
+ $this->go_to( home_url( '/' ) );
+ switch ( $scenario ) {
+ case 'live':
+ update_option( 'woocommerce_coming_soon', 'no' );
+ break;
+ case 'administrator':
+ case 'shop_manager':
+ wp_set_current_user( $this->factory->user->create( array( 'role' => $scenario ) ) );
+ break;
+ case 'private_cookie':
+ update_option( 'woocommerce_private_link', 'yes' );
+ update_option( 'woocommerce_share_key', 'test-share-key' );
+ $_COOKIE['woo-share'] = 'test-share-key';
+ break;
+ case 'excluded':
+ add_filter( 'woocommerce_coming_soon_exclude', '__return_true' );
+ break;
+ case 'non_store':
+ update_option( 'woocommerce_store_pages_only', 'yes' );
+ break;
+ case 'store_404':
+ update_option( 'woocommerce_store_pages_only', 'yes' );
+ $GLOBALS['wp_query']->set_404();
+ break;
+ case 'block_theme':
+ switch_theme( 'twentytwentytwo' );
+ $this->assertTrue( wp_is_block_theme(), 'This fixture must exercise a real block theme.' );
+ break;
+ }
- $this->assertArrayNotHasKey( 'page_id', $wp->query_vars );
+ $this->assert_classic_result( '/original.php' );
}
/**
- * @testdox Test request parser excludes admins.
+ * @testdox Should use the PHP page on classic themes with a saved coming-soon block template or block template part support, since merchants cannot edit either.
+ * @testWith ["saved_template"]
+ * ["template_parts"]
+ *
+ * @param string $state Block template state of the classic theme.
*/
- public function test_shop_manager_exclusion() {
- $this->markTestSkipped( 'Failing in CI but not locally. To be investigated.' );
- update_option( 'woocommerce_coming_soon', 'yes' );
- $user_id = $this->factory->user->create(
+ public function test_classic_theme_block_state_uses_php_page( string $state ): void {
+ if ( 'saved_template' === $state ) {
+ $post_id = $this->factory->post->create(
+ array(
+ 'post_type' => 'wp_template',
+ 'post_name' => 'coming-soon',
+ 'post_status' => 'publish',
+ )
+ );
+ wp_set_object_terms( $post_id, get_stylesheet(), 'wp_theme' );
+ } else {
+ add_theme_support( 'block-template-parts' );
+ }
+
+ $this->assert_classic_result( self::ENTIRE_SITE_TEMPLATE );
+ }
+
+ /**
+ * @testdox Should ignore a theme's root coming-soon.php and load a theme override from the WooCommerce folder.
+ * @testWith ["coming-soon.php", false]
+ * ["woocommerce/coming-soon/coming-soon-entire-site.php", true]
+ *
+ * @param string $file Theme file to create, relative to the theme folder.
+ * @param bool $override Whether WooCommerce should load the file.
+ */
+ public function test_theme_template_lookup( string $file, bool $override ): void {
+ $root = $this->use_fixture_theme( array( $file => '<?php the_content();' ) );
+
+ $this->assertSame( $override ? $root . '/' . $file : self::ENTIRE_SITE_TEMPLATE, $this->sut->handle_classic_template_include( '/protected.php' ) );
+ }
+
+ /**
+ * Assert the classic template result, and that its stylesheet is enqueued only when the WooCommerce page is selected.
+ *
+ * @param string $expected Expected template path.
+ * @param string $original Previously selected template path.
+ */
+ private function assert_classic_result( string $expected, string $original = '/original.php' ): void {
+ $this->assertSame( $expected, $this->sut->handle_classic_template_include( $original ) );
+ $style_enqueued = wp_style_is( 'woocommerce-coming-soon-classic', 'enqueued' );
+ if ( self::ENTIRE_SITE_TEMPLATE === $expected ) {
+ $this->assertTrue( $style_enqueued, 'The entire-site page must enqueue the classic Coming Soon stylesheet.' );
+ } else {
+ $this->assertFalse( $style_enqueued, 'A bypass or failed lookup must not enqueue the classic Coming Soon stylesheet.' );
+ }
+ }
+
+ /**
+ * Create an isolated filesystem fixture without touching installed themes.
+ *
+ * @return string Fresh task-owned directory.
+ */
+ private function create_fixture_directory(): string {
+ $path = get_temp_dir() . 'wc-coming-soon-test-' . wp_generate_uuid4();
+ $this->assertFileDoesNotExist( $path );
+ $this->assertTrue( wp_mkdir_p( $path ) );
+ return $path;
+ }
+
+ /**
+ * Point template lookups at a fresh theme folder holding the given files. tearDown() removes it.
+ *
+ * @param array<string, string> $files File contents keyed by path relative to the theme folder.
+ * @return string Theme folder path.
+ */
+ private function use_fixture_theme( array $files ): string {
+ $root = $this->create_fixture_directory();
+ $this->fixture_theme = $root;
+ foreach ( $files as $file => $contents ) {
+ $this->assertTrue( wp_mkdir_p( dirname( $root . '/' . $file ) ) );
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Write only a fresh task-owned theme file fixture.
+ $this->assertNotFalse( file_put_contents( $root . '/' . $file, $contents ) );
+ }
+ // locate_template() reads these globals, not the theme directory filters.
+ $GLOBALS['wp_stylesheet_path'] = $root; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restored in tearDown().
+ $GLOBALS['wp_template_path'] = $root; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restored in tearDown().
+ return $root;
+ }
+
+ /**
+ * Remove a fixture folder created by this test, with everything in it.
+ *
+ * @param string $root Task-owned directory.
+ */
+ private function remove_fixture_tree( string $root ): void {
+ $items = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $root, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST );
+ foreach ( $items as $item ) {
+ if ( $item->isDir() ) {
+ $this->remove_fixture_directory( $item->getPathname() );
+ } else {
+ wp_delete_file( $item->getPathname() );
+ }
+ }
+ $this->remove_fixture_directory( $root );
+ }
+
+ /**
+ * Remove only an empty directory created by this test.
+ *
+ * @param string $path Task-owned directory.
+ */
+ private function remove_fixture_directory( string $path ): void {
+ if ( is_dir( $path ) ) {
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Remove only an empty task-owned fixture directory.
+ $this->assertTrue( rmdir( $path ) );
+ }
+ }
+
+ /**
+ * Run a callback and return what it printed, closing the buffer even on failure.
+ *
+ * @param callable $callback Code that may print.
+ * @return string Captured output.
+ */
+ private function capture( callable $callback ): string {
+ ob_start();
+ try {
+ $callback();
+ return (string) ob_get_contents();
+ } finally {
+ ob_end_clean();
+ }
+ }
+
+ /**
+ * Render a selected template file.
+ *
+ * @param string $template Template path.
+ * @return string Rendered output.
+ */
+ private function render( string $template ): string {
+ return $this->capture(
+ function () use ( $template ) {
+ include $template;
+ }
+ );
+ }
+
+ /**
+ * @testdox Should fire coming-soon_template once, with no candidate templates, and honor a replacement.
+ */
+ public function test_classic_query_template_filter(): void {
+ $template_calls = array();
+ add_filter(
+ 'coming-soon_template',
+ function ( $template, $type, $templates ) use ( &$template_calls ) {
+ $template_calls[] = array( $template, $type, $templates );
+ return WC_ABSPATH . 'templates/single-product.php';
+ },
+ 10,
+ 3
+ );
+
+ $this->assertSame( WC_ABSPATH . 'templates/single-product.php', $this->sut->handle_classic_template_include( '/original.php' ) );
+ $this->assertSame( array( array( self::ENTIRE_SITE_TEMPLATE, 'coming-soon', array() ) ), $template_calls );
+ }
+
+ /**
+ * @testdox Should never expose the protected page when a template filter returns an unusable path.
+ * @dataProvider unusable_template_cases
+ *
+ * @param string $filter Template path filter to exercise.
+ * @param mixed $value Unusable filtered value.
+ */
+ public function test_classic_template_failure_is_closed( string $filter, $value ): void {
+ add_filter( $filter, fn() => $value );
+
+ $this->assert_classic_result( '', '/protected.php' );
+ }
+
+ /**
+ * Unusable values from the template path filters. Both filters feed the same check, so the theme override path gets one case.
+ *
+ * @return array
+ */
+ public function unusable_template_cases(): array {
+ return array(
+ 'coming-soon_template-not-a-string' => array( 'coming-soon_template', null ),
+ 'coming-soon_template-missing' => array( 'coming-soon_template', '/missing-coming-soon.php' ),
+ 'coming-soon_template-directory' => array( 'coming-soon_template', __DIR__ ),
+ 'woocommerce_locate_template-missing' => array( 'woocommerce_locate_template', '/missing-coming-soon.php' ),
+ );
+ }
+
+ /**
+ * @testdox Should render a complete escaped document with the Coming Soon marker and native login.
+ */
+ public function test_entire_site_php_output(): void {
+ update_option( 'blogname', 'Test & shop' );
+ update_option( 'blogdescription', 'A <script>alert(1)</script> tagline' );
+ $hook_counts = array();
+ foreach ( array( 'wp_head', 'wp_body_open', 'wp_footer' ) as $hook ) {
+ $hook_counts[ $hook ] = 0;
+ add_action(
+ $hook,
+ function () use ( &$hook_counts, $hook ) {
+ ++$hook_counts[ $hook ];
+ }
+ );
+ }
+ $output = $this->render( $this->sut->handle_classic_template_include( '/original.php' ) );
+
+ $this->assertSame( 1, substr_count( strtolower( $output ), '<!doctype html>' ) );
+ $this->assertSame(
+ array(
+ 'wp_head' => 1,
+ 'wp_body_open' => 1,
+ 'wp_footer' => 1,
+ ),
+ $hook_counts
+ );
+ $this->assertSame( 1, substr_count( $output, '</html>' ) );
+ $this->assertSame( 1, substr_count( $output, "<meta name='woo-coming-soon-page' content='yes'>" ) );
+ $this->assertStringContainsString( 'Test & shop is coming soon', $output );
+ $this->assertStringContainsString( esc_url( wp_login_url() ), $output );
+ $this->assertStringNotContainsString( '<script>alert(1)</script>', $output );
+ $this->assertStringNotContainsString( 'wp-block-woocommerce-coming-soon', $output );
+ $this->assertStringNotContainsString( 'facebook.com', $output );
+ }
+
+ /**
+ * @testdox Should render the store-only message inside the theme's header and footer, with the Coming Soon marker in the head.
+ */
+ public function test_store_only_php_output(): void {
+ update_option( 'woocommerce_store_pages_only', 'yes' );
+ $shop_id = $this->factory->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_content' => 'Protected shop content',
+ )
+ );
+ update_option( 'woocommerce_shop_page_id', $shop_id );
+ $this->go_to( get_permalink( $shop_id ) );
+ // The bootstrap does not load Storefront's functions.php, which its header.php needs.
+ $this->use_fixture_theme(
array(
- 'role' => 'shop_manager',
+ 'header.php' => '<!doctype html><html><head><?php wp_head(); ?></head><body>',
+ 'footer.php' => '<?php wp_footer(); ?></body></html>',
)
);
- wp_set_current_user( $user_id );
- $wp = new \WP();
- $wp->request = '/';
- do_action_ref_array( 'parse_request', array( &$wp ) );
+ $output = $this->render( $this->sut->handle_classic_template_include( '/original.php' ) );
+
+ $this->assertSame( 1, substr_count( strtolower( $output ), '<!doctype html>' ), 'The theme header must open the document once.' );
+ $this->assertSame( 1, substr_count( $output, '</html>' ), 'The theme footer must close the document once.' );
+ $this->assertSame( 1, substr_count( $output, "<meta name='woo-coming-soon-page' content='yes'>" ) );
+ $this->assertLessThan( strpos( $output, '</head>' ), strpos( $output, "<meta name='woo-coming-soon-page'" ), 'The marker must print inside the head.' );
+ $this->assertStringContainsString( 'Great things are on the horizon', $output );
+ $this->assertStringNotContainsString( 'Protected shop content', $output );
+ }
+
+ /**
+ * @testdox Should hide the product from theme footer code on product pages, so it neither crashes nor shows product details.
+ */
+ public function test_product_page_clears_product_global(): void {
+ update_option( 'woocommerce_store_pages_only', 'yes' );
+ $product = \WC_Helper_Product::create_simple_product();
+ $this->go_to( get_permalink( $product->get_id() ) );
+ $this->assertIsString( $GLOBALS['product'] ?? null, 'WordPress must expose the product query var as the slug string.' );
+ $this->use_fixture_theme(
+ array(
+ 'header.php' => '<!doctype html><html><head><?php wp_head(); ?></head><body>',
+ 'footer.php' => '<?php wp_footer(); ?></body></html>',
+ )
+ );
+ // Mirrors theme footer code such as Storefront's sticky add-to-cart bar (#52375, #54294).
+ add_action(
+ 'wp_footer',
+ function () {
+ global $product;
+ if ( $product && is_product() ) {
+ echo 'Product bar: ' . esc_html( $product->get_price() );
+ }
+ }
+ );
+
+ $output = $this->render( $this->sut->handle_classic_template_include( '/original.php' ) );
+
+ $this->assertNull( $GLOBALS['product'] ?? null, 'Theme code must see no product.' );
+ $this->assertStringContainsString( "<meta name='woo-coming-soon-page' content='yes'>", $output );
+ $this->assertStringNotContainsString( 'Product bar', $output );
+ }
+
+ /**
+ * @testdox Should render one nonempty escaped document title with and without theme title support.
+ * @testWith [false, "Shop & <launch>"]
+ * [true, "Shop & <launch>"]
+ *
+ * @param bool $title_support Whether the theme supports native title-tag rendering.
+ * @param string $filtered_title Raw fallback input, or the escaped title expected by WordPress's native renderer.
+ */
+ public function test_entire_site_php_title( bool $title_support, string $filtered_title ): void {
+ // Theme features are restored in tearDown(), and hooks by the base test case.
+ remove_theme_support( 'title-tag' );
+ if ( $title_support ) {
+ $GLOBALS['_wp_theme_features']['title-tag'] = array(); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Simulate support after wp_loaded, when WordPress rejects add_theme_support for title-tag.
+ }
+ $this->assertSame( $title_support, current_theme_supports( 'title-tag' ) );
+ remove_action( 'wp_head', '_block_template_render_title_tag', 1 );
+ add_action( 'wp_head', '_wp_render_title_tag', 1 );
+ add_filter( 'pre_get_document_title', fn() => $filtered_title );
+
+ $output = $this->render( $this->sut->handle_classic_template_include( '/original.php' ) );
+
+ preg_match_all( '/<title>(.*?)<\/title>/is', $output, $titles );
+ $this->assertCount( 1, $titles[1], 'The standalone document must have exactly one title.' );
+ $this->assertSame( 'Shop & <launch>', trim( $titles[1][0] ) );
+ }
+
+ /**
+ * @testdox Should defer the block renderer without output on classic themes.
+ */
+ public function test_legacy_renderer_defers_to_php(): void {
+ $template = null;
+ $output = $this->capture(
+ function () use ( &$template ) {
+ $template = $this->sut->handle_template_include( '/original.php' );
+ }
+ );
+
+ $this->assertSame( '/original.php', $template );
+ $this->assertSame( '', $output );
+ }
+
+ /**
+ * @testdox Should let only lower-priority template_include callbacks run on classic Coming Soon requests, so none can print or replace the page.
+ */
+ public function test_classic_request_removes_later_template_include_callbacks(): void {
+ $ran = array();
+ $this->hook_handler_with_probes( $ran );
+
+ $template = null;
+ $output = $this->capture(
+ function () use ( &$template ) {
+ $template = apply_filters( 'template_include', '/original.php' );
+ }
+ );
+
+ $this->assertSame( self::ENTIRE_SITE_TEMPLATE, $template );
+ $this->assertSame( array( 1 ), $ran, 'Only callbacks below the guard may run.' );
+ $this->assertSame( 'Printed at 1. ', $output );
+ }
+
+ /**
+ * @testdox Should leave every template_include callback in place when Coming Soon does not apply or the PHP template selector is unhooked.
+ * @testWith ["administrator"]
+ * ["selector_unhooked"]
+ *
+ * @param string $scenario Why the guard must step aside.
+ */
+ public function test_guard_keeps_template_include_callbacks( string $scenario ): void {
+ if ( 'administrator' === $scenario ) {
+ wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+ }
+ $ran = array();
+ $handler = $this->hook_handler_with_probes( $ran );
+ if ( 'selector_unhooked' === $scenario ) {
+ remove_filter( 'template_include', array( $handler, 'handle_classic_template_include' ), PHP_INT_MAX );
+ }
+
+ $template = null;
+ $this->capture(
+ function () use ( &$template ) {
+ $template = apply_filters( 'template_include', '/original.php' );
+ }
+ );
+
+ $this->assertSame( '/protected-' . PHP_INT_MAX . '.php', $template );
+ $this->assertSame( array( 1, 10, 999, PHP_INT_MAX ), $ran );
+ }
+
+ /**
+ * @testdox Should leave other template_include callbacks in place on block themes.
+ */
+ public function test_guard_keeps_callbacks_on_block_themes(): void {
+ switch_theme( 'twentytwentytwo' );
+ add_filter( 'template_include', array( $this->sut, 'handle_classic_template_include' ), PHP_INT_MAX );
+ $probe = fn( $template ) => $template;
+ add_filter( 'template_include', $probe, 999 );
+
+ $this->sut->remove_other_template_include_callbacks( '/original.php' );
+
+ $this->assertSame( 999, has_filter( 'template_include', $probe ) );
+ }
+
+ /**
+ * @testdox Should decide Coming Soon once per request, so an excluding filter fires once although the guard and the selector both ask.
+ */
+ public function test_coming_soon_is_decided_once_per_request(): void {
+ $calls = 0;
+ add_filter(
+ 'woocommerce_coming_soon_exclude',
+ function () use ( &$calls ) {
+ ++$calls;
+ return true;
+ }
+ );
+ $ran = array();
+ $this->hook_handler_with_probes( $ran );
+
+ $this->capture(
+ function () {
+ apply_filters( 'template_include', '/original.php' );
+ }
+ );
+
+ $this->assertSame( 1, $calls );
+ }
+
+ /**
+ * Hook a fresh handler as WooCommerce does, plus template_include probes at priorities 1, 10, 999 and PHP_INT_MAX that print and return a path.
+ *
+ * @param array $ran Receives the priorities of the probes that ran, in order.
+ * @return ComingSoonRequestHandler The hooked handler.
+ */
+ private function hook_handler_with_probes( array &$ran ): ComingSoonRequestHandler {
+ $handler = new ComingSoonRequestHandler();
+ remove_all_actions( 'plugins_loaded' );
+ remove_all_filters( 'template_include' );
+ $handler->init( wc_get_container()->get( ComingSoonHelper::class ) );
+ do_action( 'plugins_loaded' );
+ foreach ( array( 1, 10, 999, PHP_INT_MAX ) as $priority ) {
+ add_filter(
+ 'template_include',
+ function () use ( &$ran, $priority ) {
+ $ran[] = $priority;
+ echo esc_html( 'Printed at ' . $priority . '. ' );
+ return '/protected-' . $priority . '.php';
+ },
+ $priority
+ );
+ }
+ return $handler;
+ }
+
+ /**
+ * @testdox Should keep the deprecated block template initializer as a no-op.
+ */
+ public function test_possibly_init_block_templates_is_deprecated_noop(): void {
+ $this->setExpectedDeprecated( ComingSoonRequestHandler::class . '::possibly_init_block_templates' );
+ $controller = BlocksPackage::container()->get( BlockTemplatesController::class );
+ remove_all_filters( 'get_block_templates' );
+
+ $this->sut->possibly_init_block_templates();
- $this->assertSame( $wp->query_vars['page_id'], null );
+ $this->assertFalse( has_filter( 'get_block_templates', array( $controller, 'add_db_templates_with_woo_slug' ) ) );
}
/**