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

Interactivity API: Add a new API for getting the initial server-side state & context #65243

Closed
michalczaplinski opened this issue Sep 11, 2024 · 6 comments · Fixed by #65151
Closed
Labels
[Feature] Interactivity API API to add frontend interactivity to blocks. [Packages] Interactivity /packages/interactivity [Type] Enhancement A suggestion for improvement. [Type] New API New API to be used by plugin developers or package users.

Comments

@michalczaplinski
Copy link
Contributor

michalczaplinski commented Sep 11, 2024

⚠️ This proposed API is relevant for client-side navigation only (either region-based or full-page).

Background

Currently, the Interactivity API does not provide a way to always get the initial server-side state or context. For example, getContext() will return the initial context on the initial render but subsequent calls will return the client-side context.

This is a conscious design decision, however, sometimes it might be necessary to have access to the server-side context on the new page after client-side navigation.

Example

This example discusses the case of context, but the same issue exists for state.

Imagine an online quiz application. The user is presented with a question on each page. The user has 60 seconds to answer each question. After the user answers a question, they are redirected to the next question on the next page.

The application could look something like this:

<script>
  import { store, getContext, withScope } from '@wordpress/interactivity';

  store( 'example', {
    callbacks: {
      update() {
        setInterval(
          // Decrement the timer every second
          withScope( () => getContext().timer-- ),
          1000
        );
      },
    },
  } );
</script>

<div data-wp-router-region="id">
  <div
    data-wp-interactive="example"
    data-wp-context='{"timer": 60}'
    data-wp-text="context.timer"
    data-wp-init="callbacks.update"
  ></div>
</div>

When the user navigates to the next question, the getContext() call will return the client-side context, which is {"timer": <whatever-time-is-left>}. This is not what we want! We want the initial server-side context, which is {"timer": 60}, because the user has 60 seconds to answer each question!

The proposal

We propose adding a new API to the Interactivity API to get the initial server-side state or context.

The API would be similar to getContext() and getState(), but it would return the initial server-side state or context instead of the client-side state or context.

Using the quiz application example, the API would be called like this:

<script>
  import {
    store,
    getContext,
    getServerContext,
    withScope,
  } from '@wordpress/interactivity';

  store( 'example', {
    callbacks: {
      update() {
        setInterval(
          withScope( () => getContext().timer-- ),
          1000
        );
      },
      reset() {
        getContext().timer = getServerContext().timer;
      },
    },
  } );
</script>

<div data-wp-router-region="id">
  <div
    data-wp-interactive="example"
    data-wp-context='{"timer": 60}'
    data-wp-text="context.timer"
    data-wp-init="callbacks.update"
    data-wp-watch="callbacks.reset"
  ></div>
</div>

getServerContext()

The getServerContext() function would return the initial server-side context. It would be called like getContext() and typically be used to override the client-side context:

store( '...', {
  callbacks: {
    updateServerContext() {
      const context = getContext();
      const serverContext = getServerContext();

      // Override some property with the new value that came from the server.
      context.overridableProp = serverContext.overridableProp;
    },
  },
} );

getServerState()

The getServerState() should function analogously to getServerContext(), but for state.

@michalczaplinski michalczaplinski added [Type] Enhancement A suggestion for improvement. [Type] New API New API to be used by plugin developers or package users. [Feature] Interactivity API API to add frontend interactivity to blocks. [Packages] Interactivity /packages/interactivity labels Sep 11, 2024
@michalczaplinski
Copy link
Contributor Author

@DAreRodz and I have found an issue that needs to be solved in the current design:

When using the getServerContext() API in the following way:

store( 'example', {
  callbacks: { 
    reset() {
      getContext().timer = getServerContext().timer;
    }
  }
} );
<div 
  data-wp-watch="callbacks.reset"
  ...
>
</div>

The reset() callback is not going to be called after the client-side navigation because the value of the server-side context is not going to be changed! In other words, the signal emitted by getServerContext() is unchanged, so it does not trigger the "observer" that is reset().

We might need to find a way to "manually" trigger the observers after the client-side navigation.

@sethrubenstein
Copy link
Contributor

Oh this is fantastic. The quiz example is especially apt as converting our quiz builder plugin to the interactivity api is my next big project.

@gziolo
Copy link
Member

gziolo commented Sep 20, 2024

