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 Apr 18, 2024
1 parent a5b5b7d commit ada559b
Show file tree
Hide file tree
Showing 19 changed files with 331 additions and 44 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 @@ -19,6 +19,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 @@ -74,6 +75,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 @@ -194,6 +194,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: 12 additions & 5 deletions packages/core/src/render3/component_ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ function createRootComponentView(
rootDirectives: DirectiveDef<any>[], rootView: LView, environment: LViewEnvironment,
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 @@ -456,7 +456,7 @@ function createRootComponentView(
/** Sets up the styling information on a root component. */
function applyRootComponentStyling(
rootDirectives: DirectiveDef<any>[], tNode: TElementNode, rNode: RElement|null,
hostRenderer: Renderer): void {
hostRenderer: Renderer, lView: LView): void {
for (const def of rootDirectives) {
tNode.mergedAttrs = mergeHostAttrs(tNode.mergedAttrs, def.hostAttrs);
}
Expand All @@ -465,7 +465,7 @@ function applyRootComponentStyling(
computeStaticStyling(tNode, tNode.mergedAttrs, true);

if (rNode !== null) {
setupStaticAttributes(hostRenderer, rNode, tNode);
setupStaticAttributes(hostRenderer, rNode, tNode, lView);
}
}
}
Expand Down Expand Up @@ -524,14 +524,21 @@ function setRootNodeAttributes(
rootSelectorOrNode: any) {
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
86 changes: 60 additions & 26 deletions packages/core/src/render3/instructions/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,39 @@
* found in the LICENSE file at https://angular.io/license
*/

import {invalidSkipHydrationHost, validateMatchingNode, validateNodeExists} from '../../hydration/error_handling';
import {invalidSkipHydrationHost, validateMatchingNode, validateNodeExists,} from '../../hydration/error_handling';
import {locateNextRNode} from '../../hydration/node_lookup_utils';
import {hasSkipHydrationAttrOnRElement, hasSkipHydrationAttrOnTNode} from '../../hydration/skip_hydration';
import {getSerializedContainerViews, isDisconnectedNode, markRNodeAsClaimedByHydration, markRNodeAsSkippedByHydration, setSegmentHead} from '../../hydration/utils';
import {hasSkipHydrationAttrOnRElement, hasSkipHydrationAttrOnTNode,} from '../../hydration/skip_hydration';
import {getSerializedContainerViews, isDisconnectedNode, markRNodeAsClaimedByHydration, markRNodeAsSkippedByHydration, setSegmentHead,} from '../../hydration/utils';
import {isDetachedByI18n} from '../../i18n/utils';
import {assertDefined, assertEqual, assertIndexInRange} from '../../util/assert';
import {assertFirstCreatePass, assertHasParent} from '../assert';
import {attachPatchData} from '../context_discovery';
import {registerPostOrderHooks} from '../hooks';
import {hasClassInput, hasStyleInput, TAttributes, TElementNode, TNode, TNodeFlags, TNodeType} from '../interfaces/node';
import {hasClassInput, hasStyleInput, TAttributes, TElementNode, TNode, TNodeFlags, TNodeType,} from '../interfaces/node';
import {Renderer} from '../interfaces/renderer';
import {RElement} from '../interfaces/renderer_dom';
import {isComponentHost, isContentQueryHost, isDirectiveHost} from '../interfaces/type_checks';
import {HEADER_OFFSET, HYDRATION, LView, RENDERER, TView} from '../interfaces/view';
import {assertTNodeType} from '../node_assert';
import {appendChild, clearElementContents, createElementNode, setupStaticAttributes} from '../node_manipulation';
import {decreaseElementDepthCount, enterSkipHydrationBlock, getBindingIndex, getCurrentTNode, getElementDepthCount, getLView, getNamespace, getTView, increaseElementDepthCount, isCurrentTNodeParent, isInSkipHydrationBlock, isSkipHydrationRootTNode, lastNodeWasCreated, leaveSkipHydrationBlock, setCurrentTNode, setCurrentTNodeAsNotParent, wasLastNodeCreated} from '../state';
import {appendChild, clearElementContents, createElementNode, setupStaticAttributes,} from '../node_manipulation';
import {decreaseElementDepthCount, enterSkipHydrationBlock, getBindingIndex, getCurrentTNode, getElementDepthCount, getLView, getNamespace, getTView, increaseElementDepthCount, isCurrentTNodeParent, isInSkipHydrationBlock, isSkipHydrationRootTNode, lastNodeWasCreated, leaveSkipHydrationBlock, setCurrentTNode, setCurrentTNodeAsNotParent, 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';
import {setDirectiveInputsWhichShadowsStyling} from './property';
import {createDirectivesInstances, executeContentQueries, getOrCreateTNode, resolveDirectives, saveResolvedLocalsInData} from './shared';

import {createDirectivesInstances, executeContentQueries, getOrCreateTNode, resolveDirectives, saveResolvedLocalsInData,} from './shared';

function elementStartFirstCreatePass(
index: number, tView: TView, lView: LView, name: string, attrsIndex?: number|null,
localRefsIndex?: number): TElementNode {
index: number,
tView: TView,
lView: LView,
name: string,
attrsIndex?: number|null,
localRefsIndex?: number,
): TElementNode {
ngDevMode && assertFirstCreatePass(tView);
ngDevMode && ngDevMode.firstCreatePass++;

Expand Down Expand Up @@ -74,22 +79,27 @@ function elementStartFirstCreatePass(
* @codeGenApi
*/
export function ɵɵelementStart(
index: number, name: string, attrsIndex?: number|null,
localRefsIndex?: number): typeof ɵɵelementStart {
index: number,
name: string,
attrsIndex?: number|null,
localRefsIndex?: number,
): typeof ɵɵelementStart {
const lView = getLView();
const tView = getTView();
const adjustedIndex = HEADER_OFFSET + index;

ngDevMode &&
assertEqual(
getBindingIndex(), tView.bindingStartIndex,
'elements should be created before any bindings');
getBindingIndex(),
tView.bindingStartIndex,
'elements should be created before any bindings',
);
ngDevMode && assertIndexInRange(lView, adjustedIndex);

const renderer = lView[RENDERER];
const tNode = tView.firstCreatePass ?
elementStartFirstCreatePass(adjustedIndex, tView, lView, name, attrsIndex, localRefsIndex) :
tView.data[adjustedIndex] as TElementNode;
(tView.data[adjustedIndex] as TElementNode);

const native = _locateOrCreateElementNode(tView, lView, tNode, renderer, name, index);
lView[adjustedIndex] = native;
Expand All @@ -101,7 +111,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 @@ -183,26 +193,40 @@ export function ɵɵelementEnd(): typeof ɵɵelementEnd {
* @codeGenApi
*/
export function ɵɵelement(
index: number, name: string, attrsIndex?: number|null,
localRefsIndex?: number): typeof ɵɵelement {
index: number,
name: string,
attrsIndex?: number|null,
localRefsIndex?: number,
): typeof ɵɵelement {
ɵɵelementStart(index, name, attrsIndex, localRefsIndex);
ɵɵelementEnd();
return ɵɵelement;
}

let _locateOrCreateElementNode: typeof locateOrCreateElementNodeImpl =
(tView: TView, lView: LView, tNode: TNode, renderer: Renderer, name: string, index: number) => {
lastNodeWasCreated(true);
return createElementNode(renderer, name, getNamespace());
};
let _locateOrCreateElementNode: typeof locateOrCreateElementNodeImpl = (
tView: TView,
lView: LView,
tNode: TNode,
renderer: Renderer,
name: string,
index: number,
) => {
lastNodeWasCreated(true);
return createElementNode(renderer, name, getNamespace());
};

/**
* Enables hydration code path (to lookup existing elements in DOM)
* in addition to the regular creation mode of element nodes.
*/
function locateOrCreateElementNodeImpl(
tView: TView, lView: LView, tNode: TNode, renderer: Renderer, name: string,
index: number): RElement {
tView: TView,
lView: LView,
tNode: TNode,
renderer: Renderer,
name: string,
index: number,
): RElement {
const hydrationInfo = lView[HYDRATION];
const isNodeCreationMode = !hydrationInfo || isInSkipHydrationBlock() ||
isDetachedByI18n(tNode) || isDisconnectedNode(hydrationInfo, index);
Expand All @@ -218,6 +242,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 All @@ -244,7 +279,6 @@ function locateOrCreateElementNodeImpl(
clearElementContents(native);

ngDevMode && markRNodeAsSkippedByHydration(native);

} else if (ngDevMode) {
// If this is not a component host, throw an error.
// Hydration can be skipped on per-component basis only.
Expand Down
10 changes: 6 additions & 4 deletions packages/core/src/render3/node_manipulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {TElementNode, TIcuContainerNode, TNode, TNodeFlags, TNodeType, TProjecti
import {Renderer} from './interfaces/renderer';
import {RComment, RElement, RNode, RTemplate, RText} from './interfaces/renderer_dom';
import {isLContainer, isLView} from './interfaces/type_checks';
import {CHILD_HEAD, CLEANUP, DECLARATION_COMPONENT_VIEW, DECLARATION_LCONTAINER, DestroyHookData, ENVIRONMENT, FLAGS, HookData, HookFn, HOST, LView, LViewFlags, NEXT, ON_DESTROY_HOOKS, PARENT, QUERIES, REACTIVE_TEMPLATE_CONSUMER, RENDERER, T_HOST, TVIEW, TView, TViewType} from './interfaces/view';
import {CHILD_HEAD, CLEANUP, DECLARATION_COMPONENT_VIEW, DECLARATION_LCONTAINER, DestroyHookData, ENVIRONMENT, FLAGS, HookData, HookFn, HOST, HYDRATION, LView, LViewFlags, NEXT, ON_DESTROY_HOOKS, PARENT, QUERIES, REACTIVE_TEMPLATE_CONSUMER, RENDERER, T_HOST, TVIEW, TView, TViewType} from './interfaces/view';
import {assertTNodeType} from './node_assert';
import {profiler, ProfilerEvent} from './profiler';
import {setUpAttributes} from './util/attrs_utils';
Expand Down Expand Up @@ -1109,11 +1109,13 @@ 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
47 changes: 38 additions & 9 deletions packages/core/src/render3/util/attrs_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
* 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 {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 @@ -39,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 @@ -70,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 All @@ -83,6 +102,8 @@ export function setUpAttributes(renderer: Renderer, native: RElement, attrs: TAt
return i;
}



/**
* Test whether the given value is a marker that indicates that the following
* attribute values in a `TAttributes` array are only the names of attributes,
Expand All @@ -91,8 +112,9 @@ export function setUpAttributes(renderer: Renderer, native: RElement, attrs: TAt
* @returns true if the marker is a "name-only" marker (e.g. `Bindings`, `Template` or `I18n`).
*/
export function isNameOnlyAttributeMarker(marker: string|AttributeMarker|CssSelector) {
return marker === AttributeMarker.Bindings || marker === AttributeMarker.Template ||
marker === AttributeMarker.I18n;
return (
marker === AttributeMarker.Bindings || marker === AttributeMarker.Template ||
marker === AttributeMarker.I18n);
}

export function isAnimationProp(name: string): boolean {
Expand All @@ -110,7 +132,10 @@ export function isAnimationProp(name: string): boolean {
* @param dst Location of where the merged `TAttributes` should end up.
* @param src `TAttributes` which should be appended to `dst`
*/
export function mergeHostAttrs(dst: TAttributes|null, src: TAttributes|null): TAttributes|null {
export function mergeHostAttrs(
dst: TAttributes|null,
src: TAttributes|null,
): TAttributes|null {
if (src === null || src.length === 0) {
// do nothing
} else if (dst === null || dst.length === 0) {
Expand Down Expand Up @@ -150,8 +175,12 @@ export function mergeHostAttrs(dst: TAttributes|null, src: TAttributes|null): TA
* @param value Value to add or to overwrite to `TAttributes` Only used if `marker` is not Class.
*/
export function mergeHostAttribute(
dst: TAttributes, marker: AttributeMarker, key1: string, key2: string|null,
value: string|null): void {
dst: TAttributes,
marker: AttributeMarker,
key1: string,
key2: string|null,
value: string|null,
): void {
let i = 0;
// Assume that new markers will be inserted at the end.
let markerInsertPosition = dst.length;
Expand Down
Loading

0 comments on commit ada559b

Please sign in to comment.