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

fix(core): add hydration protected elements #52478

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

arturovt
Copy link
Contributor

@arturovt arturovt commented Oct 31, 2023

No description provided.

@AndrewKushnir AndrewKushnir added area: core Issues related to the framework runtime core: hydration labels Oct 31, 2023
@ngbot ngbot bot added this to the Backlog milestone Oct 31, 2023
@AndrewKushnir AndrewKushnir self-assigned this Oct 31, 2023
@AndrewKushnir
Copy link
Contributor

AndrewKushnir commented Mar 22, 2024

@arturovt as we've discussed offline, the proposed fix would not work due to potential performance issues. This extra logic should be limited to the following scenario:

  • when an element is an iframe and it was hydrated
  • and when we try to set the src attribute for the first time

There are 2 possible cases:

  1. Static src attribute (e.g. <iframe src="...">).
  2. When src attribute is set as a binding (e.g. <iframe [src]="...">).

Those 2 cases are being handled by different code paths (static case, binding case). Static attributes are applied when an element is created/hydrated and we can handle that. The binding case is more complicated: it's invoked as a part of change detection and currently there is no information whether an underlying element was created or hydrated, so we need to store more info about it (but only for iframes with src attributes).

Here is a proposed solution:

  1. Add a new field to the DehydratedView interface (code) to store an initial value of the src, which it currently has in the DOM. The field might have the following shape: Map<number, string>, where number would represent a TNode index and a value is the current value of the src attribute.
  2. In hydration code (here), once we find an element in the DOM, we can check whether an element is an iframe and if so, read the value of the src attribute and store it in the lView[HYDRATION] field (we've updated the type for it in Step 1). Note: the field should be null by default, so we don't create empty Maps for relatively rare cases when we need it, we can create it lazily when we come across an iframe.
  3. Update the code path for static attributes (in this function) and instead of always doing renderer.setAttribute(native, attrName, attrVal as string), we can invoke a different function (for example, _setAttribute), which would do the regular call by default, but we replace its implementation if hydration is enabled using this approach. The hydration-aware implementation may look like this:
function _setAttributeWithHydrationSupport(lView: LView, tNode: TNode, tagName: string, element: RElement, name: string, value: string|TrustedHTML|TrustedScript|TrustedScriptURL, namespace?: string|null) { 
  if (
      // Are we in the process of creating/updating a view the first time?
      (lView[FLAGS] & LViewFlags.FirstLViewPass) &&
      // Do we have extra hydration info?
      (typeof lView?.[HYDRATION]?.protectedAttrs !== 'undefined') &&
      // Is it a "protected" attribute?
      isHydrationProtectedAttr(tagName, name) && 
      // Do we have a "protected" attribute on this node and whether the value is the same?
      lView[HYDRATION].protectedAttrs[tNode.index] === value) {

    // If answers to the questions above are all "yes" - exit, we do not want to reset the value,
    // since it may negatively affect performance and UX (e.g. in the `<iframe src="...">` case,
    // this would cause a reload within the iframe).
    return;
  }
  const renderer = lView[RENDERER];
  renderer.setAttribute(element, name, value, namespace);
}

The isHydrationProtectedAttr may look like this:

function isHydrationProtectedAttr(tagName: string, attrName: string): boolean {
  // Note: this list can be expanded if there are more cases.
  return tagName === 'iframe' && attrName === 'src';
}
  1. For the binding case, we can now use the _setAttribute, which will use hydration-aware implementation and prevent overriding src for the first update pass.
  2. Put together a few tests (in this spec) to verify that the src is not overridden during hydration.

Additional note: there might be other elements affected by the same problem, we'd like to ask if you could check the spec for elements like <audio>, <object>, <picture> and others that refer to remote resources, to see if any of them might be affected and should be included into the fix.

Please let me know if any additional information is required.

@arturovt
Copy link
Contributor Author

arturovt commented Apr 5, 2024

Here's the list of valid and invalid cases: elements marked in red reload resources when hydrated. We don't consider cases when the cache is disabled through devtools or the remote resource returns a 302 status code.

<img src="/assets/img_girl.jpg" />
<picture>
  <source media="(min-width: 650px)" srcset="/assets/img_food.jpg" />
  <source media="(min-width: 465px)" srcset="/assets/img_car.jpg" />
  <img src="/assets/img_girl.jpg"  />
</picture>
<svg>
  <image xlink:href="/assets/angular.svg" />
</svg>
<video controls autoplay>
  <source src="/assets/mov_bbb.mp4" type="video/mp4" />
  <source src="/assets/mov_bbb.ogg" type="video/ogg" />
</video>
<audio controls src="/assets/t-rex-roar.mp3"></audio>
<video controls src="/assets/friday.mp4">
  <track default kind="captions" srclang="en" src="/assets/friday.vtt" />
</video>

<object
  type="application/pdf"
  data="/assets/In-CC0.pdf"
></object>
<embed type="video/webm" src="/assets/flower.mp4" />
<iframe src="https://www.youtube-nocookie.com/embed/7HVMn1TpXKQ"></iframe>

@AndrewKushnir
Copy link
Contributor

AndrewKushnir commented Apr 9, 2024

@arturovt thanks for additional information, this is very helpful. We can extend the isHydrationProtectedAttr function described in my previous comment to support <object> and <embed> cases as well. Please let us know if you might be interested in updating this PR as described in #52478 (comment) and also let us know if you have any questions about the proposed fix.

@arturovt
Copy link
Contributor Author

arturovt commented Apr 9, 2024

@AndrewKushnir I am interested in providing the fix; I'll need to go through your comments and start implementing it locally. I'll message you if I have any questions.

@arturovt arturovt changed the title fix(platform-browser): check whether attribute value is the same before setting fix(core): add hydration protected elements Apr 17, 2024
@arturovt arturovt force-pushed the fix/attribute-setting branch 9 times, most recently from ee825ad to ada559b Compare April 18, 2024 15:24
@rhutchison
Copy link

Thanks @arturovt and @AndrewKushnir for your efforts on this.

@arturovt arturovt force-pushed the fix/attribute-setting branch 2 times, most recently from 0dd36bf to 480bb0f Compare April 24, 2024 07:04
@AndrewKushnir AndrewKushnir modified the milestones: Backlog, v18 final Apr 26, 2024
@AndrewKushnir
Copy link
Contributor

@arturovt thanks for implementing this 👍 I've added this PR to the v18 milestone.

If you get a chance, could you please try to find corresponding sections of HTML spec that talk about this specific behavior of the <iframe>, <object> and <embed> elements, so that we can include it as comments into the code for future reference?

@arturovt
Copy link
Contributor Author

arturovt commented Apr 26, 2024

@AndrewKushnir I went through the HTML standard and I may guess it doesn't explicitly mandate this behavior or provide detailed instructions on how user agents should handle this aspect of network requests. Because if we talk about img.src - setting the src attribute to the same value again on the img element doesn't trigger resource reload and it's a common behavior implemented by all user agents. Considering the iframe.src case, I may guess that the content of an iframe can be dynamic and it may contain interactive elements or scripts that require reloading when the source URL changes (took this statement from Google search).

I am confident that what we're doing in this PR doesn't go against the spec because we're essentially targeting that hydration case with the very first render. (Please correct me if I'm wrong...).

