Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integrate with WooCommerce to offload Google Analytics to Web Worker #1563

Merged
merged 24 commits into from
Sep 30, 2024

Conversation

westonruter
Copy link
Member

@westonruter westonruter commented Sep 27, 2024

Summary

This integrates WooCommerce's Google Analytics integration with Partytown to offload gtag() to a worker. Inline scripts now no longer block a registered script from being offloaded to a worker; any associated inline scripts are also offloaded to a worker. It also improves the way that the configuration is managed and fleshes out the README for the initial release.

See #1455 (integration with Site Kit and Rank Math can be done in separate PRs)

Diff of Prettier-formatted page with plugin active
--- /tmp/before.html	2024-09-28 23:41:35.338249993 -0700
+++ /tmp/after.html	2024-09-28 23:44:32.403255392 -0700
@@ -1729,7 +1729,153 @@
         visibility: visible;
       }
     </style>
-    <script id="woocommerce-google-analytics-integration-gtag-js-after">
+    <script id="web-worker-offloading-js-before">
+      window.partytown = {
+        lib: "\/wp-content\/plugins\/web-worker-offloading\/build\/",
+        forward: ["dataLayer.push"],
+        debug: true,
+        mainWindowAccessors: ["wp", "ga4w"],
+        globalFns: ["gtag"],
+      };
+    </script>
+    <script id="web-worker-offloading-js-after">
+      const t = { preserveBehavior: !1 },
+        e = (e) => {
+          if ("string" == typeof e) return [e, t];
+          const [n, r = t] = e;
+          return [n, { ...t, ...r }];
+        },
+        n = Object.freeze(
+          (() => {
+            const t = new Set();
+            let e = [];
+            do {
+              Object.getOwnPropertyNames(e).forEach((n) => {
+                "function" == typeof e[n] && t.add(n);
+              });
+            } while ((e = Object.getPrototypeOf(e)) !== Object.prototype);
+            return Array.from(t);
+          })(),
+        );
+      !(function (t, r, o, i, a, s, c, d, l, p, u = t, f) {
+        function h() {
+          f ||
+            ((f = 1),
+            "/" ==
+              (c = (s.lib || "/~partytown/") + (s.debug ? "debug/" : ""))[0] &&
+              ((l = r.querySelectorAll('script[type="text/partytown"]')),
+              i != t
+                ? i.dispatchEvent(new CustomEvent("pt1", { detail: t }))
+                : ((d = setTimeout(v, 999999999)),
+                  r.addEventListener("pt0", w),
+                  a
+                    ? y(1)
+                    : o.serviceWorker
+                    ? o.serviceWorker
+                        .register(c + (s.swPath || "partytown-sw.js"), {
+                          scope: c,
+                        })
+                        .then(function (t) {
+                          t.active
+                            ? y()
+                            : t.installing &&
+                              t.installing.addEventListener(
+                                "statechange",
+                                function (t) {
+                                  "activated" == t.target.state && y();
+                                },
+                              );
+                        }, console.error)
+                    : v())));
+        }
+        function y(e) {
+          (p = r.createElement(e ? "script" : "iframe")),
+            (t._pttab = Date.now()),
+            e ||
+              ((p.style.display = "block"),
+              (p.style.width = "0"),
+              (p.style.height = "0"),
+              (p.style.border = "0"),
+              (p.style.visibility = "hidden"),
+              p.setAttribute("aria-hidden", !0)),
+            (p.src =
+              c +
+              "partytown-" +
+              (e
+                ? "atomics.js?v=0.10.2-dev1727590485751"
+                : "sandbox-sw.html?" + t._pttab)),
+            r.querySelector(s.sandboxParent || "body").appendChild(p);
+        }
+        function v(n, o) {
+          for (
+            w(),
+              i == t &&
+                (s.forward || []).map(function (n) {
+                  const [r] = e(n);
+                  delete t[r.split(".")[0]];
+                }),
+              n = 0;
+            n < l.length;
+            n++
+          )
+            ((o = r.createElement("script")).innerHTML = l[n].innerHTML),
+              (o.nonce = s.nonce),
+              r.head.appendChild(o);
+          p && p.parentNode.removeChild(p);
+        }
+        function w() {
+          clearTimeout(d);
+        }
+        (s = t.partytown || {}),
+          i == t &&
+            (s.forward || []).map(function (r) {
+              const [o, { preserveBehavior: i }] = e(r);
+              (u = t),
+                o.split(".").map(function (e, r, o) {
+                  var a;
+                  u = u[o[r]] =
+                    r + 1 < o.length
+                      ? u[o[r]] || ((a = o[r + 1]), n.includes(a) ? [] : {})
+                      : (() => {
+                          let e = null;
+                          if (i) {
+                            const { methodOrProperty: n, thisObject: r } = ((
+                              t,
+                              e,
+                            ) => {
+                              let n = t;
+                              for (let t = 0; t < e.length - 1; t += 1)
+                                n = n[e[t]];
+                              return {
+                                thisObject: n,
+                                methodOrProperty:
+                                  e.length > 0 ? n[e[e.length - 1]] : void 0,
+                              };
+                            })(t, o);
+                            "function" == typeof n &&
+                              (e = (...t) => n.apply(r, ...t));
+                          }
+                          return function () {
+                            let n;
+                            return (
+                              e && (n = e(arguments)),
+                              (t._ptf = t._ptf || []).push(o, arguments),
+                              n
+                            );
+                          };
+                        })();
+                });
+            }),
+          "complete" == r.readyState
+            ? h()
+            : (t.addEventListener("DOMContentLoaded", h),
+              t.addEventListener("load", h));
+      })(window, document, navigator, top, window.crossOriginIsolated);
+    </script>
+    <script
+      id="woocommerce-google-analytics-integration-gtag-js-after"
+      type="text/partytown"
+    >
       /* Google Analytics for WooCommerce (gtag.js) */
       window.dataLayer = window.dataLayer || [];
       function gtag() {
@@ -3637,12 +3783,13 @@
       id="wc-product-collection-block-frontend-js"
     ></script>
     <script
-      async
+      type="text/partytown"
       src="https://www.googletagmanager.com/gtag/js?id=G-12345"
       id="google-tag-manager-js"
       data-wp-strategy="async"
     ></script>
     <script
+      type="text/partytown"
       src="http://localhost:8888/wp-content/plugins/woocommerce-google-analytics-integration/assets/js/build/main.js?ver=0fcb8d9b3b96ceffc047"
       id="woocommerce-google-analytics-integration-js"
     ></script>

Looking at network logs after click an Add to Cart button...

Before:

image

After:

image

As can be seen here, with the WWO plugin active, the HTTP request to Google Analytics is happening in a worker.

How to test

  1. Install WooCommerce
  2. Install Google Analytics for WooCommerce
  3. Configure a Shop with imported sample products.
  4. Make the Shop live rather than serve a Coming Soon page.
  5. Navigate to /wp-admin/admin.php?page=wc-settings&tab=integration&section=google_analytics and supply some GA ID, for example: image
  6. Try clicking an Add to Cart button on the Shop page.
  7. Look at the Network log and confirm that the request to google-analytics.com is being made in the Web Worker as opposed to the main thread.

Ideally you would also look at Google Analytics itself to see that the events are being tracked as expected, but this is beyond my off-hand knowledge.

Relevant technical choices

WooCommerce needs to access wp.i18n from a worker-offloaded script, but unfortunately Partytown skips serializing any object properties that begin with _, meaning calling wp.i18n.__() will throw an error. I've raised this issue as BuilderIO/partytown#629 and implemented a workaround in my fork westonruter/partytown#1. I've updated the @builder.io/partytown npm dependency to use my fork.

See PR comments for additional annotations.

@westonruter westonruter added [Type] Enhancement A suggestion for improvement of an existing feature [Plugin] Web Worker Offloading Issues for the Web Worker Offloading plugin. no milestone PRs that do not have a defined milestone for release labels Sep 27, 2024
@westonruter
Copy link
Member Author

westonruter commented Sep 27, 2024

@felixarntz @adamsilverstein This isn't ready for a full review, but I wanted to get a pre-review from you guys as the overall foundation is in place here.

Current diff with WooCommerce after formatting with Prettier
--- /tmp/before.html	2024-09-27 15:07:35.737764370 -0700
+++ /tmp/after.html	2024-09-27 15:09:10.461767260 -0700
@@ -2196,7 +2196,10 @@
         visibility: visible;
       }
     </style>
