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

Replace Twenty Twenty-One custom JS frontend code with Interactivity API #5795

Draft
wants to merge 8 commits into
base: trunk
Choose a base branch
from

Conversation

felixarntz
Copy link
Member

@felixarntz felixarntz commented Dec 19, 2023

This PR uses the Interactivity API to replace the custom frontend JS code for the Twenty Twenty-One theme. More concretely:

  • Uses the Interactivity API module if available (requires WordPress 6.5 or newer).
  • Adds a store for handling state and a few other dynamic aspects (in interactivity.js module).
  • Avoids loading the regular frontend JS code (primary-navigation.js, responsive-embeds.js, dark-mode-toggler.js) as it replaces those functionalities.

One important consideration: Since the server-side portion of the Interactivity API does not process this content automatically (unlike blocks, which are processed server-side), it is important to hydrate any dynamic attributes to have the correct initial state. For example, if you add data-wp-bind--aria-expanded="state.something" to a tag, you should also set the actual aria-expanded="true|false" attribute depending on what the initial value of state.something is expected to be.

This is an experimental PR, primarily as a proof of concept and to get a better idea of how the API works, and what should or should not be done with it.

To test it, you need to have the Gutenberg plugin activated. You should then see a script[type="module"] pointing to the new Twenty Twenty-One interactivity.js file in the <head>.

A few observations:

  • 196 lines of custom JS (with Interactivity API) vs 314 lines of custom JS (manual approach)
  • 2.1 kB custom JS (with Interactivity API) vs 4.5 kB custom JS (manual approach)
  • 15.5 kB overall JS (with Interactivity API) vs 4.5 kB overall JS (manual approach)
    • While in this example the Interactivity API itself leads to a net increase in JS, it has to be considered that the benefits will become better the more code uses the API. For instance, a site with just a default WordPress theme is hardly realistic, and the amount of JS typically increases heavily the more plugins are used. With several sites loading 100s of kilobytes of JS, the size of the Interactivity API itself would play a minor role compared to the reduction of custom JS code gained from it (see the amount of custom JS as an indicator for that).

Unrelated: While working on this, I discovered and reported https://core.trac.wordpress.org/ticket/60111 which I think is a bug.

Trac ticket: https://core.trac.wordpress.org/ticket/61027


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

Copy link

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • The Plugin and Theme Directories cannot be accessed within Playground.
  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@felixarntz
Copy link
Member Author

Some open questions:

  • Can the Interactivity API data-wp-* attributes be used on the <html> element? I ran into an error when trying to declare it as data-wp-interactive.
  • Can the Interactivity API data-wp-* attributes be used to listen to window events somehow, such as resize or scroll? If not, what is the recommended alternative? Handle in an init callback (as in this PR), or keep as entirely separate custom script, or something else?
  • Is it a bad practice of declaring the entire <body> element as a data-wp-interactive region? What about interoperability concerns between different plugins? If declaring the <body> as interactive region is discouraged, what's the alternative when attributes on the element need to be dynamic?
  • What's the recommended way to have logic based on window properties? Store the relevant bits as state and listen to the state changes?

cc @luisherranz @swissspidy @joemcgill @mukeshpanchal27 for visibility

@felixarntz
Copy link
Member Author

Meh: JSHint complains because we're using modern JavaScript (it's a module, duh 😂)

