Geography facts admin: editing a fact with no source crashes the edit form (TypeError: this.field(...) is not a function) #902

Closed
opened 2026-07-30 04:36:52 +00:00 by spikerj · 1 comment
Owner

Found while adding render coverage for geography-facts-management.component.html (wave-5 coverage loop, angular branch test/component-coverage-wave5).

Symptom

Admin → Geography facts → pick a country → Edit on any fact whose source is empty throws during render and the inline edit form fails to appear:

TypeError: this.field(...) is not a function
  at FormField.state (@angular/forms/fesm2022/signals.mjs:1250)
  at FormField.ɵngControlUpdate (@angular/forms/fesm2022/signals.mjs:1113)
  at _GeographyFactsManagementComponent_ng_container_0_Conditional_2_Conditional_42_For_2_Conditional_2_Template

Editing a fact that does have a source works normally.

Root cause

toggleEdit() (geography-facts-management.component.ts:215) seeds the edit draft with undefined when the fact has no source:

this.editDraft.set({
  title: fact.title,
  content: fact.content,
  source: fact.source ?? undefined,   // <-- fact.source is `string | null`
});

The draft is a Partial<SubmitFactRequest> behind a Signal Forms FieldTree:

editDraft = signal<Partial<SubmitFactRequest>>({});
protected readonly editDraftForm = form(this.editDraft);

and the template binds [formField]="editDraftForm.source" (line 271). When the underlying value for that path is undefined, the FieldTree node is not a callable field, so ɵngControlUpdate → this.field(...) throws.

Confirmed with a three-way probe against the real component (map child stubbed so the template could mount at all):

draft source result
"USGS" (string) renders fine
null → ?? undefined TypeError
key omitted entirely TypeError

So it is the undefined value, not the null specifically — the same crash occurs for any Partial key that is missing.

GeographicFactDto.source is string | null, and the create form treats source as optional, so facts with no source are the normal case — this is not an edge case.

Impact

Admins cannot edit any fact that was submitted without a source URL. The edit panel simply fails to render (the click appears to do nothing, with an error in the console).

Fix sketch

Two options, both small:

  1. Seed empty strings instead of undefined — source: fact.source ?? "". Matches what the input expects and keeps the FieldTree fully populated. Check saveEdit() so an empty string is normalised back to null/omitted on submit rather than persisting "".
  2. Make the draft a full (non-Partial) model with an EMPTY_EDIT_FACT() factory, mirroring how the create form already does it with EMPTY_CREATE_FACT(). This is the more robust fix — a Partial model behind form() is a latent trap for every optional key, not just source.

Option 2 matches the existing pattern in this same component and the .cursor/rules/signal-forms.md guidance (model signal + schema validators).

Coverage note

geography-facts-management.render.spec.ts contains a characterization test naming this ticket which asserts the current throwing behavior, plus normal-path edit tests that pass a non-null source. Update the characterization test as part of the fix — it is written to fail once editing a source-less fact works.

Found while adding render coverage for `geography-facts-management.component.html` (wave-5 coverage loop, angular branch `test/component-coverage-wave5`). ## Symptom **Admin → Geography facts → pick a country → Edit** on any fact whose **source is empty** throws during render and the inline edit form fails to appear: ``` TypeError: this.field(...) is not a function at FormField.state (@angular/forms/fesm2022/signals.mjs:1250) at FormField.ɵngControlUpdate (@angular/forms/fesm2022/signals.mjs:1113) at _GeographyFactsManagementComponent_ng_container_0_Conditional_2_Conditional_42_For_2_Conditional_2_Template ``` Editing a fact that **does** have a source works normally. ## Root cause `toggleEdit()` (`geography-facts-management.component.ts:215`) seeds the edit draft with `undefined` when the fact has no source: ```ts this.editDraft.set({ title: fact.title, content: fact.content, source: fact.source ?? undefined, // <-- fact.source is `string | null` }); ``` The draft is a `Partial<SubmitFactRequest>` behind a Signal Forms FieldTree: ```ts editDraft = signal<Partial<SubmitFactRequest>>({}); protected readonly editDraftForm = form(this.editDraft); ``` and the template binds `[formField]="editDraftForm.source"` (line 271). When the underlying value for that path is `undefined`, the FieldTree node is not a callable field, so `ɵngControlUpdate` → `this.field(...)` throws. Confirmed with a three-way probe against the real component (map child stubbed so the template could mount at all): | draft `source` | result | | --- | --- | | `"USGS"` (string) | renders fine | | `null` → `?? undefined` | **TypeError** | | key omitted entirely | **TypeError** | So it is the `undefined` value, not the `null` specifically — the same crash occurs for any `Partial` key that is missing. `GeographicFactDto.source` is `string | null`, and the create form treats source as optional, so **facts with no source are the normal case** — this is not an edge case. ## Impact Admins cannot edit any fact that was submitted without a source URL. The edit panel simply fails to render (the click appears to do nothing, with an error in the console). ## Fix sketch Two options, both small: 1. **Seed empty strings instead of `undefined`** — `source: fact.source ?? ""`. Matches what the input expects and keeps the FieldTree fully populated. Check `saveEdit()` so an empty string is normalised back to `null`/omitted on submit rather than persisting `""`. 2. **Make the draft a full (non-`Partial`) model with an `EMPTY_EDIT_FACT()` factory**, mirroring how the create form already does it with `EMPTY_CREATE_FACT()`. This is the more robust fix — a `Partial` model behind `form()` is a latent trap for every optional key, not just `source`. Option 2 matches the existing pattern in this same component and the `.cursor/rules/signal-forms.md` guidance (model signal + schema validators). ## Coverage note `geography-facts-management.render.spec.ts` contains a characterization test naming this ticket which asserts the current throwing behavior, plus normal-path edit tests that pass a non-null source. **Update the characterization test as part of the fix** — it is written to fail once editing a source-less fact works.
Author
Owner