-    <script id="woocommerce-google-analytics-integration-gtag-js-after">
+    <script
+      id="woocommerce-google-analytics-integration-gtag-js-after"
+      type="text/partytown"
+    >
       /* Google Analytics for WooCommerce (gtag.js) */
       window.dataLayer = window.dataLayer || [];
       function gtag() {
@@ -3185,6 +3188,144 @@
         document.body.className = c;
       })();
     </script>
+    <script id="web-worker-offloading-js-before">
+      window.partytown = {
+        lib: "\/wp-content\/plugins\/web-worker-offloading\/build\/",
+        forward: ["dataLayer.push"],
+      };
+    </script>
+    <script id="web-worker-offloading-js-after">
+      const t = { preserveBehavior: !1 },
+        e = (e) => {
+          if ("string" == typeof e) return [e, t];
+          const [n, r = t] = e;
+          return [n, { ...t, ...r }];
+        },
+        n = Object.freeze(
+          ((t) => {
+            const e = new Set();
+            let n = [];
+            do {
+              Object.getOwnPropertyNames(n).forEach((t) => {
+                "function" == typeof n[t] && e.add(t);
+              });
+            } while ((n = Object.getPrototypeOf(n)) !== Object.prototype);
+            return Array.from(e);
+          })(),
+        );
+      !(function (t, r, o, i, a, s, c, d, l, p, u = t, f) {
+        function h() {
+          f ||
+            ((f = 1),
+            "/" ==
+              (c = (s.lib || "/~partytown/") + (s.debug ? "debug/" : ""))[0] &&
+              ((l = r.querySelectorAll('script[type="text/partytown"]')),
+              i != t
+                ? i.dispatchEvent(new CustomEvent("pt1", { detail: t }))
+                : ((d = setTimeout(v, 1e4)),
+                  r.addEventListener("pt0", w),
+                  a
+                    ? y(1)
+                    : o.serviceWorker
+                    ? o.serviceWorker
+                        .register(c + (s.swPath || "partytown-sw.js"), {
+                          scope: c,
+                        })
+                        .then(function (t) {
+                          t.active
+                            ? y()
+                            : t.installing &&
+                              t.installing.addEventListener(
+                                "statechange",
+                                function (t) {
+                                  "activated" == t.target.state && y();
+                                },
+                              );
+                        }, console.error)
+                    : v())));
+        }
+        function y(e) {
+          (p = r.createElement(e ? "script" : "iframe")),
+            (t._pttab = Date.now()),
+            e ||
+              ((p.style.display = "block"),
+              (p.style.width = "0"),
+              (p.style.height = "0"),
+              (p.style.border = "0"),
+              (p.style.visibility = "hidden"),
+              p.setAttribute("aria-hidden", !0)),
+            (p.src =
+              c +
+              "partytown-" +
+              (e ? "atomics.js?v=0.10.2" : "sandbox-sw.html?" + t._pttab)),
+            r.querySelector(s.sandboxParent || "body").appendChild(p);
+        }
+        function v(n, o) {
+          for (
+            w(),
+              i == t &&
+                (s.forward || []).map(function (n) {
+                  const [r] = e(n);
+                  delete t[r.split(".")[0]];
+                }),
+              n = 0;
+            n < l.length;
+            n++
+          )
+            ((o = r.createElement("script")).innerHTML = l[n].innerHTML),
+              (o.nonce = s.nonce),
+              r.head.appendChild(o);
+          p && p.parentNode.removeChild(p);
+        }
+        function w() {
+          clearTimeout(d);
+        }
+        (s = t.partytown || {}),
+          i == t &&
+            (s.forward || []).map(function (r) {
+              const [o, { preserveBehavior: i }] = e(r);
+              (u = t),
+                o.split(".").map(function (e, r, o) {
+                  var a;
+                  u = u[o[r]] =
+                    r + 1 < o.length
+                      ? u[o[r]] || ((a = o[r + 1]), n.includes(a) ? [] : {})
+                      : (() => {
+                          let e = null;
+                          if (i) {
+                            const { methodOrProperty: n, thisObject: r } = ((
+                              t,
+                              e,
+                            ) => {
+                              let n = t;
+                              for (let t = 0; t < e.length - 1; t += 1)
+                                n = n[e[t]];
+                              return {
+                                thisObject: n,
+                                methodOrProperty:
+                                  e.length > 0 ? n[e[e.length - 1]] : void 0,
+                              };
+                            })(t, o);
+                            "function" == typeof n &&
+                              (e = (...t) => n.apply(r, ...t));
+                          }
+                          return function () {
+                            let n;
+                            return (
+                              e && (n = e(arguments)),
+                              (t._ptf = t._ptf || []).push(o, arguments),
+                              n
+                            );
+                          };
+                        })();
+                });
+            }),
+          "complete" == r.readyState
+            ? h()
+            : (t.addEventListener("DOMContentLoaded", h),
+              t.addEventListener("load", h));
+      })(window, document, navigator, top, window.crossOriginIsolated);
+    </script>
     <script
       src="http://localhost:8888/wp-includes/js/dist/vendor/wp-polyfill.js?ver=3.15.0"
       id="wp-polyfill-js"
