Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
fix(core): recover embedded views whose first creation pass was incom…
…plete

When a directive throws during an embedded view's first creation pass,
Angular marks the embedded TView as `incompleteFirstPass` and sets
`firstCreatePass` to `false`. Any TNodes that hadn't been reached yet
(because the template function exited early) remain null in the TView's
data array.

On re-render (e.g. toggling an `@if` off then back on), the declaration
functions — `declareDirectiveHostTemplate` and `declareNoDirectiveHostTemplate` —
previously guarded TNode creation behind `firstCreatePass` only. With
`firstCreatePass=false` and `data[index]=null`, they fell through to the
else branch and passed `null` to `templateCreate`, which then crashed at
`tNode.flags |= flags`:

```
  TypeError: Cannot read properties of null (reading 'flags')
  at templateCreate (chunk-S65TXBMT.js:3:72993)
  ...
```

This was observed in production via Rollbar and confirmed reproducible
only in hydration mode (hydration enables the `locateOrCreateContainerAnchor`
path that processes all template containers on creation, making the null
slot reachable).

Fix: extend the guards in all three functions to also trigger when the
relevant slot is uninitialized:
- `declareDirectiveHostTemplate` / `declareNoDirectiveHostTemplate`: add
  `|| data[adjustedIndex] === null` so the TNode is (re-)created when
  missing.
- `templateCreate`: add `|| tNode.tView === null` so the embedded TView is
  created for a freshly constructed TNode even when firstCreatePass is
  already false.

This mirrors the existing recovery logic in `getOrCreateComponentTView`,
which already checks `incompleteFirstPass` before reusing a component TView.
  • Loading branch information
arturovt committed Apr 13, 2026
commit 081aa7371438a1d1f4574c427e656c6f5f13d7f9
6 changes: 3 additions & 3 deletions packages/core/src/render3/instructions/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ function templateCreate(
vars: number,
flags?: TNodeFlags,
) {
if (declarationTView.firstCreatePass) {
if (declarationTView.firstCreatePass || tNode.tView === null) {
// Merge the template attrs last so that they have the highest priority.
tNode.mergedAttrs = mergeHostAttrs(tNode.mergedAttrs, tNode.attrs);

Expand Down Expand Up @@ -144,7 +144,7 @@ function declareDirectiveHostTemplate(
const adjustedIndex = index + HEADER_OFFSET;
let tNode: TContainerNode;

if (declarationTView.firstCreatePass) {
if (declarationTView.firstCreatePass || declarationTView.data[adjustedIndex] === null) {
// TODO(pk): refactor getOrCreateTNode to have the "create" only version
tNode = getOrCreateTNode(
declarationTView,
Expand Down Expand Up @@ -213,7 +213,7 @@ export function declareNoDirectiveHostTemplate(
const adjustedIndex = index + HEADER_OFFSET;
let tNode: TContainerNode;

if (declarationTView.firstCreatePass) {
if (declarationTView.firstCreatePass || declarationTView.data[adjustedIndex] === null) {
tNode = getOrCreateTNode(
declarationTView,
adjustedIndex,
Expand Down
73 changes: 73 additions & 0 deletions packages/platform-server/test/full_app_hydration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6725,6 +6725,79 @@ describe('platform-server full application hydration integration', () => {
// such as "injector has already been destroyed."
expect(errorSpy).not.toHaveBeenCalled();
});

it('should re-render an embedded view whose first creation pass was incomplete', async () => {
// Tracks whether the directive should throw — only on the first instantiation.
let shouldThrow = true;

@Directive({
selector: '[throwOnCreate]',
})
class ThrowOnCreateDirective {
constructor() {
if (shouldThrow) {
shouldThrow = false;
throw new Error('Expected error during first creation pass');
}
}
}

// Records all errors passed to ErrorHandler so we can assert on them.
class RecordingErrorHandler extends ErrorHandler {
errors: Error[] = [];
override handleError(e: Error) {
this.errors.push(e);
}
}
const errorHandler = new RecordingErrorHandler();

@Component({
selector: 'app',
imports: [ThrowOnCreateDirective],
// Template: outer @if contains a directive-throwing element followed by a nested @if.
// If incompleteFirstPass is not handled, the nested @if TNode is never stored,
// and re-rendering after toggling crashes with "Cannot read properties of null
// (reading 'flags')" (happens in hydration mode).
template: `
@if (show()) {
<div throwOnCreate></div>
@if (show2()) {
<span>inner content</span>
}
}
`,
})
class AppCmp {
readonly show = signal(false); // Start false so initial bootstrap succeeds without error.
readonly show2 = signal(true);
}

const html = `<html><head></head><body><app></app></body></html>`;
const appRef = await prepareEnvironmentAndHydrate(doc, html, AppCmp, {
envProviders: [{provide: ErrorHandler, useValue: errorHandler}],
});
const compRef = getComponentRef<AppCmp>(appRef);

// Trigger first embedded view creation: directive throws → incompleteFirstPass=true on
// the embedded TView; the nested @if TNode is never stored in data[].
compRef.instance.show.set(true);
await appRef.whenStable();
expect(errorHandler.errors.length).toBe(1);
expect(errorHandler.errors[0].message).toBe('Expected error during first creation pass');

// Toggle @if off.
compRef.instance.show.set(false);
await appRef.whenStable();

// Re-enable show. Before the fix this would add a second error:
// "TypeError: Cannot read properties of null (reading 'flags')"
compRef.instance.show.set(true);
await appRef.whenStable();
expect(errorHandler.errors.length).toBe(1); // No new errors from the recovery render.

const el = compRef.location.nativeElement;
expect(el.querySelector('span')?.textContent).toBe('inner content');
});
});

describe('@if', () => {
Expand Down
Loading