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: Improvements to the experimental full-page navigation #64067

Open
wants to merge 22 commits into
base: trunk
Choose a base branch
from

Conversation

michalczaplinski
Copy link
Contributor

@michalczaplinski michalczaplinski commented Jul 29, 2024

This PR brings several improvements to experimental full-page navigation.

Fixes #63880


1. Remove src attributes from prefetched scripts

We have to remove the src attributes of the prefetched scripts because otherwise the contents are ignored. Reported by @luisherranz in #63880 (comment).

2. Use .textContent instead of .innerText to set <script> contents

Prefetched scripts that are inserted in the the <head> have <br> in the DOM:

Screenshot 2024-07-29 at 16 49 54

The article from MDN about innerText explains why we see this problem:

As a setter this will replace the element's children with the given value, converting any line breaks into
elements.

3. populateInitialData() with state from the server

We have to populate the client-side state with the state coming from the server (as mentioned in this comment:

We populate the client-side state with the state from the server on the initial load.
We also re-populate the state when doing region-based navigation. But we don't do the equivalent when doing full page navigations.

4. Wait for load event of the script element

Finally, there is another issue:

Scripts that are added to the DOM with type="module" are deferred by default! This means that if we add a script to the <head> the way we currently do it is not guaranteed to have executed before the HTML of the page is updated.

Notice how the DOM had been updated before the onload handler fires. The error at the end that is caused by it because the JS store that contains the callbacks.initTriggerButton has not yet loaded on the page.

output_87404e.mp4

The solution proposed in this PR follows this comment:

  • For prefetching create <link> elements with modulepreload instead of fetching the modules ourselves.
  • Upon navigation, use import() to evaluate the scripts.

Testing instructions

You can test this PR manually by using blocks that use the Interactivity API and enabling the client-side navigation in GB settings.

Some things to watch out for:

  • Watch out for any client-side console errors.
  • Ensure the JS assets are preloaded correctly (watch the Network tab of your browser's devtools). They should be preloaded on hovering over a link.
  • Ensure that the cache-busting works correctly (as referred to in this comment)
  • Ensure that upon hovering over a link, we only pre-load modules, NOT all scripts.

Example test

  1. Make sure to enable the "full page client-side navigation" experiment in GB settings
  2. Create a post/page with an Image block using the "Lightbox" feature. Let's call it an Image test post.
  3. Navigate to your Blog Archive page.
  4. Hover over the link to the Image test post.
  5. TEST: Ensure that the assets are preloaded correctly.
  6. Click on the link to the Image test post.
  7. TEST: Ensure that the post is navigated to using full-page client-side navigation.
  8. TEST: Click on the image to open the Lightbox. Ensure that it can be opened and closed without issue.
  9. Navigate back to the Blog Archive page.
  10. TEST: Ensure that the navigation was done using full-page client-side navigation.

Several follow-ups should be done:

  1. Figure out the way to only load the scripts for the new & compatible interactive blocks on the page. Previously mentioned in Image block view script causes errors when client-side navigation is enabled #63880 (comment) & Experiment: Add full page client-side navigation experiment setting #59707 (comment)

  2. Refactor the fetchHeadAssets() function so that it's not called inside of regionsToVdom.

  3. Handle CSS asset loading (not done in this PR):

    1. Handle properly the cache busting hash in the URLs of the block styles. See this comment
    2. Moving the styles from a file to <style> tags messes up with how the URLs are interpreted in CSS's url() function: Interactivity API: Improvements to the experimental full-page navigation #64067 (comment)
  4. Occasionally the CSS for the page after a navigation is messed up (see video). I'm not sure if this is due to missing CSS or or file order or something else.

    output_a052aa.mp4

@michalczaplinski michalczaplinski added [Type] Bug An existing feature does not function as intended [Feature] Interactivity API API to add frontend interactivity to blocks. [Packages] Interactivity /packages/interactivity labels Jul 29, 2024
@michalczaplinski michalczaplinski changed the title Fix: Full-page navigation for blocks with server-side defined state Interactivity API: Improvements to the experimental full-page navigation Aug 1, 2024
@michalczaplinski michalczaplinski marked this pull request as ready for review August 1, 2024 18:37
Copy link

github-actions bot commented Aug 1, 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: michalczaplinski <czapla@git.wordpress.org>
Co-authored-by: DAreRodz <darerodz@git.wordpress.org>
Co-authored-by: sirreal <jonsurrell@git.wordpress.org>
Co-authored-by: gziolo <gziolo@git.wordpress.org>
Co-authored-by: luisherranz <luisherranz@git.wordpress.org>
Co-authored-by: westonruter <westonruter@git.wordpress.org>

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

@luisherranz
Copy link
Member

I believe that this fix should work after #62734 is merged.

What functionality or change from the other pull request does this one need to work?

@michalczaplinski
Copy link
Contributor Author

What functionality or change from the other pull request does this one need to work?

Sorry, this comment is outdated and I've removed it now. I made it before updating this PR . We don't need any specific functionality from that PR.

@luisherranz
Copy link
Member

luisherranz commented Aug 6, 2024

I added a comment here about the general strategy of preloading and waiting for resources to load.

@michalczaplinski
Copy link
Contributor Author

I've just noticed that this:

// wait for the `load` event to fire before appending the element
return new Promise( ( resolve, reject ) => {
  element.onload = resolve;
  element.onerror = reject;
} );

actually breaks the full-page navigation. We can't rely on the onload event I guess. I'll take a look shortly.

@michalczaplinski michalczaplinski marked this pull request as draft August 7, 2024 12:31
@michalczaplinski
Copy link
Contributor Author

I've just noticed that this:

// wait for the `load` event to fire before appending the element
return new Promise( ( resolve, reject ) => {
  element.onload = resolve;
  element.onerror = reject;
} );

actually breaks the full-page navigation. We can't rely on the onload event I guess. I'll take a look shortly.

Just coming back to this. The reason I was seeing breakage is because the load event never fires on inline <script> elements. So something like this does not work:

     const script = document.createElement('script');
     script.textContent = 'console.log("hi");'    
     script.onload = () => { console.log("loaded")}; // This will log
     document.body.append(script);

I'm trying a different approach now:

  1. Instead of loading the content with fetch() and inlining it into <script> and <style> elements, fetch it with <link rel="modulepreload">. For styles, we can use rel="preload" as="style".
  2. In the updateHead() return a Promise waiting for the load of the script.

@luisherranz
Copy link
Member

  • In the updateHead() return a Promise waiting for the load of the script.

As I mentioned here, you don't need to add an actual script and use the load event for the scripts; you can use dynamic imports since all the scripts should be modules.

Comment on lines 57 to 60
export const headElements = new Map<
string,
{ tag: HTMLElement; text?: string }
>();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've exported the headElements so that we don't have to pass it as an argument to fetchHeadAssets().

[ ...headElements.entries() ]
.filter( ( [ , { tag } ] ) => tag.nodeName === 'SCRIPT' )
.map( async ( [ url ] ) => {
await import( /* webpackIgnore: true */ url );
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We need the webpack comment here. Otherwise, webpack will try to code-split here into a new chunk and it does not permit using fully dynamic paths.

@michalczaplinski
Copy link
Contributor Author

michalczaplinski commented Aug 21, 2024

As I mentioned #60951 (comment), you don't need to add an actual script and use the load event for the scripts; you can use dynamic imports since all the scripts should be modules.

Yup, this is what I ended up doing. I think there's nothing wrong with adding the scripts and waiting for the load approach, though.


I think this is ready for review now.

There are 2 issues that I've noticed that should be fixed in follow-ups:

  1. Do not load the scripts that have already been preloaded if they only differ by ?ver=<some-number> query param. I initially thought that this should be fixed when using the production build (npm run build) but it's not the case.

    Fixed in 9aef408

    output_a20672.mp4
  2. Occasionally the CSS for the page after a navigation is messed up (see video). I'm not sure if this is due to missing CSS or or file order or something else.

    output_a052aa.mp4

@michalczaplinski michalczaplinski marked this pull request as ready for review August 21, 2024 15:35
@gziolo
Copy link
Member

gziolo commented Aug 21, 2024

I initially thought that this should be fixed when using the production build (npm run build) but it's not the case.

You should test it with SCRIPT_DEBUG set to false in your wp-config.php file.

@michalczaplinski
Copy link
Contributor Author

I initially thought that this should be fixed when using the production build (npm run build) but it's not the case.

You should test it with SCRIPT_DEBUG set to false in your wp-config.php file.

Nice, I didn't think about it, thanks! 🙂

@michalczaplinski
Copy link
Contributor Author

ok, I've just tried it and it looks like setting SCRIPT_DEBUG to false does not make any difference, unfortunately.

@gziolo
Copy link
Member

gziolo commented Aug 21, 2024

ok, I've just tried it and it looks like setting SCRIPT_DEBUG to false does not make any difference, unfortunately.

This is the reason it uses time() for interactivity router script:

$default_version = defined( 'GUTENBERG_VERSION' ) && ! SCRIPT_DEBUG ? GUTENBERG_VERSION : time();

I don't know where the style.css comes from, but there must be something similar present in the code.

@michalczaplinski
Copy link
Contributor Author

michalczaplinski commented Aug 27, 2024

Thanks @gziolo

I figured out that GUTENBERG_VERSION is only available in production. In dev, it's unset.

echo "define( 'GUTENBERG_VERSION', '$plugin_version' );\n";

For production, there is no problem in that case. ver === GUTENBERG_VERSION

In development, we should check if the full-page navigation experiment is enabled. And use the time() only if the experiment is disabled.

@michalczaplinski
Copy link
Contributor Author

In development, we should check if the full-page navigation experiment is enabled. And use the time() only if the full-page navigation experiment is disabled.

I've now added this.

We should look into doing the same for indivudual block's stylesheets:

Screenshot 2024-08-29 at 12 19 32

But this can be done in a follow-up

@luisherranz
Copy link
Member

In development, we should check if the full-page navigation experiment is enabled. And use the time() only if the experiment is disabled.

Won't that cause problems for people developing in Gutenberg with that option enabled?

@michalczaplinski
Copy link
Contributor Author

michalczaplinski commented Aug 29, 2024

Won't that cause problems for people developing in Gutenberg with that option enabled?

I think that if you are developing with the full-page navigation enabled, then you DO want to disable the cache busting. Otherwise, full-page navigation does not work properly. This is how I did it:

$experiments = get_option( 'gutenberg-experiments' );
$full_page_navigation_enabled = isset( $experiments['gutenberg-full-page-client-side-navigation'] );
wp_register_script_module(
'@wordpress/interactivity',
gutenberg_url( '/build/interactivity/' . ( SCRIPT_DEBUG ? 'debug.min.js' : 'index.min.js' ) ),
array(),
$full_page_navigation_enabled ? null : $default_version
);

Am I missing something?

@michalczaplinski michalczaplinski force-pushed the fix/full-page-navigation-server-state branch from 068d62f to 06fd98d Compare September 17, 2024 18:16
Copy link

github-actions bot commented Sep 17, 2024

Flaky tests detected in a9469d0.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/11016205655
📝 Reported issues:

@michalczaplinski
Copy link
Contributor Author

I believe this should be good to be re-reviewed again @gziolo @DAreRodz @sirreal 🙇‍♂️

@gziolo
Copy link
Member

gziolo commented Sep 18, 2024

What is the best way to test these changes? How much confidence can we put in the existing e2e tests coverage?

@michalczaplinski
Copy link
Contributor Author

What is the best way to test these changes? How much confidence can we put in the existing e2e tests coverage?

Apologies, I've now added better testing instructions in the PR description.

The existing e2e tests do not cover the scenarios in this PR. I have started working on additional e2e tests in #63268

I'm happy to continue working on them as an immediate follow-up. We'll need them before full-page client-side navigation is moved out of GB experiment. For now, as full-page client-side navigation is still experimental, I'm not worried about the impact of this PR. The current implementation has been somewhat broken for months (which this PR attempts to fix 🙂).

@noisysocks noisysocks added the Backport to WP 6.7 Beta/RC Pull request that needs to be backported to the WordPress major release that's currently in beta label Sep 18, 2024
lib/interactivity-api.php Outdated Show resolved Hide resolved
} );
await fetchHeadAssets( document, headElements );
[].map.call(
document.querySelectorAll( 'script[type="module"][src]' ),
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 intriguing, as it skips all scripts that aren't ES Modules moving forward. It never covered inline scripts, to there is no change regarding that.

What's the story for the plugins or themes that add custom JavaScript to the site? It will load on the full-page reload but not when doing the full-page client-side navigation. It would be good to start collecting the requirements for the feature to work correctly.

@@ -29,6 +37,14 @@ export const updateHead = async ( newHead: HTMLHeadElement[] ) => {
}
}

await Promise.all(
[ ...headElements.entries() ]
Copy link
Member

@sirreal sirreal Sep 24, 2024

Choose a reason for hiding this comment

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

This looks good, but I always worry about these data structures because I think of Map as an unordered collection. I was worried about dependencies and ordering not being respected:

The entries() method of Map instances returns a new map iterator object that contains the [key, value] pairs for each element in this map in insertion order.

From MDN (bold mine).

It seems like we're OK since entries returns elements in insertion order 👍

@gziolo
Copy link
Member

gziolo commented Sep 24, 2024

We need to validate whether this PR fixes #63880. If not, then we should not need to backport this PR to WP 6.7, so the corresponding label will have to be removed.


const headElement = headElements.get( href );
const styleElement = doc.createElement( 'style' );
styleElement.textContent = headElement.text;
Copy link
Member

Choose a reason for hiding this comment

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

Is it also necessary to store the text? Couldn't headElements be Map< string, HTMLElement >, then this could become styleElement.textContent = headElement.textContent?

@DAreRodz
Copy link
Contributor

I have found an issue (maybe introduced after merging trunk?).

Following the steps listed in the description, I see this error when I navigate to the "Image test" page:

context.ts:62 Uncaught (in promise) TypeError: Invalid value used as weak map key
    at WeakMap.set (<anonymous>)
    at proxifyContext (context.ts:62:26)
    at directives.tsx:179:49
    at F (index.js:322:25)
    [...]

After a bit of debugging, the cause of the problem seems to be inside the wp-context directive. These refs lose their initial value somehow:

const client = useRef( proxifyState( ns, {} ) );
const server = useRef( proxifyState( ns, {}, { readOnly: true } ) );

This is a quick fix we could use right after initialize the refs:

if ( ! client.current ) {
    client.current = proxifyState( ns, {} );
}
if ( ! server.current ) {
    server.current = proxifyState( ns, {}, { readOnly: true } );
}

However, this is weird, and would require further investigation. It could be an issue in Preact, or the way it reconciles elements during rendering. I have no idea at this moment. 😅

@DAreRodz
Copy link
Contributor

@michalczaplinski, I've added minor changes to the renderRegions() function in a9469d0. Just made the renderRegions() function async and wrapped only the required parts with batch(). 🙂

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Backport to WP 6.7 Beta/RC Pull request that needs to be backported to the WordPress major release that's currently in beta [Feature] Interactivity API API to add frontend interactivity to blocks. No Core Sync Required Indicates that any changes do not need to be synced to WordPress Core [Packages] Interactivity /packages/interactivity [Type] Bug An existing feature does not function as intended
Projects
Status: 🔎 Needs Review
Development

Successfully merging this pull request may close these issues.

Image block view script causes errors when client-side navigation is enabled
6 participants