@DAreRodz and I have found an issue that needs to be solved in the current design:

@DAreRodz, how did you resolve the issue outlined in #65243 (comment)?

@DAreRodz
Copy link
Contributor

@gziolo, I've been thinking about this, and maybe there is no problem after all, so I decided not to write any logic to handle this case. Here is my reasoning:

For this issue to arise, these conditions must be fulfilled:

  1. A property is initialized using the value from the server.
  2. That property is modified by the client later on.
  3. The navigate() function is called, and the original value in the server has not changed.

In such a case, where developers know that the conditions above are met, they could subscribe to store( 'core/router' ).state.url, triggering an update every time a visitor navigates to a different URL.

    reset() {
      store( 'core/router' ).state.url;
      getContext().timer = getServerContext().timer;
    },

I think that should be enough and would trigger the minimum number of updates. Otherwise, if we decide to force signal updates, every directive reading from getServerContext() or getServerState() will be re-rendered for every navigation.

This is an example of getServerContext() that can be copy-pasted in a Custom HTML block:

<script type="module">
import { store, getContext, getServerContext, withScope } from '@wordpress/interactivity';
const { state } = store( 'test/context-timer', {
  callbacks: {
    start() {
      setInterval( withScope( () => {
        getContext().timer++;
      } ), 1000 );
    },
    reset() {
      store( 'core/router' ).state.url;
      getContext().timer = getServerContext().timer;
    },
  }
} );
</script>
<div
  data-wp-interactive="test/context-timer"
  data-wp-context='{ "timer": 0 }'
  data-wp-init="callbacks.start"
  data-wp-watch="callbacks.reset"
  data-wp-text="context.timer"
>
</div>

The same but for getServerState():

<script type="module">
import { store, getServerState, withScope } from '@wordpress/interactivity';
const { state } = store( 'test/state-timer', {
  callbacks: {
    start() {
      setInterval( withScope( () => {
        state.timer++;
      } ), 1000 );
    },
    reset() {
      store( 'core/router' ).state.url;
      state.timer = getServerState().timer;
    },
  }
} );
</script>
<div
  data-wp-interactive="test/state-timer"
  data-wp-init="callbacks.start"
  data-wp-watch="callbacks.reset"
  data-wp-text="state.timer"
>
</div>

@DAreRodz
Copy link
Contributor

DAreRodz commented Sep 20, 2024

Oops, I've noticed an issue with the approach I mentioned above. 😅

The store( 'core/router' ).state.url property doesn't exist until the @wordpress/interactivity-router module is loaded. So, we have an initial value of undefined there, and then it gets the current URL when the router module is imported.

That triggers an update even though the navigate() action hasn't been called. Not good. 🙁

For instance, if you import @wordpress/interactivity-router to do a prefetch―no navigation―the state.url value is updated, and the timers of the examples above are reset before navigating to any page.

We could fix that by populating the initial URL from the server so it doesn't change when the router module is loaded. Would that make sense? Can we set the same URL we have in the browser from PHP?

@gziolo, @michalczaplinski

@michalczaplinski
Copy link
Contributor Author

We could fix that by populating the initial URL from the server so it doesn't change when the router module is loaded. Would that make sense? Can we set the same URL we have in the browser from PHP?

I think that it won't be reliable because the PHP server can sit behind a Cloudflare proxy, etc. So, the URL in PHP can be different from the final URL in the browser.

However, I thought about this problem a little more, and I realized that this scenario is pretty unlikely to occur:

For this issue to arise, these conditions must be fulfilled:

  1. A property is initialized using the value from the server.
  2. That property is modified by the client later on.
  3. The navigate() function is called, and the original value in the server has not changed.

Suppose a property X is updated on the client. In that case, a developer should not expect that it will magically be persisted after doing client-side navigation. The developer should know that this property must also be updated on the server (e.g. via a REST API call) to persist after client-side navigations.

Of course, we don't have a dedicated API to do server-side mutations; you'd need a custom REST API endpoint for this. But that's another conversation 😎.

In conclusion, the current behavior should be OK as is!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Feature] Interactivity API API to add frontend interactivity to blocks. [Packages] Interactivity /packages/interactivity [Type] Enhancement A suggestion for improvement. [Type] New API New API to be used by plugin developers or package users.
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants