diff --git a/FeatureShowcase_Plugin_Admin.php b/FeatureShowcase_Plugin_Admin.php
index b9589dcc7..11d61bada 100644
--- a/FeatureShowcase_Plugin_Admin.php
+++ b/FeatureShowcase_Plugin_Admin.php
@@ -240,6 +240,22 @@ private static function get_cards() {
return array(
'new' => array(
+ 'partytown' => array(
+ 'title' => esc_html__( 'PartyTown', 'w3-total-cache' ),
+ 'icon' => 'dashicons-performance',
+ 'text' => esc_html__( 'This feature allows you to optimize third-party scripts by offloading them to web workers using PartyTown. It significantly improves your site\'s performance by moving heavy JavaScript tasks off the main thread.', 'w3-total-cache' ),
+ 'button' => '',
+ 'link' => '' . __( 'More info', 'w3-total-cache' ) . '',
+ 'is_premium' => true,
+ 'is_new' => true,
+ 'version' => 'X.X.X',
+ ),
'alwayscached' => array(
'title' => esc_html__( 'Always Cached', 'w3-total-cache' ),
'icon' => 'dashicons-yes',
diff --git a/Generic_Plugin.php b/Generic_Plugin.php
index 535589576..8b8b7dad9 100644
--- a/Generic_Plugin.php
+++ b/Generic_Plugin.php
@@ -550,6 +550,7 @@ function ob_callback( $buffer ) {
'swarmify',
'lazyload',
'removecssjs',
+ 'partytown',
'deferscripts',
'minify',
'newrelic',
diff --git a/Root_Environment.php b/Root_Environment.php
index 39619bc2e..27b176e6b 100644
--- a/Root_Environment.php
+++ b/Root_Environment.php
@@ -178,6 +178,7 @@ private function get_handlers() {
new Cdn_Environment(),
new Extension_ImageService_Environment(),
new Extension_AlwaysCached_Environment(),
+ new UserExperience_PartyTown_Environment(),
);
return $a;
diff --git a/UserExperience_GeneralPage_View.php b/UserExperience_GeneralPage_View.php
index e1d086085..c393ac618 100644
--- a/UserExperience_GeneralPage_View.php
+++ b/UserExperience_GeneralPage_View.php
@@ -169,6 +169,40 @@
)
);
+ Util_Ui::config_item_extension_enabled(
+ array(
+ 'extension_id' => 'user-experience-partytown',
+ 'checkbox_label' => esc_html__( 'Enable PartyTown', 'w3-total-cache' ),
+ 'description' => __(
+ 'This feature allows you to optimize third-party scripts by offloading them to web workers using PartyTown.',
+ 'w3-total-cache'
+ ) . (
+ UserExperience_PartyTown_Extension::is_enabled()
+ ? wp_kses(
+ sprintf(
+ // translators: 1 opening HTML a tag to W3TC User Experience page, 2 closing HTML a tag.
+ __(
+ ' Settings can be found on the %1$sUser Experience page%2$s.',
+ 'w3-total-cache'
+ ),
+ '',
+ ''
+ ),
+ array(
+ 'a' => array(
+ 'href' => array(),
+ ),
+ )
+ )
+ : ''
+ ),
+ 'label_class' => 'w3tc_single_column',
+ 'pro' => true,
+ 'disabled' => ! Util_Environment::is_w3tc_pro( $config ) ? true : false,
+ 'show_learn_more' => true,
+ )
+ );
+
Util_Ui::config_item_extension_enabled(
array(
'extension_id' => 'user-experience-preload-requests',
diff --git a/UserExperience_PartyTown_Environment.php b/UserExperience_PartyTown_Environment.php
new file mode 100644
index 000000000..97cd0a8f0
--- /dev/null
+++ b/UserExperience_PartyTown_Environment.php
@@ -0,0 +1,182 @@
+get_boolean( 'config.check' ) || $force_all_checks ) {
+ if ( UserExperience_PartyTown_Extension::is_enabled() ) {
+ try {
+ $this->rules_add( $config, $exs );
+ } catch ( Util_WpFile_FilesystemOperationException $ex ) {
+ $exs->push( $ex );
+ }
+ } else {
+ $this->rules_remove( $exs );
+ }
+ }
+
+ if ( count( $exs->exceptions() ) > 0 ) {
+ throw $exs;
+ }
+ }
+
+ /**
+ * Fixes environment once event occurs.
+ *
+ * @since X.X.X
+ *
+ * @param Config $config Config object.
+ * @param mixed $event Event.
+ * @param Config $old_config Old config object.
+ */
+ public function fix_on_event( $config, $event, $old_config = null ) {
+ }
+
+ /**
+ * Fixes environment after plugin deactivation
+ *
+ * @since X.X.X
+ *
+ * @throws Util_Environment_Exceptions Exceptions.
+ */
+ public function fix_after_deactivation() {
+ $exs = new Util_Environment_Exceptions();
+
+ $this->rules_remove( $exs );
+
+ if ( count( $exs->exceptions() ) > 0 ) {
+ throw $exs;
+ }
+ }
+
+ /**
+ * Returns required rules for module.
+ *
+ * @since X.X.X
+ *
+ * @param Config $config Configuration object.
+ * @return array
+ */
+ public function get_required_rules( $config ) {
+ return array(
+ array(
+ 'filename' => Util_Rule::get_browsercache_rules_cache_path(),
+ 'content' => $this->rules_generate(),
+ ),
+ );
+ }
+
+ /**
+ * Write rewrite rules.
+ *
+ * @since X.X.X
+ *
+ * @param Config $config Configuration.
+ * @param Util_Environment_Exceptions $exs Exceptions.
+ *
+ * @throws Util_WpFile_FilesystemOperationException S/FTP form if it can't get the required filesystem credentials.
+ */
+ private function rules_add( $config, $exs ) {
+ Util_Rule::add_rules(
+ $exs,
+ Util_Rule::get_browsercache_rules_cache_path(),
+ $this->rules_generate(),
+ W3TC_MARKER_BEGIN_PARTYTOWN,
+ W3TC_MARKER_END_PARTYTOWN,
+ array(
+ W3TC_MARKER_BEGIN_BROWSERCACHE_CACHE => 0,
+ W3TC_MARKER_BEGIN_WORDPRESS => 0,
+ )
+ );
+ }
+
+ /**
+ * Generate rewrite rules.
+ *
+ * @since X.X.X
+ *
+ * @see Dispatcher::nginx_rules_for_browsercache_section()
+ *
+ * @return string
+ */
+ private function rules_generate() {
+ $config = Dispatcher::config();
+
+ switch ( true ) {
+ case Util_Environment::is_apache():
+ case Util_Environment::is_litespeed():
+ return '
+# BEGIN W3TC PartyTown
+# Serve all Partytown JavaScript files with the correct MIME type
+
+ ForceType application/javascript
+
+
+' . ( $config->get_boolean( array( 'user-experience-partytown', 'atomics' ) ) ? '
+Header set Cross-Origin-Opener-Policy "same-origin"
+Header set Cross-Origin-Embedder-Policy "require-corp"
+' : '' ) . '
+# END W3TC PartyTown
+';
+
+ case Util_Environment::is_nginx():
+ return '
+# BEGIN W3TC PartyTown
+# Serve the actual PartyTown JavaScript files with the correct MIME type
+location ~ ^' . preg_quote( plugin_dir_path( __FILE__ ) . '/lib/PartyTown/lib/' ) . 'partytown-.*\.js$ {
+ default_type application/javascript;
+}
+
+' . ( $config->get_boolean( array( 'user-experience-partytown', 'atomics' ) ) ? '
+add_header Cross-Origin-Opener-Policy same-origin;
+add_header Cross-Origin-Embedder-Policy require-corp;
+' : '' ) . '
+# END W3TC PartyTown
+';
+
+ default:
+ return '';
+ }
+ }
+
+ /**
+ * Removes cache directives
+ *
+ * @since X.X.X
+ *
+ * @param Util_Environment_Exceptions $exs Exceptions.
+ *
+ * @throws Util_WpFile_FilesystemOperationException S/FTP form if it can't get the required filesystem credentials.
+ */
+ private function rules_remove( $exs ) {
+ Util_Rule::remove_rules(
+ $exs,
+ Util_Rule::get_browsercache_rules_cache_path(),
+ W3TC_MARKER_BEGIN_PARTYTOWN,
+ W3TC_MARKER_END_PARTYTOWN
+ );
+ }
+}
diff --git a/UserExperience_PartyTown_Extension.php b/UserExperience_PartyTown_Extension.php
new file mode 100644
index 000000000..2eed7d030
--- /dev/null
+++ b/UserExperience_PartyTown_Extension.php
@@ -0,0 +1,284 @@
+config = Dispatcher::config();
+ }
+
+ /**
+ * Runs User Experience PartyTown feature.
+ *
+ * @since X.X.X
+ *
+ * @return void
+ */
+ public function run() {
+ add_action( 'w3tc_userexperience_page', array( $this, 'w3tc_userexperience_page' ), 14 );
+
+ /**
+ * This filter is documented in Generic_AdminActions_Default.php under the read_request method.
+ */
+ add_filter( 'w3tc_config_key_descriptor', array( $this, 'w3tc_config_key_descriptor' ), 10, 2 );
+
+ if ( ! Util_Environment::is_w3tc_pro( $this->config ) ) {
+ $this->config->set_extension_active_frontend( 'user-experience-partytown', false );
+ return;
+ }
+
+ Util_Bus::add_ob_callback( 'partytown', array( $this, 'ob_callback' ) );
+
+ add_filter( 'w3tc_save_options', array( $this, 'w3tc_save_options' ), 11, 2 );
+
+ add_action( 'wp_enqueue_scripts', array( $this, 'w3tc_enqueue_partytown' ) );
+ }
+
+ /**
+ * Enqueue main PartyTown script.
+ *
+ * @since X.X.X
+ *
+ * @return void
+ */
+ public function w3tc_enqueue_partytown() {
+ $script_path = plugins_url( 'lib/PartyTown/lib/partytown.js', __FILE__ );
+ $party_path = wp_make_link_relative( plugins_url( 'lib/PartyTown/lib/', __FILE__ ) );
+ $debug = $this->config->get_boolean( array( 'user-experience-partytown', 'debug' ) ) ? 'true' : 'false';
+ $timeout = $this->config->get_integer( array( 'user-experience-partytown', 'timeout' ) ) ?? 5000;
+ $worker_concurrency = $this->config->get_integer( array( 'user-experience-partytown', 'workers' ) ) ?? 5;
+
+ wp_register_script( 'partytown', $script_path, array(), W3TC_VERSION, true );
+
+ // Preload Partytown if enabled.
+ if ( $this->config->get_boolean( array( 'user-experience-partytown', 'preload' ) ) ) {
+ wp_script_add_data( 'partytown', 'preload', 'true' );
+ }
+
+ // Prepare configuration for Partytown.
+ $inline_script = "
+ window.partytown = {
+ lib: '{$party_path}',
+ debug: {$debug},
+ timeout: {$timeout},
+ workerConcurrency: {$worker_concurrency},
+ };
+ ";
+
+ // Add inline script to configure Partytown.
+ wp_add_inline_script( 'partytown', $inline_script, 'before' );
+
+ wp_enqueue_script( 'partytown' );
+ }
+
+ /**
+ * Processes the page content buffer to modify target CSS/JS.
+ *
+ * @since X.X.X
+ *
+ * @param string $buffer page content buffer.
+ *
+ * @return string
+ */
+ public function ob_callback( $buffer ) {
+ if ( '' === $buffer || ! \W3TC\Util_Content::is_html_xml( $buffer ) ) {
+ return $buffer;
+ }
+
+ $can_process = array(
+ 'enabled' => true,
+ 'buffer' => $buffer,
+ 'reason' => null,
+ );
+
+ $can_process = $this->can_process( $can_process );
+ $can_process = apply_filters( 'w3tc_partytown_can_process', $can_process );
+
+ // set reject reason in comment.
+ if ( $can_process['enabled'] ) {
+ $reject_reason = '';
+ } else {
+ $reject_reason = empty( $can_process['reason'] ) ? ' (not specified)' : ' (' . $can_process['reason'] . ')';
+ }
+
+ $buffer = str_replace(
+ '{w3tc_partytown_reject_reason}',
+ $reject_reason,
+ $buffer
+ );
+
+ // processing.
+ if ( ! $can_process['enabled'] ) {
+ return $buffer;
+ }
+
+ $this->mutator = new UserExperience_PartyTown_Mutator( $this->config );
+
+ $buffer = $this->mutator->run( $buffer );
+
+ return $buffer;
+ }
+
+ /**
+ * Checks if the request can be processed for PartyTown.
+ *
+ * @since X.X.X
+ *
+ * @param boolean $can_process flag representing if PartyTown can be executed.
+ *
+ * @return boolean
+ */
+ private function can_process( $can_process ) {
+ if ( defined( 'WP_ADMIN' ) ) {
+ $can_process['enabled'] = false;
+ $can_process['reason'] = 'WP_ADMIN';
+
+ return $can_process;
+ }
+
+ if ( defined( 'SHORTINIT' ) && SHORTINIT ) {
+ $can_process['enabled'] = false;
+ $can_process['reason'] = 'SHORTINIT';
+
+ return $can_process;
+ }
+
+ if ( function_exists( 'is_feed' ) && is_feed() ) {
+ $can_process['enabled'] = false;
+ $can_process['reason'] = 'feed';
+
+ return $can_process;
+ }
+
+ return $can_process;
+ }
+
+ /**
+ * Adds PartyTown message to W3TC footer comment.
+ *
+ * @since X.X.X
+ *
+ * @param array $strings array of W3TC footer comments.
+ *
+ * @return array
+ */
+ public function w3tc_footer_comment( $strings ) {
+ $strings[] = __( 'PartyTown', 'w3-total-cache' ) . '{w3tc_partytown_reject_reason}';
+ return $strings;
+ }
+
+ /**
+ * Renders the user experience PartyTown settings page.
+ *
+ * @since X.X.X
+ *
+ * @return void
+ */
+ public function w3tc_userexperience_page() {
+ include __DIR__ . '/UserExperience_PartyTown_Page_View.php';
+ }
+
+ /**
+ * Specify config key typing for fields that need it.
+ *
+ * @since X.X.X
+ *
+ * @param mixed $descriptor Descriptor.
+ * @param mixed $key Compound key array.
+ *
+ * @return array
+ */
+ public function w3tc_config_key_descriptor( $descriptor, $key ) {
+ if ( is_array( $key ) && 'user-experience-partytown.includes' === implode( '.', $key ) ) {
+ $descriptor = array( 'type' => 'array' );
+ }
+
+ return $descriptor;
+ }
+
+ /**
+ * Performs actions on save.
+ *
+ * @since X.X.X
+ *
+ * @param array $data Array of save data.
+ * @param array $page String page value.
+ *
+ * @return array
+ */
+ public function w3tc_save_options( $data, $page ) {
+ if ( 'w3tc_userexperience' === $page ) {
+ $new_config =& $data['new_config'];
+ $old_config =& $data['old_config'];
+
+ $old_partytown_includes = $old_config->get_array( array( 'user-experience-partytown', 'includes' ) );
+ $new_partytown_includes = $new_config->get_array( array( 'user-experience-partytown', 'includes' ) );
+
+ if ( $new_partytown_includes !== $old_partytown_includes ) {
+ $minify_enabled = $new_config->get_boolean( 'minify.enabled' );
+ $pgcache_enabled = $new_config->get_boolean( 'pgcache.enabled' );
+ if ( $minify_enabled || $pgcache_enabled ) {
+ $state = Dispatcher::config_state();
+ if ( $minify_enabled ) {
+ $state->set( 'minify.show_note.need_flush', true );
+ }
+ if ( $pgcache_enabled ) {
+ $state->set( 'common.show_note.flush_posts_needed', true );
+ }
+ $state->save();
+ }
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Gets the enabled status of the extension.
+ *
+ * @since 2.5.1
+ *
+ * @return bool
+ */
+ public static function is_enabled() {
+ $config = Dispatcher::config();
+ $extensions_active = $config->get_array( 'extensions.active' );
+ return Util_Environment::is_w3tc_pro( $config ) && array_key_exists( 'user-experience-partytown', $extensions_active );
+ }
+}
+
+$o = new UserExperience_PartyTown_Extension();
+$o->run();
diff --git a/UserExperience_PartyTown_Mutator.php b/UserExperience_PartyTown_Mutator.php
new file mode 100644
index 000000000..cad885b82
--- /dev/null
+++ b/UserExperience_PartyTown_Mutator.php
@@ -0,0 +1,137 @@
+config = $config;
+ }
+
+ /**
+ * Runs User Experience PartyTown Mutator.
+ *
+ * @since X.X.X
+ *
+ * @param string $buffer Buffer string containing browser output.
+ *
+ * @return string
+ */
+ public function run( $buffer ) {
+ $r = apply_filters(
+ 'w3tc_partytown_mutator_before',
+ array(
+ 'buffer' => $buffer,
+ )
+ );
+
+ $this->buffer = $r['buffer'];
+
+ // Sets includes whose matches will be stripped site-wide.
+ $this->includes = $this->config->get_array(
+ array(
+ 'user-experience-partytown',
+ 'includes',
+ )
+ );
+
+ $this->buffer = preg_replace_callback(
+ '~()~is',
+ array( $this, 'modify_content' ),
+ $this->buffer
+ );
+
+ return $this->buffer;
+ }
+
+ /**
+ * Modifies matched link/script tag from HTML content.
+ *
+ * @since X.X.X
+ *
+ * @param array $matches array of matched CSS/JS entries.
+ *
+ * @return string
+ */
+ public function modify_content( $matches ) {
+ $content = $matches[0];
+
+ // Early return if not the main query or content not a match.
+ if ( ! $this->is_content_included( $content ) ) {
+ return $content;
+ }
+
+ // Check if it's a script tag and type="text/partytown" is not already present.
+ if ( strpos( $content, '
+ *
+ * ```
+ *
+ * The `nonce` property should be generated by the server, and it should be unique
+ * for each request. You can leave a placeholder, as shown in the above example,
+ * to facilitate replacement through a regular expression on the server side.
+ * For instance, you can use the following code:
+ *
+ * ```js
+ * html.replace(/THIS_SHOULD_BE_REPLACED/g, nonce);
+ * ```
+ */
+ nonce?: string;
+}
+
+/**
+ * A forward property to patch on `window`. The forward config property is an string,
+ * representing the call to forward, such as `dataLayer.push` or `fbq`.
+ *
+ * https://partytown.builder.io/forwarding-events
+ *
+ * @public
+ */
+export declare type PartytownForwardProperty = string | PartytownForwardPropertyWithSettings;
+
+/**
+ * @public
+ */
+export declare type PartytownForwardPropertySettings = {
+ preserveBehavior?: boolean;
+};
+
+/**
+ * @public
+ */
+export declare type PartytownForwardPropertyWithSettings = [string, PartytownForwardPropertySettings?];
+
+/**
+ * Function that returns the Partytown snippet as a string, which can be
+ * used as the innerHTML of the inlined Partytown script in the head.
+ *
+ * @public
+ */
+export declare const partytownSnippet: (config?: PartytownConfig) => string;
+
+/**
+ * @public
+ */
+export declare type ResolveUrlType = 'fetch' | 'xhr' | 'script' | 'iframe' | 'image';
+
+/**
+ * The `type` attribute for Partytown scripts, which does two things:
+ *
+ * 1. Prevents the `
+ *
+ * ```
+ *
+ * The `nonce` property should be generated by the server, and it should be unique
+ * for each request. You can leave a placeholder, as shown in the above example,
+ * to facilitate replacement through a regular expression on the server side.
+ * For instance, you can use the following code:
+ *
+ * ```js
+ * html.replace(/THIS_SHOULD_BE_REPLACED/g, nonce);
+ * ```
+ */
+ nonce?: string;
+}
+
+/**
+ * A forward property to patch on `window`. The forward config property is an string,
+ * representing the call to forward, such as `dataLayer.push` or `fbq`.
+ *
+ * https://partytown.builder.io/forwarding-events
+ *
+ * @public
+ */
+declare type PartytownForwardProperty = string | PartytownForwardPropertyWithSettings;
+
+/**
+ * @public
+ */
+declare type PartytownForwardPropertySettings = {
+ preserveBehavior?: boolean;
+};
+
+/**
+ * @public
+ */
+declare type PartytownForwardPropertyWithSettings = [string, PartytownForwardPropertySettings?];
+
+/**
+ * Props for ``, which extends the Partytown Config.
+ *
+ * https://github.com/BuilderIO/partytown#config
+ *
+ * @public
+ */
+export declare interface PartytownProps extends PartytownConfig {
+}
+
+/**
+ * @public
+ */
+declare type ResolveUrlType = 'fetch' | 'xhr' | 'script' | 'iframe' | 'image';
+
+/**
+ * @public
+ */
+declare type SendBeaconParameters = Pick;
+
+/**
+ * @public
+ */
+declare type SetHook = (opts: SetHookOptions) => any;
+
+/**
+ * @public
+ */
+declare interface SetHookOptions extends HookOptions {
+ value: any;
+ prevent: Symbol;
+}
+
+declare type WinId = string;
+
+declare const WinIdKey: unique symbol;
+
+declare interface WorkerInstance {
+ [WinIdKey]: WinId;
+ [InstanceIdKey]: InstanceId;
+ [ApplyPathKey]: string[];
+ [InstanceDataKey]: string | undefined;
+ [NamespaceKey]: string | undefined;
+ [InstanceStateKey]: {
+ [key: string]: any;
+ };
+}
+
+export { }
diff --git a/lib/PartyTown/react/index.mjs b/lib/PartyTown/react/index.mjs
new file mode 100644
index 000000000..0ce18cf96
--- /dev/null
+++ b/lib/PartyTown/react/index.mjs
@@ -0,0 +1,42 @@
+import React from 'react';
+import { partytownSnippet } from '../integration/index.mjs';
+
+/**
+ * The React `` component should be placed within the ``
+ * of the document. This component should work for SSR/SSG only HTML
+ * (static HTML without javascript), clientside javascript only
+ * (entire React app is build with clientside javascript),
+ * and both SSR/SSG HTML that's then hydrated by the client.
+ *
+ * @public
+ */
+const Partytown = ({ nonce, ...props } = {}) => {
+ // purposely not using useState() or useEffect() so this component
+ // can also work as a React Server Component
+ // this check is only be done on the client, and skipped over on the server
+ if (typeof document !== 'undefined' && !document._partytown) {
+ if (!document.querySelector('script[data-partytown]')) {
+ // the append script to document code should only run on the client
+ // and only if the SSR'd script doesn't already exist within the document.
+ // If the SSR'd script isn't found in the document, then this
+ // must be a clientside only render. Append the partytown script
+ // to the .
+ const scriptElm = document.createElement('script');
+ scriptElm.dataset.partytown = '';
+ scriptElm.innerHTML = partytownSnippet(props);
+ scriptElm.nonce = nonce;
+ document.head.appendChild(scriptElm);
+ }
+ // should only append this script once per document, and is not dynamic
+ document._partytown = true;
+ }
+ // `dangerouslySetInnerHTML` only works for scripts rendered as HTML from SSR.
+ // The added code will set the [type="data-pt-script"] attribute to the SSR rendered
+ //