@@ -3260,7 +3401,7 @@
       id="wc-mini-cart-block-frontend-js"
     ></script>
     <script
-      async
+      type="text/partytown"
       src="https://www.googletagmanager.com/gtag/js?id=G-12345"
       id="google-tag-manager-js"
       data-wp-strategy="async"

I'm not entirely sure yet why async is getting removed from the google-tag-manager script here, but this won't matter from a functional perspective.

@westonruter
Copy link
Member Author

I'm not entirely sure yet why async is getting removed from the google-tag-manager script here, but this won't matter from a functional perspective.

Ah, the reason is that WooCommerce is doing this:

	/**
	 * Add async to script tags with defined handles.
	 *
	 * @param string $tag HTML for the script tag.
	 * @param string $handle Handle of script.
	 * @param string $src Src of script.
	 * @return string
	 */
	public function async_script_loader_tags( $tag, $handle, $src ) {
		if ( ! in_array( $handle, array( 'google-tag-manager' ), true ) ) {
			return $tag;
		}
		// If script was output manually in wp_head, abort.
		if ( did_action( 'woocommerce_gtag_snippet' ) ) {
			return '';
		}
		return str_replace( '<script src', '<script async src', $tag );
	}

This is forcing the injection of the async attribute even though WooCommerce is also registering the script with 'strategy' => 'async'. Core removes async from this SCRIPT tag because the script has an inline after script. So this is a case where an async script with an after script should be forced a la Core-58632 which we closed as maybelater since it wasn't a common enough occurrence. (In reality, WooCommerce could change this to a inline before script instead and the behavior would be unchanged while the async attribute would be preserved without having to filter script_loader_tag.) In any case, there is also a wc-blocks-google-analytics script which is a dependent of this script but it is registered with $in_footer = true, without the necessary array( 'strategy' => 'async' ) to allow the google-tag-manager dependency to be served as async.

