Skip to content

Commit

Permalink
fix(core): add hydration protected elements
Browse files Browse the repository at this point in the history
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`.
  • Loading branch information
arturovt committed May 15, 2024
1 parent 99bd9f1 commit fec38df
Show file tree
Hide file tree
Showing 21 changed files with 343 additions and 19 deletions.
2 changes: 2 additions & 0 deletions packages/core/src/hydration/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {enableApplyRootElementTransformImpl} from '../render3/instructions/share
import {enableLocateOrCreateContainerAnchorImpl} from '../render3/instructions/template';
import {enableLocateOrCreateTextNodeImpl} from '../render3/instructions/text';
import {getDocument} from '../render3/interfaces/document';
import {enableSetAttributeWithHydrationSupportImpl} from '../render3/util/attrs_utils_with_hydration_support';
import {isPlatformBrowser} from '../render3/util/misc_utils';
import {TransferState} from '../transfer_state';
import {performanceMarkFeature} from '../util/performance';
Expand Down Expand Up @@ -89,6 +90,7 @@ function enableHydrationRuntimeSupport() {
enableLocateOrCreateContainerRefImpl();
enableFindMatchingDehydratedViewImpl();
enableApplyRootElementTransformImpl();
enableSetAttributeWithHydrationSupportImpl();
}
}

Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/hydration/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,18 @@ export interface DehydratedView {
* removed from the DOM during hydration cleanup.
*/
dehydratedIcuData?: Map<number, DehydratedIcuData>;

/**
* A mapping from the node (TNode) index to a hydration protected attribute value.
*
* This information is utilized during the hydration process on the client when the initial render
* occurs (creation mode). Its purpose is to avoid overwriting attributes if they are already set
* on the element on the server side. For instance, the `iframe` element may already be rendered
* with the `src` attribute set. However, during the hydration process, calling `setAttribute`
* again could cause the `iframe` to reload the resource, even if the `src` attribute value
* remains the same as it was set on the server.
*/
protectedAttributes?: Map<number, string>;
}

/**
Expand Down
17 changes: 13 additions & 4 deletions packages/core/src/render3/component_ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,7 @@ function createRootComponentView(
hostRenderer: Renderer,
): LView {
const tView = rootView[TVIEW];
applyRootComponentStyling(rootDirectives, tNode, hostRNode, hostRenderer);
applyRootComponentStyling(rootDirectives, tNode, hostRNode, hostRenderer, rootView);

// Hydration info is on the host element and needs to be retrieved
// and passed to the component LView.
Expand Down Expand Up @@ -579,6 +579,7 @@ function applyRootComponentStyling(
tNode: TElementNode,
rNode: RElement | null,
hostRenderer: Renderer,
lView: LView,
): void {
for (const def of rootDirectives) {
tNode.mergedAttrs = mergeHostAttrs(tNode.mergedAttrs, def.hostAttrs);
Expand All @@ -588,7 +589,7 @@ function applyRootComponentStyling(
computeStaticStyling(tNode, tNode.mergedAttrs, true);

if (rNode !== null) {
setupStaticAttributes(hostRenderer, rNode, tNode);
setupStaticAttributes(hostRenderer, rNode, tNode, lView);
}
}
}
Expand Down Expand Up @@ -658,14 +659,22 @@ function setRootNodeAttributes(
) {
if (rootSelectorOrNode) {
// The placeholder will be replaced with the actual version at build time.
setUpAttributes(hostRenderer, hostRNode, ['ng-version', '0.0.0-PLACEHOLDER']);
setUpAttributes(
hostRenderer,
hostRNode,
['ng-version', '0.0.0-PLACEHOLDER'],
// Note that the following arguments are not provided because the root element and its
// attributes are not protected from hydration.
/* hydrationInfo */ null,
/* nodeIndex */ 0,
);
} else {
// If host element is created as a part of this function call (i.e. `rootSelectorOrNode`
// is not defined), also apply attributes and classes extracted from component selector.
// Extract attributes and classes from the first selector only to match VE behavior.
const {attrs, classes} = extractAttrsAndClassesFromSelector(componentDef.selectors[0]);
if (attrs) {
setUpAttributes(hostRenderer, hostRNode, attrs);
setUpAttributes(hostRenderer, hostRNode, attrs, /* hydrationInfo */ null, /* nodeIndex */ 0);
}
if (classes && classes.length > 0) {
writeDirectClass(hostRenderer, hostRNode, classes.join(' '));
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/render3/i18n/i18n_apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,8 @@ export function applyMutableOpCodes(
attrName,
attrValue,
null,
/* hydrationInfo */ null,
/* nodeIndex */ 0,
);
break;
default:
Expand Down Expand Up @@ -441,6 +443,8 @@ export function applyUpdateOpCodes(
propName,
value,
sanitizeFn,
/* hydrationInfo */ null,
/* nodeIndex */ 0,
);
} else {
elementPropertyInternal(
Expand Down
15 changes: 13 additions & 2 deletions packages/core/src/render3/instructions/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import {
TAttributes,
TElementNode,
TNode,
TNodeFlags,
TNodeType,
} from '../interfaces/node';
import {Renderer} from '../interfaces/renderer';
Expand Down Expand Up @@ -68,6 +67,7 @@ import {
wasLastNodeCreated,
} from '../state';
import {computeStaticStyling} from '../styling/static_styling';
import {getHydrationProtectedAttribute} from '../util/attrs_utils_with_hydration_support';
import {getConstant} from '../util/view_utils';

import {validateElementIsKnown} from './element_validation';
Expand Down Expand Up @@ -160,7 +160,7 @@ export function ɵɵelementStart(
}

setCurrentTNode(tNode, true);
setupStaticAttributes(renderer, native, tNode);
setupStaticAttributes(renderer, native, tNode, lView);

if (!isDetachedByI18n(tNode) && wasLastNodeCreated()) {
// In the i18n case, the translation may have removed this element, so only add it if it is not
Expand Down Expand Up @@ -294,6 +294,17 @@ function locateOrCreateElementNodeImpl(
ngDevMode && validateMatchingNode(native, Node.ELEMENT_NODE, name, lView, tNode);
ngDevMode && markRNodeAsClaimedByHydration(native);

const hydrationProtectedAttribute = getHydrationProtectedAttribute(native.tagName);
// If we're examining an element that is in the list of hydration-protected elements (e.g.,
// `<iframe>`) and it has a set attribute (e.g., `src`).
if (hydrationProtectedAttribute && native.hasAttribute(hydrationProtectedAttribute)) {
const attributeValue = native.getAttribute(hydrationProtectedAttribute);
if (attributeValue) {
const protectedAttributes = (hydrationInfo.protectedAttributes ??= new Map<number, string>());
protectedAttributes.set(tNode.index, attributeValue);
}
}

// This element might also be an anchor of a view container.
if (getSerializedContainerViews(hydrationInfo, index)) {
// Important note: this element acts as an anchor, but it's **not** a part
Expand Down
18 changes: 15 additions & 3 deletions packages/core/src/render3/instructions/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import {
import {escapeCommentText} from '../../util/dom';
import {normalizeDebugBindingName, normalizeDebugBindingValue} from '../../util/ng_reflect';
import {stringify} from '../../util/stringify';
import {applyValueToInputField} from '../apply_value_input_field';
import {
assertFirstCreatePass,
assertFirstUpdatePass,
Expand Down Expand Up @@ -140,6 +139,7 @@ import {
} from '../state';
import {NO_CHANGE} from '../tokens';
import {mergeHostAttrs} from '../util/attrs_utils';
import {_setAttributeImpl} from '../util/attrs_utils_with_hydration_support';
import {INTERPOLATION_DELIMITER} from '../util/misc_utils';
import {renderStringify} from '../util/stringify_utils';
import {
Expand Down Expand Up @@ -1672,7 +1672,17 @@ export function elementAttributeInternal(
);
}
const element = getNativeByTNode(tNode, lView) as RElement;
setElementAttribute(lView[RENDERER], element, namespace, tNode.value, name, value, sanitizer);
setElementAttribute(
lView[RENDERER],
element,
namespace,
tNode.value,
name,
value,
sanitizer,
lView[HYDRATION],
tNode.index,
);
}

export function setElementAttribute(
Expand All @@ -1683,6 +1693,8 @@ export function setElementAttribute(
name: string,
value: any,
sanitizer: SanitizerFn | null | undefined,
hydrationInfo: DehydratedView | null,
nodeIndex: number,
) {
if (value == null) {
ngDevMode && ngDevMode.rendererRemoveAttribute++;
Expand All @@ -1692,7 +1704,7 @@ export function setElementAttribute(
const strValue =
sanitizer == null ? renderStringify(value) : sanitizer(value, tagName || '', name);

renderer.setAttribute(element, name, strValue as string, namespace);
_setAttributeImpl(hydrationInfo, renderer, nodeIndex, element, name, strValue, namespace);
}
}

Expand Down
18 changes: 10 additions & 8 deletions packages/core/src/render3/node_manipulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {
consumerDestroy,
getActiveConsumer,
setActiveConsumer,
} from '@angular/core/primitives/signals';
import {consumerDestroy, setActiveConsumer} from '@angular/core/primitives/signals';

import {NotificationSource} from '../change_detection/scheduling/zoneless_scheduling';
import {hasInSkipHydrationBlockFlag} from '../hydration/skip_hydration';
Expand Down Expand Up @@ -68,6 +64,7 @@ import {
HookData,
HookFn,
HOST,
HYDRATION,
LView,
LViewFlags,
NEXT,
Expand Down Expand Up @@ -1272,11 +1269,16 @@ export function writeDirectClass(renderer: Renderer, element: RElement, newValue
}

/** Sets up the static DOM attributes on an `RNode`. */
export function setupStaticAttributes(renderer: Renderer, element: RElement, tNode: TNode) {
const {mergedAttrs, classes, styles} = tNode;
export function setupStaticAttributes(
renderer: Renderer,
element: RElement,
tNode: TNode,
lView: LView,
) {
const {index, mergedAttrs, classes, styles} = tNode;

if (mergedAttrs !== null) {
setUpAttributes(renderer, element, mergedAttrs);
setUpAttributes(renderer, element, mergedAttrs, lView[HYDRATION], index);
}

if (classes !== null) {
Expand Down
15 changes: 13 additions & 2 deletions packages/core/src/render3/util/attrs_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {DehydratedView} from '../../hydration/interfaces';
import {CharCode} from '../../util/char_code';
import {AttributeMarker} from '../interfaces/attribute_marker';
import {TAttributes} from '../interfaces/node';
import {CssSelector} from '../interfaces/projection';
import {Renderer} from '../interfaces/renderer';
import {RElement} from '../interfaces/renderer_dom';

import {_setAttributeImpl} from './attrs_utils_with_hydration_support';

/**
* Assigns all attribute values to the provided element via the inferred renderer.
*
Expand All @@ -37,9 +40,17 @@ import {RElement} from '../interfaces/renderer_dom';
* @param renderer The renderer to be used
* @param native The element that the attributes will be assigned to
* @param attrs The attribute array of values that will be assigned to the element
* @param hydrationInfo The hydration information
* @param nodeIndex The node index
* @returns the index value that was last accessed in the attributes array
*/
export function setUpAttributes(renderer: Renderer, native: RElement, attrs: TAttributes): number {
export function setUpAttributes(
renderer: Renderer,
native: RElement,
attrs: TAttributes,
hydrationInfo: DehydratedView | null,
nodeIndex: number,
): number {
let i = 0;
while (i < attrs.length) {
const value = attrs[i];
Expand Down Expand Up @@ -68,7 +79,7 @@ export function setUpAttributes(renderer: Renderer, native: RElement, attrs: TAt
if (isAnimationProp(attrName)) {
renderer.setProperty(native, attrName, attrVal);
} else {
renderer.setAttribute(native, attrName, attrVal as string);
_setAttributeImpl(hydrationInfo, renderer, nodeIndex, native, attrName, attrVal as string);
}
i++;
}
Expand Down
107 changes: 107 additions & 0 deletions packages/core/src/render3/util/attrs_utils_with_hydration_support.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {DehydratedView} from '../../hydration/interfaces';
import {Renderer} from '../interfaces/renderer';
import {RElement} from '../interfaces/renderer_dom';

/**
* This represents a mapping of element tag name to an attribute name that should not be reset
* during hydration in case the value is the same.
* Values are implemented as a list in case they need to be expanded in the future to accommodate
* more cases.
*/
const hydrationProtectedElementToAttributeMap = new Map<string, string>([
// All these elements and their attributes force the browser to reload resources when they're set
// again with the same value. For example, consider an `<object>` element with its `data`
// attribute set to `/assets/some-file.pdf`. When the browser retrieves an HTML document from the
// server and finishes parsing it (after the document state is set to `complete`), it loads
// external resources such as images, videos, audios, etc. This includes loading
// `/assets/some-file.pdf`. Subsequently, when Angular begins its hydration process, it attempts
// to call `setAttribute` on the `<object>` element again with `setAttribute('data',
// '/assets/some-file.pdf')`. This action forces the browser to reload the same resources, even
// though they have already been loaded previously.
['iframe', 'src'],
['embed', 'src'],
['object', 'data'],
]);