Out of curiosity, wanna check what engines do internally...

UPD: I examined the behavior of Safari's JSC for images and iframes.

Each HTML element class has a hook called attributeChanged.

For iframes, HTMLIFrameElement::attributeChanged triggers HTMLFrameElementBase::attributeChanged, which then determines the attribute being changed. If name == srcAttr, it invokes setLocation, which in turn calls openURL.

In the case of images, it's more intricate and aligns with our previous discussion. HTMLImageElement::attributeChanged employs a switch-case structure, and if it's case AttributeNames::srcAttr, it performs this check:

if (oldValue != newValue)
  selectImageSource(RelevantMutation::Yes);
else
  m_imageLoader->updateFromElementIgnoringPreviousErrorToSameValue();

And updateFromElementIgnoringPreviousErrorToSameValue checks whether image->allowsCaching() and does nothing if it has caching or does not have running service worker.

But you know, it's more like interesting information to keep in mind. Unfortunately, such implementation details are not included in the specification.

@arturovt arturovt force-pushed the fix/attribute-setting branch 2 times, most recently from eec6111 to ec8c509 Compare May 3, 2024 05:46
Copy link
Contributor

@AndrewKushnir AndrewKushnir left a comment

Choose a reason for hiding this comment

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

@arturovt thanks for the PR, the code looks great! I've just left a few comments with some proposals.

