Skip to content

fix(core): add hydration protected elements - #52478

Closed
arturovt wants to merge 1 commit into
angular:mainfrom
arturovt:fix/attribute-setting
Closed

arturovt wants to merge 1 commit into
angular:mainfrom
arturovt:fix/attribute-setting

Conversation

@arturovt

@arturovt arturovt commented Oct 31, 2023 •

Copy link
Copy Markdown
Contributor

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

AndrewKushnir commented Mar 22, 2024 •

Copy link
Copy Markdown
Contributor

@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

arturovt commented Apr 5, 2024 •

Copy link
Copy Markdown
Contributor Author

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

AndrewKushnir commented Apr 9, 2024 •

Copy link
Copy Markdown
Contributor

@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

arturovt commented Apr 9, 2024

Copy link
Copy Markdown
Contributor Author

@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 force-pushed the fix/attribute-setting branch from e834d25 to 8d2cffe Compare April 17, 2024 16:50
@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
Copy Markdown

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
Copy Markdown
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

arturovt commented Apr 26, 2024 •

Copy link
Copy Markdown
Contributor Author

@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

@AndrewKushnir AndrewKushnir left a comment

Copy link
Copy Markdown
Contributor

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
@arturovt

Copy link
Copy Markdown
Contributor Author

@AndrewKushnir made changes according to your comment.

@arturovt
arturovt force-pushed the fix/attribute-setting branch 2 times, most recently from b4edc63 to f98e4f7 Compare July 15, 2024 07:38
@rhutchison

Copy link
Copy Markdown

@AndrewKushnir made changes according to your comment.

When this ships, I will celebrate for all of you

@rhutchison

Copy link
Copy Markdown

@AndrewKushnir what's left?

@AndrewKushnir

Copy link
Copy Markdown
Contributor

@arturovt @rhutchison Sorry for the delay. I've added this fix to the v19 candidate list and we'll perform code review and testing as a part of v19 preparation process.

@arturovt
arturovt force-pushed the fix/attribute-setting branch from f98e4f7 to 2d8144a Compare October 22, 2024 09:31
@angular-robot angular-robot Bot added area: core Issues related to the framework runtime and removed area: core Issues related to the framework runtime labels Oct 22, 2024
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`.
@arturovt
arturovt force-pushed the fix/attribute-setting branch from 2d8144a to a61aa28 Compare October 23, 2024 21:50
@angular-robot angular-robot Bot added area: core Issues related to the framework runtime and removed area: core Issues related to the framework runtime labels Oct 23, 2024
@AndrewKushnir

AndrewKushnir commented Oct 24, 2024 •

Copy link
Copy Markdown
Contributor

@arturovt thanks for working on this fix 👍

We've were reviewing this change today and the conclusion was that this fix would work (and I remember that I was proposing this/similar approach earlier), but it introduces quite a lot of complexity and runtime code at perf-sensitive locations, which would be very hard to manage. @alxhub proposed a different and a very interesting approach. Instead of relying on the overwritten behavior at runtime, we can serialize more info on the server and pre-populate corresponding LView slots with the necessary attribute values during hydration. In this case, a non-hydration runtime code would not require any changes - an update of an attribute would not happen with an existing runtime logic.

Here are some extra details that might be helpful for this change.

  1. The serialization logic can be added to the serializeLView function here, you can take a look how TNodes with the TNodeType.Element type are processed. The TNode would contain a tag name (tNode.value) and you'd also need an attribute/property name and value - this would require some extra code, see points below.

    • Static attributes (e.g. <iframe src="...">): those attributes (both name and value ) would be stored on a TNode at the tNode.mergedAttrs slot.
    • Bindings (e.g. <iframe [src]="...">): the value of an attribute/property would be stored in an LView, but the name is currently not stored in production mode, only in dev mode by using the storePropertyBindingMetadata function. We may potentially need a similar function (or reuse the same one?) and invoke it only when we are on the server. We can potentially bring this logic in in a similar way how it's done for attribute and property setter functions in this PR.
  2. The necessary information (a list of key-value pairs) can be included into the serialized output (it'd be present in the nghData in the TransferState). For example:

nghData: {
  ...
  {  // this object represents an LView info:
    attributes: {
      2 /* element idx */: [5 /* binding idx */, 'src', '...'],
      7 /* element idx */: [-1 /* `-1` means static */, 'src', '...'],
      ...
    } 
  }
}

The idea is that we'll have a special object in a serialized object for each LView and we can use an element LView slot id as an index and represent key-value pairs as a flat array + include binding slot index (we can also optimize it and have a string with separators as a value, e.g. 2|src|https://some-url.com).

  1. On the client, during hydration (in the locateOrCreateElementNodeImpl function), we can do the following:

    • check to see if we an element with a given index have special attributes
    • if yes, read the data and determine whether it's a static value or a dynamic binding
    • for static values - we'll use it to skip a "write" operation in the setupStaticAttributes (see point 4 below)
    • for dynamic bindings - restore it at LView[index]
  2. For static values, we may need to do an extra update to the setupStaticAttributes function to avoid resetting the value (for attributes that were serialized as "special"). There are a few ways to do it, we can look into that if the dynamic binding case works correctly.

Please let me know if you need any extra info or have any questions, we'll be happy to help. Also, if you'd like, I can create an initial draft of the change and share with you (in this thread) as a diff.

Let's also keep this PR open for now, we'll get back to it in case the described approach doesn't work.

Thank you.

@arturovt

Copy link
Copy Markdown
Contributor Author

@AndrewKushnir okay, I'll need to go through the functions you mentioned locally in the playground to see what data they're working with and how they're serializing the LView.

@thePunderWoman

Copy link
Copy Markdown
Contributor

Since this has been stale for so long, I'm going to close this. Feel free to open a new PR if you want to still see this land.

@arturovt
arturovt deleted the fix/attribute-setting branch April 30, 2025 15:51
@angular-automatic-lock-bot

Copy link
Copy Markdown

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

@angular-automatic-lock-bot angular-automatic-lock-bot Bot locked and limited conversation to collaborators May 31, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

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.

4 participants