Migrated to spikerj/spikersoft-angular#650 as part of the umbrella-tracker breakup.

Verified 2026-08-07 against spikersoft-angular@8e5a404, and live against the deployed main-KZKZUOSX.js bundle on learn.spikersoft.com.

  • Code: Unchanged. projects/spikersoft/src/app/_components/admin/geography-facts-management/geography-facts-management.component.ts:104-105 — editDraft = signal<Partial<SubmitFactRequest>>({}) behind form(this.editDraft); toggleEdit() at :215-219 still seeds source: fact.source ?? undefined; the template still binds [formField]="editDraftForm.source" at geography-facts-management.component.html:271. The Partial model behind form() is intact, so every optional key carries the same trap.
  • Live: Confirmed deployed. learn.spikersoft.com → main-KZKZUOSX.js → chunk-rBzRkADA.js contains this.editDraft.set({title:t.title,content:t.content,source:t.source??void 0}) — the exact undefined seed that makes the FieldTree node non-callable. Admins editing a source-less fact hit this in production right now.

Status: not done — The fix (option 2 preferred: a full non-Partial model with an EMPTY_EDIT_FACT() factory, mirroring EMPTY_CREATE_FACT() in the same component and .cursor/rules/signal-forms.md), plus normalising empty strings back to null/omitted in saveEdit(), plus flipping the characterization test in geography-facts-management.render.spec.ts.

Closing here. Work now lives in the repo that holds the fix, so fixes #650 in a PR will auto-close it on merge. The umbrella tracker keeps cross-repo epics only.

— Opus 5 Agent

Migrated to **spikerj/spikersoft-angular#650** as part of the umbrella-tracker breakup. Verified 2026-08-07 against `spikersoft-angular@8e5a404`, and live against the deployed `main-KZKZUOSX.js` bundle on learn.spikersoft.com. - **Code:** Unchanged. `projects/spikersoft/src/app/_components/admin/geography-facts-management/geography-facts-management.component.ts:104-105` — `editDraft = signal<Partial<SubmitFactRequest>>({})` behind `form(this.editDraft)`; `toggleEdit()` at `:215-219` still seeds `source: fact.source ?? undefined`; the template still binds `[formField]="editDraftForm.source"` at `geography-facts-management.component.html:271`. The `Partial` model behind `form()` is intact, so every optional key carries the same trap. - **Live:** **Confirmed deployed.** `learn.spikersoft.com` → `main-KZKZUOSX.js` → `chunk-rBzRkADA.js` contains `this.editDraft.set({title:t.title,content:t.content,source:t.source??void 0})` — the exact `undefined` seed that makes the FieldTree node non-callable. Admins editing a source-less fact hit this in production right now. Status: **not done** — The fix (option 2 preferred: a full non-`Partial` model with an `EMPTY_EDIT_FACT()` factory, mirroring `EMPTY_CREATE_FACT()` in the same component and `.cursor/rules/signal-forms.md`), plus normalising empty strings back to `null`/omitted in `saveEdit()`, plus flipping the characterization test in `geography-facts-management.render.spec.ts`. Closing here. Work now lives in the repo that holds the fix, so `fixes #650` in a PR will auto-close it on merge. The umbrella tracker keeps cross-repo epics only. — Opus 5 Agent
Sign in to join this conversation.