export function getHydrationProtectedAttribute(tagName: string): string | undefined {
return hydrationProtectedElementToAttributeMap.get(
// Convert to lowercase so we cover both cases when the tag name is `iframe` or `IFRAME`.
tagName.toLowerCase(),
);
}

let _setAttributeWithHydrationSupport: typeof _setAttributeWithHydrationSupportImpl = (
hydrationInfo: DehydratedView | null,
renderer: Renderer,
nodeIndex: number,
element: RElement,
attributeName: string,
attributeValue: string,
namespace?: string | null,
) => {
renderer.setAttribute(element, attributeName, attributeValue, namespace);
};

function _setAttributeWithHydrationSupportImpl(
hydrationInfo: DehydratedView | null,
renderer: Renderer,
nodeIndex: number,
element: RElement,
attributeName: string,
attributeValue: string,
namespace?: string | null,
) {
if (
getHydrationProtectedAttribute(element.tagName) &&
hydrationInfo?.protectedAttributes?.has(nodeIndex)
) {
// If the index of the currently hydrating element is found in the `protectedAttributes` map, it
// indicates that this element has hydration-protected attributes (for example, if the `element`
// is an `iframe` with its `src` attribute set).
const protectedAttributeValue = hydrationInfo.protectedAttributes.get(nodeIndex)!;
if (attributeValue === protectedAttributeValue) {
// An initial value is protected for the first `setAttribute` call, further protection would
// happen as a part of the change detection mechanisms. Remove the node index entry
// from the map to indicate that no special logic are required for this element and a given
// attribute.
hydrationInfo.protectedAttributes.delete(nodeIndex);
// We do not want to reset the value since it may negatively affect performance and UX. For
// example, in the `<iframe src="...">` case, this would cause a reload within the iframe.
return;
}
}

renderer.setAttribute(element, attributeName, attributeValue, namespace);
}

export function _setAttributeImpl(
hydrationInfo: DehydratedView | null,
renderer: Renderer,
nodeIndex: number,
element: RElement,
attributeName: string,
attributeValue: string,
namespace?: string | null,
) {
_setAttributeWithHydrationSupport(
hydrationInfo,
renderer,
nodeIndex,
element,
attributeName,
attributeValue,
namespace,
);
}

export function enableSetAttributeWithHydrationSupportImpl() {
_setAttributeWithHydrationSupport = _setAttributeWithHydrationSupportImpl;
}
Loading

0 comments on commit fec38df

Please sign in to comment.