@@ -27,8 +27,15 @@
function twenty_twenty_one_add_sub_menu_toggle( $output, $item, $depth, $args ) {
if ( 0 === $depth && in_array( 'menu-item-has-children', $item->classes, true ) ) {

// Extra attributes depending on whether or not the Interactivity API is being used.
Copy link
Member

Choose a reason for hiding this comment

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

Nice side effect: avoiding onClick inline script is better for CSP

@swissspidy
Copy link
Member

Both numbers include 12.4 kB from import map polyfill.

Interactivity API does not require import maps and modules, does it? I thought the import maps stuff was a separate experiment in Gutenberg.

@felixarntz
Copy link
Member Author

felixarntz commented Dec 19, 2023

@swissspidy

Interactivity API does not require import maps and modules, does it? I thought the import maps stuff was a separate experiment in Gutenberg.

Looks like it does. I didn't enable any experiments, and the Interactivity API via Gutenberg is loaded as a module. In fact, there's a minor issue there, if I enqueue wp-interactivity in the regular WordPress way (which I tried first), it'll enqueue the Gutenberg version, but that's written as a module now, so it'll cause a JS error.

Overall, I certainly think using a module is a good idea though.

Copy link
Member

@luisherranz luisherranz left a comment

Choose a reason for hiding this comment

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

What a fantastic exploration, @felixarntz!! 😄

I think the code is great and it shows how to use the Interactivity API pretty well.

The only modification I would do is to use directives for the submenus to make them declarative, instead of manipulating the DOM manually: 762b3b

But other than that, awesome work! ❤️


toggleDarkMode: () => {
state.isDarkMode = ! state.isDarkMode;
window.localStorage.setItem( 'twentytwentyoneDarkMode', state.isDarkMode ? 'yes' : 'no' );
Copy link
Member

Choose a reason for hiding this comment

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

Not that this is wrong, but noting here that this can also be done reactively, which is a bit safer in cases where state.isDarkMode would be modified from a few different places:

data-wp-watch="callbacks.storeDarkMode"
callbacks: {
  storeDarkMode: () => {
    // This callback is triggered each time `state.isDarkMode` changes, no matter from where.
    window.localStorage.setItem( 'twentytwentyoneDarkMode', state.isDarkMode ? 'yes' : 'no' );
  }
}

Copy link
Member Author

Choose a reason for hiding this comment

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

@luisherranz Follow up question: Wouldn't your suggestion call window.localStorage.setItem any time that any of the store's state properties are updated? If not, how does it not only to look for state.isDarkMode changes?

Copy link
Member

Choose a reason for hiding this comment

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

The callback only runs when the state.isDarkMode value changes because it's only subscribed to the state.isDarkMode signal.

That's simply how signals work: data-wp-watch is a computation and when you access a property (state.isDarkMode), before returning its value, it checks if it's inside a computation. If it is, it adds the computation to its internal graph of dependencies. When state.isDarkMode is updated, it invalidates all its computations, and the computation callbacks run again, updating their values/DOM.

In the Interactivity API, the interface is transparent (transparent reactive programming) because we are using Proxies on top of state and context to track their access/assignments.

By the way, terms are a bit vague in the world of signals. Computations are sometimes called effects, reactions or observers, and signals are sometimes called observables or reactive variables. Oh, and now with Svelte 5, they are also called runes 😆 .

Copy link
Member Author

Choose a reason for hiding this comment

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

@luisherranz Thanks! Updated in b3af9e0 (please ignore the whitespace changes)

Another follow up question: I have now two data-wp-watch callbacks for the same state property on the same HTML element. I think that makes sense, given that they serve different purposes, but I wanted to check whether there's any downside to this approach compared to putting the logic for both into the same callback, e.g. performance-wise.

Copy link
Member Author

Choose a reason for hiding this comment

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

@luisherranz Actually, on second thought, I'm not sure that this change makes sense - from a functionality perspective:

  • The isDarkMode value should only be cached in localStorage if it was modified by the user.
  • With the change in the new commit, I don't think that's the case any longer. Because it reacts to any isDarkMode changes, I believe it now also caches the initial value that comes purely from the browser detection. The original code only cached the value when invoked via the toggle, which is the intention.
  • Let me know if this makes sense. Do you have any suggestions how to achieve that functionality in a way that still uses data-wp-watch? Or best to go back to my previous code where it happens together with the toggling? 🤔

Copy link
Member

Choose a reason for hiding this comment

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

Can the user "unselect" the color preference to go back to prefers-color-scheme? So toggle between light/dark/prefers-color-scheme?

If so, I'd check the value of the setting in the watch callback and use removeItem() when the user has selected prefers-color-scheme, which should also be the initial value and therefore nothing should be saved on page load.

Would that make sense?

Copy link
Member Author

Choose a reason for hiding this comment

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

@luisherranz That isn't possible. As soon as the user makes a choice in that UI, it's "permanent".

Copy link
Member

@luisherranz luisherranz Mar 20, 2024

Choose a reason for hiding this comment

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

Ok. So, even though the user can't go back to system once they select light/dark, there are three possible values. So what I would do is to replace state.isDarkMode = true | false, which only stores two values, with state.colorMode = 'system' | 'light' | 'dark' to fully represent all the possibilities.

The default value should be system (you can define it in PHP using wp_interactivity_state or in JS directly in the store definition). Then, I'd modify the watch callback to do something like this:

callbacks: {
  storeDarkMode: () => {
    if ( state.colorMode !== 'system' ) {
      window.localStorage.setItem( 
        'twentytwentyoneDarkMode', 
        state.colorMode === 'dark' ? 'yes' : 'no' 
      );
    }
  }
}

I'm assuming you need to keep twentytwentyoneDarkMode for backward compatibility but if not you could use twentytwentyoneColorMode and store light/dark.

Does that make sense?

Copy link
Member Author

Choose a reason for hiding this comment

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

@luisherranz That's a good idea, however with one modification necessary: We can't only store system, we would need to still know whether the system's value is light or dark. So we could use something like system-light and system-dark, but at that point I feel like we're overloading that property.

I went with two separate properties: isDarkMode remains as before, while a separate boolean property isDarkModeManuallyOverwritten (yes, very cumbersome name 😆) stores whether it's the system default or manually overwritten.

See 4a35f3f

Copy link
Member

Choose a reason for hiding this comment

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

Ok. But you should derive isDarkMode. Remember that all the state should have only one source of truth, so all the state that can be derived, should be derived.

Something like this:

state: {
  colorPreference: window.localStorage.getItem( 'twentytwentyoneColorPreference' ) || "system",
  get isDarkMode() {
    if ( state.colorPreference === "system" )
      return window.matchMedia('(prefers-color-scheme: dark)').matches ? "dark" : "light";
    return state.colorPreference;
  }
},
actions: {
  toggleDarkMode() {
    state.colorPreference = state.colorPreference !== "dark" ? "dark" : "light";
  }
},
callbacks: {
  storeColorPreference() {
    if ( state.colorPreference !== 'system' ) {
      window.localStorage.setItem( 
        'twentytwentyoneColorPreference', 
        state.colorPreference
      );
    }
  }
}


state.isDarkMode = isDarkMode;

// The following may be needed here since we can't use `data-wp-on--scroll`?
Copy link
Member

Choose a reason for hiding this comment

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

data-wp-on--scroll works fine, but it's scoped to the element that has the directive:

<div data-wp-on--scroll="callbacks.onScroll">...</div>
// element is the <div>
element.addEventListener("scroll", callbacks.onScroll);

But here, you want to use the global scroll event: window.addEventListener( 'scroll', checkScroll ).

We don't have any special syntax for directives attached to global events yet, so for now it's fine to use data-wp-init. Once we see how common is this, maybe we can come up with something more specific, like data-wp-global-event--scroll or something like that.

What do you think? 🙂

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 think having a more declarative way via attribute to add listeners for global / window events would be great. Maybe data-wp-on-global--{event} or data-wp-on-window--{event}? This could then work for any window events, like scroll, resize etc.

Copy link
Member

Choose a reason for hiding this comment

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

Makes sense. Would you mind opening a new discussion to talk about it? 🙂

Copy link
Member

Choose a reason for hiding this comment

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

I've opened a related discussion:

Copy link
Member Author

Choose a reason for hiding this comment

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

@luisherranz I've updated this PR to use the new data-wp-on-window and data-wp-on-document in 44e4977, reducing the amount of custom JS further. 🎉

cc @c4rl0sbr4v0

},

refreshHtmlElementDarkMode: () => {
// This hack may be needed since the HTML element cannot be controlled with the API attributes?
Copy link
Member

Choose a reason for hiding this comment

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

<html> is not supported as an interactivity target, so this is the exception where mutating the DOM manually is correct.

Oh, and doing so reactively with a data-wp-watch like you're doing here is the best possible way to do so because it will always be in sync, no matter where state.isDarkMode is changed 🙂

Comment on lines 25 to 32
data-wp-interactive='{"namespace": "twentytwentyone"}'
data-wp-class--primary-navigation-open="state.isPrimaryMenuOpen"
data-wp-class--lock-scrolling="state.isPrimaryMenuOpen"
data-wp-class--is-dark-theme="state.isDarkMode"
data-wp-init--iframes="callbacks.updateWindowWidthOnResize"
data-wp-watch--iframes="callbacks.makeIframesResponsive"
data-wp-init--darkmode="callbacks.initDarkMode"
data-wp-watch--darkmode="callbacks.refreshHtmlElementDarkMode"
Copy link
Member

@luisherranz luisherranz Jan 8, 2024

Choose a reason for hiding this comment

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

Using directives in <body> is fine 🙂

Copy link
Member Author

Choose a reason for hiding this comment

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

My concern is that the <body> element isn't in any way "owned" by a specific plugin or theme. Normally, regions can be assumed to be subject to a specific plugin or theme (e.g. a block, or a specific HTML container element), but <body> may be manipulated by anyone. For instance, any plugin could add a dynamic body class, in which case it would need to use directives on the body.

How could that work? Wouldn't there be conflicts between namespaces? That's where my concern stems from.

Copy link
Member

Choose a reason for hiding this comment

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

I guess it's fine for the theme to set it to its own namespace. After all, the namespace option in data-wp-interactive is just a shorter way to say "the default namespace of the directives of this element and its children is X (until you find another data-wp-interactive that overrides the value)".

Other plugins that want to use directives in the <body> element can do so using the non-default syntax to avoid conflicts: data-wp-class--my-plugin-class="myPlugin::state.someClass".

What do you think? Would that be enough?

Copy link
Member Author

Choose a reason for hiding this comment

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

@luisherranz Thanks, I didn't know it was even possible to put namespaces into the attribute values as a prefix. Are both of these methods fully supported? For example, could I choose to not put data-wp-interactive anywhere but instead prefix every single action/callback/state reference with the namespace (e.g. data-wp-class--primary-navigation-open="twentytwentyone::state.isPrimaryMenuOpen")?

While generally that would be cumbersome, for something like <body> which by definition isn't "owned" by any particular plugin or theme I'm thinking it may be more appropriate to not use a default namespace.

Copy link
Member

Choose a reason for hiding this comment

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

data-wp-interactive is still required for hydration. It can be empty if you don't want to set a default namespace, though.

By the way, you can use a single string now: data-wp-interactive="twentytwentyone" 🙂

Copy link
Member Author

Choose a reason for hiding this comment

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

That seems to be a quite significant limitation to me. Is there a reasonable way this could be fixed in the Interactivity API?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'll check it 👀

Directives using a different namespace should work, as there is other namespaces compatibility.

But I will add a test just to be sure that SSR is working as expected.

Copy link
Contributor

@cbravobernal cbravobernal Mar 12, 2024

Choose a reason for hiding this comment

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

This PR would fix it, but I would love some feedback from @DAreRodz. As is setting a default 'WP' namespace that every developer could read and use.

#6262

cc: @felixarntz @gziolo

Copy link
Member

Choose a reason for hiding this comment

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

I don't have enough context to help. What implications the namespace have on the client? The fix proposed only changes the default value from null to WP. Does it mean that devs will have to explicitly use WP as a namespace when interacting with stores?

Copy link
Contributor

Choose a reason for hiding this comment

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

No, stores should infer the namespace. But all interactive chunks created with just data-wp-interactive would use that default one, being accesible.

It would create a "rule" and, as @luisherranz mentions, could create unexpected bugs as would have a different behavior than the client.

Let's close that one and work on a better solution.

@luisherranz
Copy link
Member

The only modification I would do is to use directives for the submenus to make them declarative, instead of manipulating the DOM manually: 762b3b

By the way, making this part declarative brings the Interactivity API JS lines down to 189, vs the 314 of the vanilla JS approach, a reduction of 40% in the amount of code that the developer needs to write.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
5 participants