The reason why WooCommerce's script_loader_tag isn't able to inject async anymore into the script tag is because WWO is setting the type attribute to text/partytown and this is added to the beginning of the tag like <script type="text/partytown" so WooCommerce's replacement of <script src no longer matches.

In the end, this doesn't matter at all because a Partytown-run script is effectively also async.

*
* @return array{ debug?: bool, forward?: non-empty-string[], lib: non-empty-string, loadScriptsOnMainThread?: non-empty-string[], nonce?: non-empty-string } Configuration for Partytown.
*/
function wwo_get_configuration(): array {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just moved this function from hooks.php to helpers.php

Comment on lines -92 to -96
// TODO: This should be reconsidered because scripts needing to be offloaded will often have after scripts. See <https://github.com/WordPress/performance/pull/1497/files#r1733538721>.
if ( false === wp_scripts()->get_data( $handle, 'strategy' ) ) {
wp_script_add_data( $handle, 'strategy', 'async' ); // The 'defer' strategy would work as well.
wp_script_add_data( $handle, 'wwo_strategy_added', true );
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline after scripts are often used with analytics, and they need to be offloaded to a worker (maybe always?).

wp_script_add_data( $handle, 'strategy', 'async' ); // The 'defer' strategy would work as well.
wp_script_add_data( $handle, 'wwo_strategy_added', true );
}
break;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We break because we only need to prepend the WWO script when encountering the first script that is to be offloaded to a worker.

}
}
return $script_handles;
}
add_filter( 'print_scripts_array', 'wwo_filter_print_scripts_array' );
add_filter( 'print_scripts_array', 'wwo_filter_print_scripts_array', PHP_INT_MAX );
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The priority is increased to PHP_INT_MAX so that functions like wwo_mark_scripts_for_offloading() can add print_scripts_array filters to mark scripts for worker offloading easily at earlier priorities.

$html_processor->set_attribute( 'type', 'text/partytown' );
$tag = $html_processor->get_updated_html();
break;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only one tag should have a given ID, so once we found it, we can bail.

@westonruter westonruter changed the title Integrate with popular plugins to offload Google Analytics to Web Worker Integrate with WooCommerce to offload Google Analytics to Web Worker Sep 29, 2024
@westonruter westonruter marked this pull request as ready for review September 29, 2024 07:18
Copy link

github-actions bot commented Sep 29, 2024

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: westonruter <[email protected]>
Co-authored-by: swissspidy <[email protected]>
Co-authored-by: felixarntz <[email protected]>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@westonruter
Copy link
Member Author

