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

Script and useScript #2194

Open
wants to merge 27 commits into
base: v1.x-2022-07
Choose a base branch
from
Open

Script and useScript #2194

wants to merge 27 commits into from

Conversation

juanpprieto
Copy link
Contributor

@juanpprieto juanpprieto commented Sep 27, 2022

Description

⚠️ WIP ⚠️

This PR introduces the following

  • <Script /> — A script component supporting multiple loading strategies and reload.
  • useScript — A hook version

Additional context

useLoadScript has performance limitations due to its eager nature.

Notes

  • Docs def need some love. Working on it today.
  • There are 2 new lint rules
    -- prefer-script-component
    -- scripts-in-layout-components
  • There's a few e2e tests at playground/async-config/tests/e2e-test-cases.ts
  • To 🎩 you can go to the playground/async-config and the visit /scripts
  • Expecting feedback 🙂

Before submitting the PR, please make sure you do the following:

  • Read the Contributing Guidelines
  • Provide a description in this PR that addresses what the PR is solving, or reference the issue that it solves (e.g. fixes #123)
  • [wip] Update docs in this repository according to your change
  • Run yarn changeset add if this PR cause a version bump based on Keep a Changelog and adheres to Semantic Versioning

@github-actions
Copy link
Contributor

We detected some changes in packages/*/package.json or packages/*/src, and there are no updates in the .changeset.
If the changes are user-facing and should cause a version bump, run "yarn changeset add" to track your changes and include them in the next release CHANGELOG.
If you are making simple updates to examples or documentation, you do not need to add a changeset.

import {ScriptState, UseScriptProps} from './types.js';

export function useScript(props: UseScriptProps) {
const {pathname} = useUrl();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@frehner — This is the only hydrogren dependency that would prevent this from being part of h-ui. We could maybe replace it with a framework agnostic hook. Any thoughts?

Copy link
Contributor

@frehner frehner Sep 28, 2022

Choose a reason for hiding this comment

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

To be honest, I would have to see what it would look like in other frameworks and if it would actually be valuable there or not. For example, if I'm using NextJS, why would I use this over their official component?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's a valid point but some frameworks like Remix and Gatsby don't offer a Script alternative.

Copy link
Contributor

Choose a reason for hiding this comment

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

I would see a lot of value in ditching this dependency. Do we need to reach for a hook here to get the pathname.

@@ -0,0 +1,24 @@
// polyfill for Safari and IE
export const requestIdleCallback =
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is nextjs's polyfill. @frehner this is another dependency to enable load="onIndle". Thoughts for a h-ui context?

@@ -0,0 +1,52 @@
// simulate a CDN/server delivering 3rd-party scripts
export async function api(request) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is only needed for e2e tests

| Name | Type | Description |
| -------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `target` | <code>"head" \| "body" (default)</code> | The target DOM element where the script should be inserted. This feature is only available to non-inline loading strategies such as "afterHydration", "inWorker" and "onIdle" |
| `id` | <code>string (required)</code> | A unique identifier for the script. The id will be used as the key of the script. |
Copy link
Contributor

Choose a reason for hiding this comment

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

If id is required, shouldn't it be in the above example?


### `beforeHydration`

These scripts are inlined and hence considered render-blocking. This strategy is mainly recommended for scripts aiming to set global `window` properties, configurations or event listeners. These scripts can only be included in `App.server.tsx`. This ensures that they are run on any initial route.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
These scripts are inlined and hence considered render-blocking. This strategy is mainly recommended for scripts aiming to set global `window` properties, configurations or event listeners. These scripts can only be included in `App.server.tsx`. This ensures that they are run on any initial route.
Scripts using the `beforeHydration` strategy are inlined and render-blocking. This strategy is only recommended for scripts that set global `window` properties, configurations or event listeners. These scripts can only be included in `App.server.tsx`, which ensures they are run on any initial route.

Copy link
Contributor

Choose a reason for hiding this comment

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

#The that after ensures is correct, let's put it back in!

To @cartogram's edit, would add an Oxford comma between configurations and event listeners

Verify throughout that you're using the Oxford comma


### `afterHydration`

These scripts are loaded, injected and run after react has finished hydrating on the client (via a useEffect). In addition, these scripts include additional props to further customize their behavior `target`, `reload`, `onLoad`, `onReady` and `onError` callbacks.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
These scripts are loaded, injected and run after react has finished hydrating on the client (via a useEffect). In addition, these scripts include additional props to further customize their behavior `target`, `reload`, `onLoad`, `onReady` and `onError` callbacks.
Scripts using the `afterHydration` strategy are loaded, injected and run after React has finished hydrating on the client (via a `useEffect`). In addition, these scripts accept props to further customize the behavior of the `target`, `reload`, `onLoad`, `onReady` and `onError` callbacks.

Copy link
Contributor

Choose a reason for hiding this comment

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

The beforeHydration strategy gives an example how or why it would be used. Maybe provide the same for afterHydration.

<Script
id="oi-children-reload"
load="afterHydration"
reload={true}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
reload={true}
reload

Make this change for all of these I think.

id="oi-src-reload"
src="//example.com/script.js"
load="afterHydration"
reload={true}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
reload={true}
reload

src="//example.com/script.js"
/>

// with a src and with reload
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// with a src and with reload
// with a src and reload


// with a src
<Script
id="oi-src"
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
id="oi-src"
id="ah-src"


// with a src and with reload
<Script
id="oi-src-reload"
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
id="oi-src-reload"
id="ah-src-reload"


// with callbacks
<Script
id="oi-src-cbs"
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
id="oi-src-cbs"
id="ah-src-cbs"


### `onIdle`

These scripts are loaded, injected and run when the main thread is idle (via requestIdleCallback). In addition, these scripts include additional props to further customize their behavior `target`, `reload`, `onLoad`, `onReady` and `onError` callbacks.
Copy link
Contributor

Choose a reason for hiding this comment

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

If you end up making the suggestions above, please apply them to this paragraph as well.

<>
// Loading google analytics.js via a web worker
<Script
id="inWorker-analytics"
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
id="inWorker-analytics"
id="iw-analytics"

I don't really care how these IDs are named as long as it is consistent.

@davidhousedev
Copy link

Just read through the docs, super exciting!!!

It seems like there are a lot of different loading strategies for different use cases but I don't have a clear sense which one I should use when. In other words, are there any heuristics I should consider when setting the loading strategy for a given script?

| `src` | <code>string</code> | A URL string. This string can be an absolute path or a relative path depending on the location of the third-party script. The `src` prop is required if `dangerouslySetInnerHTML` or `children` are not used |
| `dangerouslySetInnerHTML` | <code>string</code> | Any valid javascript code |
| `children` | <code>string \| string[]</code> | Any valid javascript code |
| `load` | <code>"beforeHydration" \| "afterHydration" (default) \| "inWorker" \| "onIdle"</code>| The loading strategy. See Loading strategies for more info. |
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we might want to bikeshed these strategy names

response = new Response(body, {
headers: {
'content-type': contentType,
'Access-Control-Allow-Origin': url.origin,
Copy link
Contributor

Choose a reason for hiding this comment

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

Is an Access-Control-Allow_Origin header necessary? Just by going through the proxy we are already on the same domain right?

Copy link
Contributor

@rennyG rennyG left a comment

Choose a reason for hiding this comment

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

Looks good, @juanpprieto! Just some style/organization/copy nits. After you address the comments, I'll take another pass and 🎩 on .dev

---
gid: 5848138c-3dbb-11ed-b878-0242ac120002
title: Script
description: The Script component renders a script tag
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
description: The Script component renders a script tag
description: The Script component renders a script tag.

<aside class="note beta">
<h4>Experimental feature</h4>

<p>Hydrogen Script is an experimental feature. As a result, functionality is subject to change. You can provide feedback on this feature by <a href="https://github.com/Shopify/hydrogen/issues">submitting an issue in GitHub</a>.</p>
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
<p>Hydrogen Script is an experimental feature. As a result, functionality is subject to change. You can provide feedback on this feature by <a href="https://github.com/Shopify/hydrogen/issues">submitting an issue in GitHub</a>.</p>
<p>The `Script` component is an experimental feature. As a result, functionality is subject to change. You can provide feedback on this feature by <a href="https://github.com/Shopify/hydrogen/issues">submitting an issue in GitHub</a>.</p>


</aside>

The `Script` component is an enhanced HTML [`<script>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script) element. Script enables efficient loading third-party scripts by offering different loading strategies within the application lifecycle.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
The `Script` component is an enhanced HTML [`<script>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script) element. Script enables efficient loading third-party scripts by offering different loading strategies within the application lifecycle.
The `Script` component is an enhanced HTML [`<script>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script) element. It offers different loading strategies within the application lifecycle. Use `Script` to load third-party scripts efficiently.

Maybe a use case here, like a for example, to help orient partners on when to use.


## Example code

Load google analytics only after react has finished hydrating on the client. "afterHydration" is the default loading strategy
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
Load google analytics only after react has finished hydrating on the client. "afterHydration" is the default loading strategy
Load Google Analytics only after React has finished hydrating on the client. 'afterHydration' is the default loading strategy.

Throughout, review for correct capitalization on Google


Load google analytics only after react has finished hydrating on the client. "afterHydration" is the default loading strategy

{% codeblock file, filename: 'App.server.jsx' %}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
{% codeblock file, filename: 'App.server.jsx' %}
{% codeblock file, filename: 'App.server.jsx' %}

hypo-nit. Just a linting thing on the shopify-dev repo. Would apply throughout

| `id` | Yes | A unique identifier for the script tag |
| `url` | Yes | The URL string for the external script. |
| `target` | <code>"head" \| "body" (default)</code> | The target DOM element where the script should be inserted. This feature is only available to non-inline loading strategies such as
| `load` | <code>"afterHydration" (default) \| "onIdle"</code>| The loading strategy. See loading strategies [`section`](https://shopify.dev/api/hydrogen/components/primitive/script#loading-strategies) for more info. |
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
| `load` | <code>"afterHydration" (default) \| "onIdle"</code>| The loading strategy. See loading strategies [`section`](https://shopify.dev/api/hydrogen/components/primitive/script#loading-strategies) for more info. |
| `load` | <code>"afterHydration" (default) \| "onIdle"</code>| The loading strategy. For more information, refer to [loading strategies](https://shopify.dev/api/hydrogen/components/primitive/script#loading-strategies).|

Not sure, but looks like you'd edit this so that the link is over loading strategies.

| `url` | Yes | The URL string for the external script. |
| `target` | <code>"head" \| "body" (default)</code> | The target DOM element where the script should be inserted. This feature is only available to non-inline loading strategies such as
| `load` | <code>"afterHydration" (default) \| "onIdle"</code>| The loading strategy. See loading strategies [`section`](https://shopify.dev/api/hydrogen/components/primitive/script#loading-strategies) for more info. |
| `reload` | <code>boolean (default false)</code> | Scripts rendered with this option will be reloaded after every page navigation (if available on the next route). This option is only available in "afterHydration" and "onIdle" loading strategies. |
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
| `reload` | <code>boolean (default false)</code> | Scripts rendered with this option will be reloaded after every page navigation (if available on the next route). This option is only available in "afterHydration" and "onIdle" loading strategies. |
| `reload` | <code>boolean (default `false`)</code> | Scripts rendered with this option are reloaded after every page navigation (if available on the next route). This option is only available in "afterHydration" and "onIdle" loading strategies. |

can apply the previous edits here. Code formatting, links, tightening up the phrase (if acceptable).


## Return value

The `useLoadScript` hook returns the following values that allow you to understand the state of the external script you are loading:
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
The `useLoadScript` hook returns the following values that allow you to understand the state of the external script you are loading:
The `useLoadScript` hook returns the following values for understanding the state of the external script that you're loading:

verify contraction use throughout

| `load` | <code>"afterHydration" (default) \| "onIdle"</code>| The loading strategy. See loading strategies [`section`](https://shopify.dev/api/hydrogen/components/primitive/script#loading-strategies) for more info. |
| `reload` | <code>boolean (default false)</code> | Scripts rendered with this option will be reloaded after every page navigation (if available on the next route). This option is only available in "afterHydration" and "onIdle" loading strategies. |

## Return value
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
## Return value
## Return values


| Value | Description |
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `loading` | The script is still loading. For example, the script tag can be on the page but the resource might not be fully loaded yet while in this state. |
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
| `loading` | The script is still loading. For example, the script tag can be on the page but the resource might not be fully loaded yet while in this state. |
| `loading` | The script is still loading. For example, the script tag can be on the page but the resource might not be fully loaded yet. |

],
}
: {};
// console.log({suggestions});
Copy link
Contributor

Choose a reason for hiding this comment

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

🔥

@@ -325,7 +325,7 @@ If your Store is based on the "Demo Store" tempate, and you are using the `test:
} from '@shopify/hydrogen/platforms';

// Platform entry handler
export default function(request) {
export default function (request) {
Copy link
Contributor

Choose a reason for hiding this comment

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

?

}

/*
If we are not loading and have and exiting idle callback and
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
If we are not loading and have and exiting idle callback and
If we are not loading and exiting idle callback and

const hasLoaded = key && LoadCache.has(key);

// track last pathname
PrevPathCache.set(key, pathname);
Copy link
Contributor

Choose a reason for hiding this comment

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

Only bother doing this if it pathnameChanged.

reload = false,
} = props;

const key = (id ?? '') + (src ?? '');
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
const key = (id ?? '') + (src ?? '');
const key = `${id}${key}`

Should be fine given your types.

*/
export function loadScriptOnIdle(
props: ScriptProps,
cb: (status: boolean) => void = () => {}
Copy link
Contributor

Choose a reason for hiding this comment

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

What do you think about passing the entire ScriptResponse to the call back?

: '';
}

// Spread <Script /> props as <scrip /> attributes
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// Spread <Script /> props as <scrip /> attributes
// Pass <Script /> component props as <script /> tag attributes if they are valid HTML attributes

}

/*
marks this script to be loaded by partytown inWorker
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
marks this script to be loaded by partytown inWorker
marks this script to be loaded by PartyTown inWorker

continue;
}

// map react props to DOM attribute names
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// map react props to DOM attribute names
// map React props to DOM attribute names

} else {
return Promise.resolve({
status: false,
event: 'Script load promise not found',
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we can be more consistent and helpful with these events. I'm not yet sure how they are meant to be used.

Copy link
Contributor

@cartogram cartogram left a comment

Choose a reason for hiding this comment

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

A few more comments on files I didn't get around to reviewing on the first pass, but overall this is looking really great!

In the future, I would have loved to see this broken up in PRs that are a fraction of the size. Lint rules and such can always come in subsequent PRs to make things more palatable for reviewers and overall less risky 🙂 I know this was a beast of a feature and appreciate all the work you put in to get this to this point! ❤️

docs: {
description: `Prevent using ${new (Intl as any).ListFormat('en').format(
SCRIPT_CALLBACKS
)} <Script /> callback(s) in server components`,
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
)} <Script /> callback(s) in server components`,
)} <Script /> callbacks in server components`,


context.report({
node,
data: {callback: callbackAttributes.join(', ')},
Copy link
Contributor

Choose a reason for hiding this comment

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

instead of join, what about using the same ListFormat niceness from above. IE:

      new (Intl as any).ListFormat('en').format(
        callbackAttributes
      )

meta: {
type: 'problem',
docs: {
description: `Prevent using ${new (Intl as any).ListFormat('en').format(
Copy link
Contributor

Choose a reason for hiding this comment

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

do we still need the any? We may have updated the typings since I added the other rules.


function ServerComponent() {
return (
<Script onReady={() => {}} onLoad={() => {}} onError />
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we also test multiple Script components in the same Component.

return <Script onError={() => {}} />;
}
`,
filename: 'ServerComponent.client.tsx',
Copy link
Contributor

Choose a reason for hiding this comment

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

Adds tests for multiple callbacks and multiple components.

@@ -0,0 +1,31 @@
# Prefer using the `Script` component instead of HTML `script` tags

Scripts loaded at the wrong time can negatively [Core Web Vital](https://web.dev/vitals/) that [Google uses in search ranking](https://developers.google.com/search/blog/2020/05/evaluating-page-experience) performance.
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we can use this opportunity to educate the user more on what the "wrong time" means, ie: not blocking the main thread, workers, partytown, etc... I know this information is written extensively elsewhere in our docs so not expecting an MDN article on the subject, but just a few sentences to drive home the value of why this rule exists (with links) would be awesome. Also, I might even add like a "Additional resources" heading at the bottom. Thoughts?

cc @rennyG

Copy link
Contributor

Choose a reason for hiding this comment

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

yep I think that's a great idea. If this is written extensively elsewhere, could consider linking out to those pages for devs that want to learn more

@@ -0,0 +1,3 @@
## Rule details
Copy link
Contributor

Choose a reason for hiding this comment

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

🔥 this whole directory

@@ -0,0 +1,14 @@
// Examples of **correct** code for this rule:
Copy link
Contributor

Choose a reason for hiding this comment

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

delete this directory too. Lets move everything to a single Readme for each rule.

import {createRule} from '../../utilities';

const SCRIPT_TAG_NAME = 'Script';
// TODO: adapt to other frameworks (e.g. Next.js requires beforeInteractive @ `pages/_document.js`)
Copy link
Contributor

Choose a reason for hiding this comment

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

💯

});

function error(options: {suggestion?: string; messageId?: string} = {}) {
console.log(options);
Copy link
Contributor

Choose a reason for hiding this comment

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

🔥

<script
key={id + js.slice(0, 24)}
{...props}
suppressHydrationWarning
Copy link
Contributor

Choose a reason for hiding this comment

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

Curious, why is it a mismatch in this case?

Comment on lines +141 to +143
if (load === 'inWorker') {
script.setAttribute('type', 'text/partytown');
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we somehow notify if this is set but Partytown is not installed? Like, is there anything in window. we can check of similar?

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

Successfully merging this pull request may close these issues.

7 participants