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 3, 2024
1 parent 58a8f12 commit ec8c509
Show file tree
Hide file tree
Showing 21 changed files with 376 additions and 14 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
25 changes: 21 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,30 @@ 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.
/* isFirstPass */ false,
/* 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,
/* isFirstPass */ false,
/* hydrationInfo */ null,
/* nodeIndex */ 0,
);
}
if (classes && classes.length > 0) {
writeDirectClass(hostRenderer, hostRNode, classes.join(' '));
Expand Down
6 changes: 6 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,9 @@ export function applyMutableOpCodes(
attrName,
attrValue,
null,
/* isFirstPass */ false,
/* hydrationInfo */ null,
/* nodeIndex */ 0,
);
break;
default:
Expand Down Expand Up @@ -441,6 +444,9 @@ export function applyUpdateOpCodes(
propName,
value,
sanitizeFn,
/* isFirstPass */ false,
/* 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
30 changes: 27 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 {setAttributeWithHydrationSupport} 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,19 @@ export function elementAttributeInternal(
);
}
const element = getNativeByTNode(tNode, lView) as RElement;
setElementAttribute(lView[RENDERER], element, namespace, tNode.value, name, value, sanitizer);
const isFirstPass = (lView[FLAGS] & LViewFlags.FirstLViewPass) === LViewFlags.FirstLViewPass;
setElementAttribute(
lView[RENDERER],
element,
namespace,
tNode.value,
name,
value,
sanitizer,
isFirstPass,
lView[HYDRATION],
tNode.index,
);
}

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

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

Expand Down
13 changes: 10 additions & 3 deletions packages/core/src/render3/node_manipulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
HookData,
HookFn,
HOST,
HYDRATION,
LView,
LViewFlags,
NEXT,
Expand Down Expand Up @@ -1272,11 +1273,17 @@ 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);
const isFirstPass = (lView[FLAGS] & LViewFlags.FirstLViewPass) === LViewFlags.FirstLViewPass;
setUpAttributes(renderer, element, mergedAttrs, isFirstPass, lView[HYDRATION], index);
}

if (classes !== null) {
Expand Down
25 changes: 23 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 {setAttributeWithHydrationSupport} from './attrs_utils_with_hydration_support';

/**
* Assigns all attribute values to the provided element via the inferred renderer.
*
Expand All @@ -37,9 +40,19 @@ 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 isFirstPass Whether we are in the process of creating/updating a view (first time)
* @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,
isFirstPass: boolean,
hydrationInfo: DehydratedView | null,
nodeIndex: number,
): number {
let i = 0;
while (i < attrs.length) {
const value = attrs[i];
Expand Down Expand Up @@ -68,7 +81,15 @@ 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);
setAttributeWithHydrationSupport(
isFirstPass,
hydrationInfo,
renderer,
nodeIndex,
native,
attrName,
attrVal as string,
);
}
i++;
}
Expand Down
Loading

0 comments on commit ec8c509

Please sign in to comment.