If this is approved, I'm thinking to submit it to the plugin directory for a Monday release.

Copy link
Member

@felixarntz felixarntz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@westonruter This mostly looks good, just a few questions / suggestions.

*/
function wwo_get_configuration(): array {
$config = array(
'lib' => wp_parse_url( plugin_dir_url( __FILE__ ), PHP_URL_PATH ) . 'build/',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There used to be forward in here, can you clarify why you removed it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, because it was empty so it wasn't having any purpose.

Comment on lines +26 to +29
$configuration['mainWindowAccessors'][] = 'wp'; // Because woocommerce-google-analytics-integration needs to access wp.i18n.
$configuration['mainWindowAccessors'][] = 'ga4w'; // Because woocommerce-google-analytics-integration needs to access window.ga4w.
$configuration['globalFns'][] = 'gtag'; // Because gtag() is defined in one script and called in another.
$configuration['forward'][] = 'dataLayer.push'; // Because the Partytown integration has this in its example config.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't these cause PHP notices because the keys they add to are not initialized as arrays?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No it doesn't: https://3v4l.org/YPITA

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact, this also doesn't cause any notice:

$foo['bar']['baz'][] = 'quux';

It results in $foo being defined as:

array(1) {
  ["bar"]=>
  array(1) {
    ["baz"]=>
    array(1) {
      [0]=>
      string(4) "quux"
    }
  }
}

https://3v4l.org/rMVi7

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I learned something today. :)

*
* @param array<string, mixed> $config Configuration for Partytown.
*/
return (array) apply_filters( 'wwo_configuration', $config );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be good to add some sanitization here.

For example, since several supported properties/keys are arrays and several plugins/sources may add similar entries to them, we could sanitize these arrays using array_unique(). Just thinking about what this PR implements for WooCommerce for example allowlisting wp, I'm sure lots of scripts would need that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would agree with you, but I think sanitizing for this initial version could go down a rabbit hole.

The number of possible configuration options is large and the value types can be complex: https://github.com/BuilderIO/partytown/blob/main/src/lib/types.ts

We could implement a JSON Schema to mirror the TypeScript types and use this to provide usage warnings. However, I think this validation would be better done inside Partytown itself where the types are being defined.

In the case of mainWindowAccessors, there's no need for array_unique() because Partytown is using the value like this:

              } else if (webWorkerCtx.$config$.mainWindowAccessors?.includes(propName)) {
                return getter(this, [propName]);

So I think we should consider what validation and sanitization we do as part of this plugin for a subsequent release.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it depends also on how we want to use their configuration options. We don't need to holistically capture everything Partytown supports, that would indeed be a rabbit hole. But for instance, it seems that mainWindowAccessors will be relevant in how we would use Partytown for quite a few integrations, so we could do some sanitization there where it makes sense. In other words, I think we should do this based on demand.

As long as all of these 4 arguments only support an array of strings, and as long as duplicate values are not a problem, it's probably okay not to sanitize anything. So we can move ahead with this as is, but I think we should look out for when things are more complicated to use, or could be of more than one type, and especially then we should do some sanitization for how we want those arguments to be used.

Comment on lines +27 to +29
if ( WP_DEBUG && SCRIPT_DEBUG ) {
$config['debug'] = true;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about always having this key set, and making the conditional only around whether it's true or false?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, because it defaults to false anyway, and by not setting it we avoid sending (a tiny amount) of bytes down the wire.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's probably negligible though for performance. I'd say initializing properties here makes it easier to understand what's available, also since this PHP layer is entirely separate from the actual JS API where this is consumed.

Copy link
Member Author

@westonruter westonruter Sep 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure. If someone wants to know what is available they should look at the docs which are linked in the source code:

* @link https://partytown.builder.io/configuration
* @link https://github.com/BuilderIO/partytown/blob/b292a14047a0c12ca05ba97df1833935d42fdb66/src/lib/types.ts#L393-L548

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't want to have to supply the default values for very single configuration option (many of which are under-documented in Partytown).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fair. Let's leave this as is then.

…it is not yet ready for release."

This reverts commit 16222c1.
@westonruter westonruter merged commit 282a068 into trunk Sep 30, 2024
14 checks passed
@westonruter westonruter deleted the add/wwo-gtag-offloading branch September 30, 2024 20:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
no milestone PRs that do not have a defined milestone for release [Plugin] Web Worker Offloading Issues for the Web Worker Offloading plugin. [Type] Enhancement A suggestion for improvement of an existing feature
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants