Skip to content

Commit a94b66f

Browse files
refactor(core): avoid ngSkipHydration on nodes other than component host ones (#49500)
Angular Hydration uses Components as a hydration boundary, i.e. you can enable/disable hydration on per-component basis. This commit enforces that the `ngSkipHydration` can only be applied on component host nodes (an error if thrown otherwise). PR Close #49500
1 parent a0c289c commit a94b66f

File tree

3 files changed

+78
-8
lines changed

3 files changed

+78
-8
lines changed

packages/core/src/hydration/error_handling.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,11 @@ export function unsupportedProjectionOfDomNodes(): Error {
6262
'Hydration is not supported for such cases, consider refactoring the code to avoid ' +
6363
'this pattern or using `ngSkipHydration` on the host element of the component.');
6464
}
65+
66+
export function invalidSkipHydrationHost() {
67+
// TODO: improve error message and use RuntimeError instead.
68+
return new Error(
69+
'The `ngSkipHydration` flag is applied on a node ' +
70+
'that doesn\'t act as a component host. Hydration can be ' +
71+
'skipped only on per-component basis.');
72+
}

packages/core/src/render3/instructions/element.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {validateMatchingNode, validateNodeExists} from '../../hydration/error_handling';
9+
import {invalidSkipHydrationHost, validateMatchingNode, validateNodeExists} from '../../hydration/error_handling';
1010
import {locateNextRNode} from '../../hydration/node_lookup_utils';
1111
import {hasNgSkipHydrationAttr} from '../../hydration/skip_hydration';
1212
import {getSerializedContainerViews, markRNodeAsClaimedByHydration, setSegmentHead} from '../../hydration/utils';
@@ -17,7 +17,7 @@ import {registerPostOrderHooks} from '../hooks';
1717
import {hasClassInput, hasStyleInput, TAttributes, TElementNode, TNode, TNodeFlags, TNodeType} from '../interfaces/node';
1818
import {Renderer} from '../interfaces/renderer';
1919
import {RElement} from '../interfaces/renderer_dom';
20-
import {isContentQueryHost, isDirectiveHost} from '../interfaces/type_checks';
20+
import {isComponentHost, isContentQueryHost, isDirectiveHost} from '../interfaces/type_checks';
2121
import {HEADER_OFFSET, HYDRATION, LView, RENDERER, TView} from '../interfaces/view';
2222
import {assertTNodeType} from '../node_assert';
2323
import {appendChild, clearElementContents, createElementNode, setupStaticAttributes} from '../node_manipulation';
@@ -231,11 +231,17 @@ function locateOrCreateElementNodeImpl(
231231
// Checks if the skip hydration attribute is present during hydration so we know to
232232
// skip attempting to hydrate this block.
233233
if (hydrationInfo && hasNgSkipHydrationAttr(tNode)) {
234-
enterSkipHydrationBlock(tNode);
235-
236-
// Since this isn't hydratable, we need to empty the node
237-
// so there's no duplicate content after render
238-
clearElementContents(renderer, native);
234+
if (isComponentHost(tNode)) {
235+
enterSkipHydrationBlock(tNode);
236+
237+
// Since this isn't hydratable, we need to empty the node
238+
// so there's no duplicate content after render
239+
clearElementContents(renderer, native);
240+
} else if (ngDevMode) {
241+
// If this is not a component host, throw an error.
242+
// Hydration can be skipped on per-component basis only.
243+
throw invalidSkipHydrationHost();
244+
}
239245
}
240246
return native;
241247
}

packages/platform-server/test/hydration_spec.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {CommonModule, DOCUMENT, isPlatformServer, NgComponentOutlet, NgFor, NgIf, NgTemplateOutlet} from '@angular/common';
10-
import {APP_ID, ApplicationRef, Component, ComponentRef, createComponent, destroyPlatform, ElementRef, EnvironmentInjector, getPlatform, inject, Input, PLATFORM_ID, Provider, TemplateRef, Type, ViewChild, ViewContainerRef, ɵgetComponentDef as getComponentDef, ɵprovideHydrationSupport as provideHydrationSupport, ɵsetDocument, ɵunescapeTransferStateContent as unescapeTransferStateContent} from '@angular/core';
10+
import {APP_ID, ApplicationRef, Component, ComponentRef, createComponent, destroyPlatform, Directive, ElementRef, EnvironmentInjector, getPlatform, inject, Input, PLATFORM_ID, Provider, TemplateRef, Type, ViewChild, ViewContainerRef, ɵgetComponentDef as getComponentDef, ɵprovideHydrationSupport as provideHydrationSupport, ɵsetDocument, ɵunescapeTransferStateContent as unescapeTransferStateContent} from '@angular/core';
1111
import {TestBed} from '@angular/core/testing';
1212
import {bootstrapApplication} from '@angular/platform-browser';
1313
import {first} from 'rxjs/operators';
@@ -1788,6 +1788,62 @@ describe('platform-server integration', () => {
17881788
verifyAllNodesClaimedForHydration(clientRootNode);
17891789
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
17901790
});
1791+
1792+
it('should throw when ngSkipHydration attribute is set on a node ' +
1793+
'which is not a component host',
1794+
async () => {
1795+
@Component({
1796+
standalone: true,
1797+
selector: 'app',
1798+
template: `
1799+
<header ngSkipHydration>Header</header>
1800+
<footer ngSkipHydration>Footer</footer>
1801+
`,
1802+
})
1803+
class SimpleComponent {
1804+
}
1805+
1806+
try {
1807+
await ssr(SimpleComponent);
1808+
} catch (e: unknown) {
1809+
expect((e as Error).toString())
1810+
.toContain(
1811+
'The `ngSkipHydration` flag is applied ' +
1812+
'on a node that doesn\'t act as a component host');
1813+
}
1814+
});
1815+
1816+
it('should throw when ngSkipHydration attribute is set on a node ' +
1817+
'which is not a component host (when using host bindings)',
1818+
async () => {
1819+
@Directive({
1820+
standalone: true,
1821+
selector: '[dir]',
1822+
host: {ngSkipHydration: 'true'},
1823+
})
1824+
class Dir {
1825+
}
1826+
1827+
@Component({
1828+
standalone: true,
1829+
selector: 'app',
1830+
imports: [Dir],
1831+
template: `
1832+
<div dir></div>
1833+
`,
1834+
})
1835+
class SimpleComponent {
1836+
}
1837+
1838+
try {
1839+
await ssr(SimpleComponent);
1840+
} catch (e: unknown) {
1841+
expect((e as Error).toString())
1842+
.toContain(
1843+
'The `ngSkipHydration` flag is applied ' +
1844+
'on a node that doesn\'t act as a component host');
1845+
}
1846+
});
17911847
});
17921848

17931849
describe('corrupted text nodes restoration', () => {

0 commit comments

Comments
 (0)