[Bug][Frontend] NG0951 console spam on C# playground load — viewChild.required(LanguageRunner) read during CD before the child mounts #609

Closed
opened 2026-07-16 04:32:21 +00:00 by spikerj · 1 comment
Owner

Symptom

Loading a C# playground page logs a repeating ERROR w: NG0951 (with an Object.computation → runner → runnerOrNull → hasAnyOutput style stack) at bootstrap. Observed live on build 2026.07.16b. It doesn't crash the page — the runner mounts a tick later and the bindings recover — but it's real console noise and a latent correctness hazard.

Note: this is NOT the cause of the Spanish-localization bug (spikersoft-issues#608). A throwing computed can only blank a binding, never turn Spanish content into English, and the localization miss was proven to be server-side. Filing this separately as its own defect (surfaced while investigating #608).

Location

libraries/platform/clang-runtime/src/lib/language-playground-shell.component.ts

// line 84
private readonly runner = viewChild.required(LanguageRunner);

Root cause

viewChild.required() throws (NG0951) when its signal is read before the child view exists — it does not return null/undefined initially. The component reads it during the first change-detection pass (the sidebar/results bindings are computed()s that Angular evaluates before <app-language-runner> has mounted).

The mitigation is applied inconsistently:

  • runnerOrNull() (line 534) correctly wraps the read in try/catch and returns null, so the ~30 chrome/result bindings that go through it are safe.
  • But four computeds read the required signal directly, unguarded:
    • lessons (line 183) — this.runner()
    • completedLessonNumbers (line 188) — this.runner()
    • selectedLessonNumber (line 192) — this.runner()?.selectedLessonNumber() ?? null
    • curriculumTitle (line 194) — this.runner()?.curriculumTitle ?? "Curriculum"

The ?. on lines 192/194 is a false safeguard: this.runner() throws on the read itself, before optional chaining is ever applied. So these four throw NG0951 on every initial render.

The comment at lines 178–180 documents the opposite of the actual behavior and is the source of the mistake:

// Each one safely defaults to an empty value before the language-runner
// has finished mounting (`viewChild.required` is a signal that fires `null`
// initially). After mount these mirror the runner's own state perfectly.

viewChild.required does not "fire null initially" — that's viewChild() (optional). This comment should be corrected regardless of the fix chosen.

Recommended fix

Prefer making the query optional so the whole file's ?./?? default pattern becomes correct and the try/catch crutch is no longer needed:

private readonly runner = viewChild(LanguageRunner); // returns undefined until mounted

Then every this.runner()?.… (including the four unguarded computeds) short-circuits cleanly, runnerOrNull() can be simplified (or kept as a harmless alias), and the misleading comment becomes true.

Alternatively (narrower), route the four direct reads through runnerOrNull() to match the other bindings — but that leaves the footgun (a future direct this.runner() read reintroduces NG0951) and the wrong comment.

Verification to add

  • A shell component spec that instantiates the component and triggers CD before projecting the child, asserting no NG0951 is thrown and the sidebar bindings default cleanly (lessons() === [], curriculumTitle() === "Curriculum", etc.).
## Symptom Loading a C# playground page logs a repeating `ERROR w: NG0951` (with an `Object.computation → runner → runnerOrNull → hasAnyOutput` style stack) at bootstrap. Observed live on build `2026.07.16b`. It doesn't crash the page — the runner mounts a tick later and the bindings recover — but it's real console noise and a latent correctness hazard. Note: this is **NOT** the cause of the Spanish-localization bug (spikersoft-issues#608). A throwing `computed` can only blank a binding, never turn Spanish content into English, and the localization miss was proven to be server-side. Filing this separately as its own defect (surfaced while investigating #608). ## Location `libraries/platform/clang-runtime/src/lib/language-playground-shell.component.ts` ```ts // line 84 private readonly runner = viewChild.required(LanguageRunner); ``` ## Root cause `viewChild.required()` **throws** (NG0951) when its signal is read before the child view exists — it does **not** return `null`/`undefined` initially. The component reads it during the first change-detection pass (the sidebar/results bindings are `computed()`s that Angular evaluates before `<app-language-runner>` has mounted). The mitigation is applied **inconsistently**: - `runnerOrNull()` (line 534) correctly wraps the read in `try/catch` and returns `null`, so the ~30 chrome/result bindings that go through it are safe. - But **four computeds read the required signal directly, unguarded**: - `lessons` (line 183) — `this.runner()` - `completedLessonNumbers` (line 188) — `this.runner()` - `selectedLessonNumber` (line 192) — `this.runner()?.selectedLessonNumber() ?? null` - `curriculumTitle` (line 194) — `this.runner()?.curriculumTitle ?? "Curriculum"` The `?.` on lines 192/194 is a false safeguard: `this.runner()` **throws on the read itself**, before optional chaining is ever applied. So these four throw NG0951 on every initial render. The comment at lines 178–180 documents the *opposite* of the actual behavior and is the source of the mistake: ``` // Each one safely defaults to an empty value before the language-runner // has finished mounting (`viewChild.required` is a signal that fires `null` // initially). After mount these mirror the runner's own state perfectly. ``` `viewChild.required` does not "fire null initially" — that's `viewChild()` (optional). This comment should be corrected regardless of the fix chosen. ## Recommended fix Prefer **making the query optional** so the whole file's `?.`/`?? default` pattern becomes correct and the `try/catch` crutch is no longer needed: ```ts private readonly runner = viewChild(LanguageRunner); // returns undefined until mounted ``` Then every `this.runner()?.…` (including the four unguarded computeds) short-circuits cleanly, `runnerOrNull()` can be simplified (or kept as a harmless alias), and the misleading comment becomes true. Alternatively (narrower), route the four direct reads through `runnerOrNull()` to match the other bindings — but that leaves the footgun (a future direct `this.runner()` read reintroduces NG0951) and the wrong comment. ## Verification to add - A shell component spec that instantiates the component and triggers CD **before** projecting the child, asserting no NG0951 is thrown and the sidebar bindings default cleanly (`lessons() === []`, `curriculumTitle() === "Curriculum"`, etc.).
Author
Owner

Resolved in spikersoft-angular PR #194 (merged to master). The runner query in language-playground-shell.component.ts is now the optional viewChild(LanguageRunner) — every sidebar/result binding short-circuits to its documented default before <app-language-runner> mounts, so the first CD pass no longer throws NG0951. runnerOrNull() lost its try/catch crutch and the misleading "fires null initially" comment was corrected. A new pre-mount spec (mutation-checked: it fails with NG0951 against the old viewChild.required) guards the regression. Closing.

Resolved in spikersoft-angular PR #194 (merged to `master`). The runner query in `language-playground-shell.component.ts` is now the optional `viewChild(LanguageRunner)` — every sidebar/result binding short-circuits to its documented default before `<app-language-runner>` mounts, so the first CD pass no longer throws NG0951. `runnerOrNull()` lost its `try/catch` crutch and the misleading "fires null initially" comment was corrected. A new pre-mount spec (mutation-checked: it fails with NG0951 against the old `viewChild.required`) guards the regression. Closing.
Sign in to join this conversation.