Skip to content

Commit 97eea8d

Browse files
crisbetoAndrewKushnir
authored andcommitted
fix(core): resolve error for multiple component instances that use fallback content (#55478)
Currently fallback content for `ng-content` gets declared and rendered out in one go. This breaks down if multiple instances of the same component are used where one doesn't render the fallback content while the other one does, because the `TNode` for the content has to be created during the first creation pass. These changes resolve the issue by always _declaring_ the template, but only rendering it if the slot is empty. Fixes #55466. PR Close #55478
1 parent e54c950 commit 97eea8d

File tree

4 files changed

+116
-14
lines changed

4 files changed

+116
-14
lines changed

packages/compiler/src/template/pipeline/src/ingest.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,10 +357,11 @@ function ingestContent(unit: ViewCompilationUnit, content: t.Content): void {
357357
throw Error(`Unhandled i18n metadata type for element: ${content.i18n.constructor.name}`);
358358
}
359359

360-
const id = unit.job.allocateXrefId();
361360
let fallbackView: ViewCompilationUnit | null = null;
362361

363362
// Don't capture default content that's only made up of empty text nodes and comments.
363+
// Note that we process the default content before the projection in order to match the
364+
// insertion order at runtime.
364365
if (
365366
content.children.some(
366367
(child) =>
@@ -372,6 +373,7 @@ function ingestContent(unit: ViewCompilationUnit, content: t.Content): void {
372373
ingestNodes(fallbackView, content.children);
373374
}
374375

376+
const id = unit.job.allocateXrefId();
375377
const op = ir.createProjectionOp(
376378
id,
377379
content.selector,

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

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88
import {findMatchingDehydratedView} from '../../hydration/views';
99
import {newArray} from '../../util/array_utils';
10-
import {assertLContainer} from '../assert';
10+
import {assertLContainer, assertTNode} from '../assert';
1111
import {ComponentTemplate} from '../interfaces/definition';
1212
import {TAttributes, TElementNode, TNode, TNodeFlags, TNodeType} from '../interfaces/node';
1313
import {ProjectionSlots} from '../interfaces/projection';
@@ -132,6 +132,17 @@ export function ɵɵprojection(
132132
fallbackVars?: number): void {
133133
const lView = getLView();
134134
const tView = getTView();
135+
const fallbackIndex = fallbackTemplateFn ? nodeIndex + 1 : null;
136+
137+
// Fallback content needs to be declared no matter whether the slot is empty since different
138+
// instances of the component may or may not insert it. Also it needs to be declare *before*
139+
// the projection node in order to work correctly with hydration.
140+
if (fallbackIndex !== null) {
141+
declareTemplate(
142+
lView, tView, fallbackIndex, fallbackTemplateFn!, fallbackDecls!, fallbackVars!, null,
143+
attrs);
144+
}
145+
135146
const tProjectionNode =
136147
getOrCreateTNode(tView, HEADER_OFFSET + nodeIndex, TNodeType.Projection, null, attrs || null);
137148

@@ -149,9 +160,8 @@ export function ɵɵprojection(
149160
const componentHostNode = lView[DECLARATION_COMPONENT_VIEW][T_HOST] as TElementNode;
150161
const isEmpty = componentHostNode.projection![tProjectionNode.projection] === null;
151162

152-
if (isEmpty && fallbackTemplateFn) {
153-
insertFallbackContent(
154-
lView, tView, nodeIndex, fallbackTemplateFn, fallbackDecls!, fallbackVars!, attrs);
163+
if (isEmpty && fallbackIndex !== null) {
164+
insertFallbackContent(lView, tView, fallbackIndex);
155165
} else if (
156166
isNodeCreationMode &&
157167
(tProjectionNode.flags & TNodeFlags.isDetached) !== TNodeFlags.isDetached) {
@@ -161,13 +171,11 @@ export function ɵɵprojection(
161171
}
162172

163173
/** Inserts the fallback content of a projection slot. Assumes there's no projected content. */
164-
function insertFallbackContent(
165-
lView: LView, tView: TView, projectionIndex: number, templateFn: ComponentTemplate<unknown>,
166-
decls: number, vars: number, attrs: TAttributes|undefined) {
167-
const fallbackIndex = projectionIndex + 1;
168-
const fallbackTNode =
169-
declareTemplate(lView, tView, fallbackIndex, templateFn, decls, vars, null, attrs);
170-
const fallbackLContainer = lView[HEADER_OFFSET + fallbackIndex];
174+
function insertFallbackContent(lView: LView, tView: TView, fallbackIndex: number) {
175+
const adjustedIndex = HEADER_OFFSET + fallbackIndex;
176+
const fallbackTNode = tView.data[adjustedIndex] as TNode;
177+
const fallbackLContainer = lView[adjustedIndex];
178+
ngDevMode && assertTNode(fallbackTNode);
171179
ngDevMode && assertLContainer(fallbackLContainer);
172180

173181
const dehydratedView = findMatchingDehydratedView(fallbackLContainer, fallbackTNode.tView!.ssrId);

packages/core/test/acceptance/content_spec.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1808,5 +1808,95 @@ describe('projection', () => {
18081808
expect(content).toContain('Outer header override');
18091809
expect(content).toContain('Outer footer fallback');
18101810
});
1811+
1812+
it('should not instantiate directives inside the fallback content', () => {
1813+
let creationCount = 0;
1814+
1815+
@Component({
1816+
selector: 'fallback',
1817+
standalone: true,
1818+
template: 'Fallback',
1819+
})
1820+
class Fallback {
1821+
constructor() {
1822+
creationCount++;
1823+
}
1824+
}
1825+
1826+
@Component({
1827+
selector: 'projection',
1828+
template: `<ng-content><fallback/></ng-content>`,
1829+
standalone: true,
1830+
imports: [Fallback],
1831+
})
1832+
class Projection {
1833+
}
1834+
1835+
@Component({
1836+
standalone: true,
1837+
imports: [Projection],
1838+
template: `<projection>Hello</projection>`,
1839+
})
1840+
class App {
1841+
}
1842+
1843+
const fixture = TestBed.createComponent(App);
1844+
expect(creationCount).toBe(0);
1845+
expect(getElementHtml(fixture.nativeElement)).toContain(`<projection>Hello</projection>`);
1846+
});
1847+
1848+
it('should render the fallback content when an instance of a component that uses ' +
1849+
'fallback content is declared after one that does not',
1850+
() => {
1851+
@Component({
1852+
selector: 'projection',
1853+
template: `<ng-content>Fallback</ng-content>`,
1854+
standalone: true,
1855+
})
1856+
class Projection {
1857+
}
1858+
1859+
@Component({
1860+
standalone: true,
1861+
imports: [Projection],
1862+
template: `
1863+
<projection>Content</projection>
1864+
<projection/>
1865+
`
1866+
})
1867+
class App {
1868+
}
1869+
1870+
const fixture = TestBed.createComponent(App);
1871+
expect(getElementHtml(fixture.nativeElement))
1872+
.toContain('<projection>Content</projection><projection>Fallback</projection>');
1873+
});
1874+
1875+
it('should render the fallback content when an instance of a component that uses ' +
1876+
'fallback content is declared before one that does not',
1877+
() => {
1878+
@Component({
1879+
selector: 'projection',
1880+
template: `<ng-content>Fallback</ng-content>`,
1881+
standalone: true,
1882+
})
1883+
class Projection {
1884+
}
1885+
1886+
@Component({
1887+
standalone: true,
1888+
imports: [Projection],
1889+
template: `
1890+
<projection/>
1891+
<projection>Content</projection>
1892+
`
1893+
})
1894+
class App {
1895+
}
1896+
1897+
const fixture = TestBed.createComponent(App);
1898+
expect(getElementHtml(fixture.nativeElement))
1899+
.toContain('<projection>Fallback</projection><projection>Content</projection>');
1900+
});
18111901
});
18121902
});

packages/platform-server/test/hydration_spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5987,9 +5987,11 @@ describe('platform-server hydration integration', () => {
59875987
const content = clientRootNode.innerHTML;
59885988
verifyAllNodesClaimedForHydration(clientRootNode);
59895989
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
5990-
expect(content).toContain('Header slot: <header>Header override</header>');
5990+
expect(content).toContain('Header slot: <!--container--><header>Header override</header>');
59915991
expect(content).toContain('Main slot: <main>Main fallback</main>');
5992-
expect(content).toContain('Footer slot: <footer><h1>Footer override 321</h1></footer>');
5992+
expect(content).toContain(
5993+
'Footer slot: <!--container--><footer><h1>Footer override 321</h1></footer>',
5994+
);
59935995
expect(content).toContain('Wildcard fallback');
59945996
});
59955997
});

0 commit comments

Comments
 (0)