Could you please also mark this PR as "Ready for review" via GitHub UI?

@AndrewKushnir AndrewKushnir added the target: rc This PR is targeted for the next release-candidate label May 12, 2024
@AndrewKushnir AndrewKushnir added the action: review The PR is still awaiting reviews from at least one requested reviewer label May 12, 2024
@arturovt arturovt marked this pull request as ready for review May 14, 2024 13:09
@arturovt
Copy link
Contributor Author

Could you please also mark this PR as "Ready for review" via GitHub UI?

Done.

Symbol tests are failing, I will update symbols later. Gotta play with unit tests first to spy on the setAttribute.

@AndrewKushnir AndrewKushnir removed this from the v18 final milestone May 16, 2024
@ngbot ngbot bot added this to the Backlog milestone May 16, 2024
@arturovt arturovt force-pushed the fix/attribute-setting branch 3 times, most recently from 7752b64 to 05bfe2b Compare May 19, 2024 16:43
@arturovt
Copy link
Contributor Author

arturovt commented May 19, 2024

@AndrewKushnir I have addressed your comments and also updated unit tests with spying on the setAttribute of the renderer.

@arturovt arturovt force-pushed the fix/attribute-setting branch 2 times, most recently from b3e360c to 7c18e66 Compare May 19, 2024 18:20
@AndrewKushnir AndrewKushnir added target: patch This PR is targeted for the next patch release and removed target: rc This PR is targeted for the next release-candidate labels May 22, 2024
Copy link
Contributor

@AndrewKushnir AndrewKushnir left a comment

Choose a reason for hiding this comment

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

@arturovt thanks for addressing the feedback! I've left a comment about some extra testing cases.

Comment on lines +7378 to +7393
// Assert that the spy for the hydration-protected element renderer has not been called
// at all.
expect(setAttributeSpies[1]).not.toHaveBeenCalled();
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you please also add some logic here to change the value and make sure that the setAttribite was invoked as needed (i.e. that the protection logic doesn't prevent subsequent value updates)?

@arturovt
Copy link
Contributor Author

arturovt commented May 24, 2024

@AndrewKushnir your last comment helped me realize I was moving in the wrong direction. We only covered the attribute case (<iframe [attr.src]="..."), but <iframe [src]=value actually calls elementPropertyInternal and then the renderer's setProperty method.

I have updated packages/platform-server/test/hydration_spec.ts with a value update to ensure that setAttribute is called after hydration is completed. You may notice that I updated bindings to attribute bindings ([attr.src]).

Wondering whether we have to update the setProperty implementation too?

With this commit, we are now able to handle hydration-protected elements and their attributes.

This enhancement is aimed at improving UX and reducing the number of HTTP requests made by browsers
to external resources during the hydration process. Take, for example, the `iframe` element with a
static `src` attribute, which is rendered on the server. When we begin hydrating that element on the
client, it attempts to set up the static attributes again. This includes setting the `src` attribute
again with the same value it already has. Browsers interpret each change to the `src` attribute as a
new request to load the specified resource. Consequently, the video may flicker as the browser attempts
to reload it.

We now maintain a map of elements that should be protected from hydration. Each element (acting as a key)
maps to a list of attributes, for instance, `iframe -> ['src']`. When we look up an existing element, we
check its tag name and if there's a list of attributes associated with it. If we encounter an `iframe`
(or other protected element), we iterate over its protected attributes and check whether that element
already has those attributes set. If it does, we save this information in the hydration info map to use
it when calling `setAttributes`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
action: review The PR is still awaiting reviews from at least one requested reviewer area: core Issues related to the framework runtime core: hydration target: patch This PR is targeted for the next patch release
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants